diff --git a/.gitattributes b/.gitattributes index aa889a5aeb..65a69428c6 100644 --- a/.gitattributes +++ b/.gitattributes @@ -50,6 +50,7 @@ ## Platform files generated by Flutter during `flutter create` **/example/android/** linguist-generated **/example/ios/** linguist-generated +**/example/ios/unit_tests/** linguist-generated=false **/example/linux/** linguist-generated **/example/macos/** linguist-generated **/example/windows/** linguist-generated @@ -71,6 +72,9 @@ ## Generated SDK files packages/**/lib/src/sdk/src/** linguist-generated +## Generated Swift Plugins +packages/amplify_datastore/ios/internal/** linguist-generated + ## Smithy files packages/smithy/goldens/lib/** linguist-generated packages/smithy/goldens/lib2/** linguist-generated diff --git a/packages/aft/lib/src/commands/generate/generate_amplify_swift_command.dart b/packages/aft/lib/src/commands/generate/generate_amplify_swift_command.dart new file mode 100644 index 0000000000..196e1953ac --- /dev/null +++ b/packages/aft/lib/src/commands/generate/generate_amplify_swift_command.dart @@ -0,0 +1,317 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import 'dart:async'; +import 'dart:io'; + +import 'package:aft/aft.dart'; +import 'package:aft/src/options/glob_options.dart'; +import 'package:async/async.dart'; +import 'package:git/git.dart'; +import 'package:io/io.dart'; +import 'package:path/path.dart' as p; + +class PluginConfig { + const PluginConfig({ + required this.name, + required this.remotePathToSource, + }); + + /// The name of the plugin. + final String name; + + /// The path to the plugin source files in the Amplify Swift repo. + final String remotePathToSource; +} + +/// Command for generating the Amplify Swift plugins for the DataStore plugin. +class GenerateAmplifySwiftCommand extends AmplifyCommand with GlobOptions { + GenerateAmplifySwiftCommand() { + argParser + ..addOption( + 'branch', + abbr: 'b', + help: 'The branch of Amplify Swift to target', + defaultsTo: 'release', + ) + ..addFlag( + 'diff', + abbr: 'd', + help: 'Show the diff of the generated files', + negatable: false, + defaultsTo: false, + ); + } + + @override + String get description => + 'Generates Amplify Swift DataStore for the DataStore plugin.'; + + @override + String get name => 'amplify-swift'; + + @override + bool get hidden => true; + + /// The branch of Amplify Swift to target. + /// + /// If not provided, defaults to `release`. + late final branchTarget = argResults!['branch'] as String; + + late final _dataStoreRootDir = + p.join(rootDir.path, 'packages/amplify_datastore'); + + final _pluginOutputDir = 'ios/internal'; + final _exampleOutputDir = 'example/ios'; + + /// Whether to check the diff of the generated files. + /// If not provided, defaults to `false`. + late final isDiff = argResults!['diff'] as bool; + + /// Cache of repos by git ref. + final _repoCache = {}; + final _cloneMemo = AsyncMemoizer(); + + final _amplifySwiftPlugins = [ + const PluginConfig( + name: 'AWSDataStorePlugin', + remotePathToSource: 'AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin', + ), + const PluginConfig( + name: 'AWSPluginsCore', + remotePathToSource: 'AmplifyPlugins/Core/AWSPluginsCore', + ), + const PluginConfig( + name: 'Amplify', + remotePathToSource: 'Amplify', + ), + ]; + + final _importsToRemove = [ + 'import Amplify', + 'import AWSPluginsCore', + 'import AWSDataStorePlugin', + ]; + + /// Downloads Amplify Swift from GitHub into a temporary directory. + Future _downloadRepository() => _cloneMemo.runOnce(() async { + final cloneDir = + await Directory.systemTemp.createTemp('amplify_swift_'); + logger + ..info('Downloading Amplify Swift...') + ..verbose('Cloning repo to ${cloneDir.path}'); + await runGit( + [ + 'clone', + // https://github.blog/2020-12-21-get-up-to-speed-with-partial-clone-and-shallow-clone/ + '--filter=tree:0', + 'https://github.com/aws-amplify/amplify-swift.git', + cloneDir.path, + ], + echoOutput: verbose, + ); + logger.info('Successfully cloned Amplify Swift Repo'); + return cloneDir; + }); + + /// Checks out [ref] in [pluginDir]. + Future _checkoutRepositoryRef( + Directory pluginDir, + String ref, + ) async { + logger + ..info('Checking out target branch: $ref') + ..verbose('Creating git work tree in $pluginDir'); + final worktreeDir = + await Directory.systemTemp.createTemp('amplify_swift_worktree_'); + try { + await runGit( + ['worktree', 'add', worktreeDir.path, ref], + processWorkingDir: pluginDir.path, + ); + } on Exception catch (e) { + if (e.toString().contains('already checked out')) { + return pluginDir; + } + rethrow; + } + return worktreeDir; + } + + /// Find and replaces the `import` statements in the plugin files. + Future _replaceImports(Directory pluginDir) async { + final files = await pluginDir.list(recursive: true).toList(); + for (final file in files) { + if (file is! File) { + continue; + } + // Only process Swift files. + if (!file.path.endsWith('.swift')) { + continue; + } + final contents = await file.readAsString(); + // remove the list of import statement for Amplify including line breaks + final newContents = contents.split('\n').where((line) { + return !_importsToRemove.any((import) => line.contains(import)); + }).join('\n'); + await file.writeAsString(newContents); + } + } + + /// Remove `info.plist` from the plugin files. + Future _removePListFiles(Directory pluginDir) async { + final files = await pluginDir.list(recursive: true).toList(); + for (final file in files) { + if (file is! File) { + continue; + } + // Only process Info.plist files. + if (!file.path.endsWith('Info.plist')) { + continue; + } + await file.delete(); + } + } + + /// Transforms the plugin files to Amplify Flutter requirements. + Future _transformPlugin(Directory directory) async { + logger + ..info('Transforming plugin files...') + ..verbose('In ${directory.path}'); + await _replaceImports(directory); + await _removePListFiles(directory); + } + + /// Sets up the Amplify Swift repo for use later + Future _setupRepo() async { + if (_repoCache[branchTarget] != null) { + return; + } + final repoDir = await _downloadRepository(); + final repoRef = await _checkoutRepositoryRef(repoDir, branchTarget); + + _repoCache[branchTarget] = repoRef; + } + + /// Returns the directory for the plugin at [path]. + Future _pluginDirForPath(String path) async { + final repoDir = _repoCache[branchTarget]; + if (repoDir == null) { + exitError('No cached repo for branch $branchTarget'); + } + + final pluginDir = Directory.fromUri( + repoDir.uri.resolve(path), + ); + + await _transformPlugin(pluginDir); + + return pluginDir; + } + + /// Generates the Amplify Swift plugin for [plugin]. + Future _generatePlugin(PluginConfig plugin) async { + logger.info('Selecting source files for ${plugin.name}...'); + + // The directory in the Amplify Swift repo where the plugin source files are. + final remotePluginDir = await _pluginDirForPath(plugin.remotePathToSource); + + // The local directory to copy the plugin files to. + final outputDir = Directory( + p.join(_dataStoreRootDir, _pluginOutputDir, plugin.name), + ); + + // Clear out the directory if it already exists. + // This is to ensure that we don't have any stale files. + if (await outputDir.exists()) { + logger.info( + 'Deleting existing plugin directory for ${plugin.name}...', + ); + await outputDir.delete(recursive: true); + } + await outputDir.create(recursive: true); + + // Copy the files from the repo to the plugin directory. + logger + ..info('Copying plugin files for ${plugin.name}...') + ..verbose('From $remotePluginDir to $outputDir'); + await copyPath(remotePluginDir.path, outputDir.path); + } + + Future checkDiff(PluginConfig plugin) async { + logger.info('Checking diff for ${plugin.name}...'); + final incoming = (await _pluginDirForPath(plugin.remotePathToSource)).path; + final current = p.join(_dataStoreRootDir, _pluginOutputDir, plugin.name); + final diffCmd = await Process.start( + 'git', + [ + 'diff', + '--no-index', + '--exit-code', + incoming, + current, + ], + mode: verbose ? ProcessStartMode.inheritStdio : ProcessStartMode.normal, + ); + final exitCode = await diffCmd.exitCode; + if (exitCode != 0) { + exitError( + '`diff` failed: $exitCode. There are differences between $incoming and $current', + ); + } + logger.info( + 'No differences between incoming and current for ${plugin.name}.', + ); + } + + /// Runs pod install after copying files to the plugin directory. + Future _podInstall() async { + final podFilePath = p.join(_dataStoreRootDir, _exampleOutputDir); + logger.verbose('Running pod install in $podFilePath...'); + + final podInstallCmd = await Process.start( + 'pod', + [ + 'install', + ], + mode: verbose ? ProcessStartMode.inheritStdio : ProcessStartMode.normal, + workingDirectory: podFilePath, + ); + final exitCode = await podInstallCmd.exitCode; + if (exitCode != 0) { + exitError('`pod install` failed: $exitCode.'); + } + } + + Future _runDiff() async { + await _setupRepo(); + for (final plugin in _amplifySwiftPlugins) { + await checkDiff(plugin); + } + logger.info( + 'Successfully checked diff for Amplify Swift plugins', + ); + } + + Future _runGenerate() async { + await _setupRepo(); + for (final plugin in _amplifySwiftPlugins) { + await _generatePlugin(plugin); + } + await _podInstall(); + logger.info('Successfully generated Amplify Swift plugins'); + } + + @override + Future run() async { + logger.info('Generating Amplify Swift plugins.'); + await super.run(); + switch (isDiff) { + case true: + await _runDiff(); + default: + await _runGenerate(); + break; + } + } +} diff --git a/packages/aft/lib/src/commands/generate/generate_command.dart b/packages/aft/lib/src/commands/generate/generate_command.dart index 92e95e3724..8aede5adda 100644 --- a/packages/aft/lib/src/commands/generate/generate_command.dart +++ b/packages/aft/lib/src/commands/generate/generate_command.dart @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import 'package:aft/aft.dart'; +import 'package:aft/src/commands/generate/generate_amplify_swift_command.dart'; import 'package:aft/src/commands/generate/generate_goldens_command.dart'; import 'package:aft/src/commands/generate/generate_sdk_command.dart'; import 'package:aft/src/commands/generate/generate_workflows_command.dart'; @@ -12,6 +13,7 @@ class GenerateCommand extends AmplifyCommand { addSubcommand(GenerateSdkCommand()); addSubcommand(GenerateWorkflowsCommand()); addSubcommand(GenerateGoldensCommand()); + addSubcommand(GenerateAmplifySwiftCommand()); } @override diff --git a/packages/aft/pubspec.yaml b/packages/aft/pubspec.yaml index 1fc101cc9b..e3864bc38b 100644 --- a/packages/aft/pubspec.yaml +++ b/packages/aft/pubspec.yaml @@ -20,6 +20,7 @@ dependencies: git: any # override glob: ^2.1.0 graphs: ^2.1.0 + io: ^1.0.4 json_annotation: ">=4.8.1 <4.9.0" markdown: ^5.0.0 mason: ^0.1.0-dev.40 diff --git a/packages/amplify_datastore/android/src/main/kotlin/com/amazonaws/amplify/amplify_datastore/AmplifyDataStorePlugin.kt b/packages/amplify_datastore/android/src/main/kotlin/com/amazonaws/amplify/amplify_datastore/AmplifyDataStorePlugin.kt index a122e7909f..0ad51df167 100644 --- a/packages/amplify_datastore/android/src/main/kotlin/com/amazonaws/amplify/amplify_datastore/AmplifyDataStorePlugin.kt +++ b/packages/amplify_datastore/android/src/main/kotlin/com/amazonaws/amplify/amplify_datastore/AmplifyDataStorePlugin.kt @@ -19,6 +19,7 @@ import com.amazonaws.amplify.amplify_datastore.pigeons.NativeApiPlugin import com.amazonaws.amplify.amplify_datastore.pigeons.NativeAuthBridge import com.amazonaws.amplify.amplify_datastore.pigeons.NativeAuthPlugin import com.amazonaws.amplify.amplify_datastore.pigeons.NativeAuthUser +import com.amazonaws.amplify.amplify_datastore.pigeons.NativeGraphQLSubscriptionResponse import com.amazonaws.amplify.amplify_datastore.types.model.FlutterCustomTypeSchema import com.amazonaws.amplify.amplify_datastore.types.model.FlutterModelSchema import com.amazonaws.amplify.amplify_datastore.types.model.FlutterSerializedModel @@ -920,6 +921,13 @@ class AmplifyDataStorePlugin : callback(kotlin.Result.failure(e)) } } + + override fun sendSubscriptionEvent( + event: NativeGraphQLSubscriptionResponse, + callback: (kotlin.Result) -> Unit + ) { + throw NotImplementedError("Not yet implemented") + } override fun configure( version: String, diff --git a/packages/amplify_datastore/android/src/main/kotlin/com/amazonaws/amplify/amplify_datastore/pigeons/NativePluginBindings.kt b/packages/amplify_datastore/android/src/main/kotlin/com/amazonaws/amplify/amplify_datastore/pigeons/NativePluginBindings.kt index 1d556a948f..9a832cdd1a 100644 --- a/packages/amplify_datastore/android/src/main/kotlin/com/amazonaws/amplify/amplify_datastore/pigeons/NativePluginBindings.kt +++ b/packages/amplify_datastore/android/src/main/kotlin/com/amazonaws/amplify/amplify_datastore/pigeons/NativePluginBindings.kt @@ -1,7 +1,7 @@ -// +// // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -// Autogenerated from Pigeon (v11.0.0), do not edit directly. +// Autogenerated from Pigeon (v11.0.1), do not edit directly. // See also: https://pub.dev/packages/pigeon package com.amazonaws.amplify.amplify_datastore.pigeons @@ -15,23 +15,23 @@ import java.io.ByteArrayOutputStream import java.nio.ByteBuffer private fun wrapResult(result: Any?): List { - return listOf(result) + return listOf(result) } private fun wrapError(exception: Throwable): List { - if (exception is FlutterError) { - return listOf( - exception.code, - exception.message, - exception.details - ) - } else { - return listOf( - exception.javaClass.simpleName, - exception.toString(), - "Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception) - ) - } + if (exception is FlutterError) { + return listOf( + exception.code, + exception.message, + exception.details + ) + } else { + return listOf( + exception.javaClass.simpleName, + exception.toString(), + "Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception) + ) + } } /** @@ -40,345 +40,550 @@ private fun wrapError(exception: Throwable): List { * @property message The error message. * @property details The error details. Must be a datatype supported by the api codec. */ -class FlutterError( - val code: String, - override val message: String? = null, - val details: Any? = null +class FlutterError ( + val code: String, + override val message: String? = null, + val details: Any? = null ) : Throwable() /** Generated class from Pigeon that represents data sent in messages. */ -data class NativeAuthSession( - val isSignedIn: Boolean, - val userSub: String? = null, - val userPoolTokens: NativeUserPoolTokens? = null, - val identityId: String? = null, - val awsCredentials: NativeAWSCredentials? = null +data class NativeAuthSession ( + val isSignedIn: Boolean, + val userSub: String? = null, + val userPoolTokens: NativeUserPoolTokens? = null, + val identityId: String? = null, + val awsCredentials: NativeAWSCredentials? = null ) { - companion object { - @Suppress("UNCHECKED_CAST") - fun fromList(list: List): NativeAuthSession { - val isSignedIn = list[0] as Boolean - val userSub = list[1] as String? - val userPoolTokens: NativeUserPoolTokens? = (list[2] as List?)?.let { - NativeUserPoolTokens.fromList(it) - } - val identityId = list[3] as String? - val awsCredentials: NativeAWSCredentials? = (list[4] as List?)?.let { - NativeAWSCredentials.fromList(it) - } - return NativeAuthSession(isSignedIn, userSub, userPoolTokens, identityId, awsCredentials) - } - } - fun toList(): List { - return listOf( - isSignedIn, - userSub, - userPoolTokens?.toList(), - identityId, - awsCredentials?.toList() - ) + companion object { + @Suppress("UNCHECKED_CAST") + fun fromList(list: List): NativeAuthSession { + val isSignedIn = list[0] as Boolean + val userSub = list[1] as String? + val userPoolTokens: NativeUserPoolTokens? = (list[2] as List?)?.let { + NativeUserPoolTokens.fromList(it) + } + val identityId = list[3] as String? + val awsCredentials: NativeAWSCredentials? = (list[4] as List?)?.let { + NativeAWSCredentials.fromList(it) + } + return NativeAuthSession(isSignedIn, userSub, userPoolTokens, identityId, awsCredentials) } + } + fun toList(): List { + return listOf( + isSignedIn, + userSub, + userPoolTokens?.toList(), + identityId, + awsCredentials?.toList(), + ) + } } /** Generated class from Pigeon that represents data sent in messages. */ -data class NativeAuthUser( - val userId: String, - val username: String +data class NativeAuthUser ( + val userId: String, + val username: String ) { - companion object { - @Suppress("UNCHECKED_CAST") - fun fromList(list: List): NativeAuthUser { - val userId = list[0] as String - val username = list[1] as String - return NativeAuthUser(userId, username) - } + companion object { + @Suppress("UNCHECKED_CAST") + fun fromList(list: List): NativeAuthUser { + val userId = list[0] as String + val username = list[1] as String + return NativeAuthUser(userId, username) } - fun toList(): List { - return listOf( - userId, - username - ) + } + fun toList(): List { + return listOf( + userId, + username, + ) + } +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class NativeUserPoolTokens ( + val accessToken: String, + val refreshToken: String, + val idToken: String + +) { + companion object { + @Suppress("UNCHECKED_CAST") + fun fromList(list: List): NativeUserPoolTokens { + val accessToken = list[0] as String + val refreshToken = list[1] as String + val idToken = list[2] as String + return NativeUserPoolTokens(accessToken, refreshToken, idToken) } + } + fun toList(): List { + return listOf( + accessToken, + refreshToken, + idToken, + ) + } } /** Generated class from Pigeon that represents data sent in messages. */ -data class NativeUserPoolTokens( - val accessToken: String, - val refreshToken: String, - val idToken: String +data class NativeAWSCredentials ( + val accessKeyId: String, + val secretAccessKey: String, + val sessionToken: String? = null, + val expirationIso8601Utc: String? = null ) { - companion object { - @Suppress("UNCHECKED_CAST") - fun fromList(list: List): NativeUserPoolTokens { - val accessToken = list[0] as String - val refreshToken = list[1] as String - val idToken = list[2] as String - return NativeUserPoolTokens(accessToken, refreshToken, idToken) - } + companion object { + @Suppress("UNCHECKED_CAST") + fun fromList(list: List): NativeAWSCredentials { + val accessKeyId = list[0] as String + val secretAccessKey = list[1] as String + val sessionToken = list[2] as String? + val expirationIso8601Utc = list[3] as String? + return NativeAWSCredentials(accessKeyId, secretAccessKey, sessionToken, expirationIso8601Utc) } - fun toList(): List { - return listOf( - accessToken, - refreshToken, - idToken - ) + } + fun toList(): List { + return listOf( + accessKeyId, + secretAccessKey, + sessionToken, + expirationIso8601Utc, + ) + } +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class NativeGraphQLResponse ( + val payloadJson: String? = null, + val errorsJson: String? = null + +) { + companion object { + @Suppress("UNCHECKED_CAST") + fun fromList(list: List): NativeGraphQLResponse { + val payloadJson = list[0] as String? + val errorsJson = list[1] as String? + return NativeGraphQLResponse(payloadJson, errorsJson) } + } + fun toList(): List { + return listOf( + payloadJson, + errorsJson, + ) + } } /** Generated class from Pigeon that represents data sent in messages. */ -data class NativeAWSCredentials( - val accessKeyId: String, - val secretAccessKey: String, - val sessionToken: String? = null, - val expirationIso8601Utc: String? = null +data class NativeGraphQLSubscriptionResponse ( + val type: String, + val subscriptionId: String, + val payloadJson: String? = null ) { - companion object { - @Suppress("UNCHECKED_CAST") - fun fromList(list: List): NativeAWSCredentials { - val accessKeyId = list[0] as String - val secretAccessKey = list[1] as String - val sessionToken = list[2] as String? - val expirationIso8601Utc = list[3] as String? - return NativeAWSCredentials(accessKeyId, secretAccessKey, sessionToken, expirationIso8601Utc) - } + companion object { + @Suppress("UNCHECKED_CAST") + fun fromList(list: List): NativeGraphQLSubscriptionResponse { + val type = list[0] as String + val subscriptionId = list[1] as String + val payloadJson = list[2] as String? + return NativeGraphQLSubscriptionResponse(type, subscriptionId, payloadJson) } - fun toList(): List { - return listOf( - accessKeyId, - secretAccessKey, - sessionToken, - expirationIso8601Utc - ) + } + fun toList(): List { + return listOf( + type, + subscriptionId, + payloadJson, + ) + } +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class NativeGraphQLRequest ( + val document: String, + val apiName: String? = null, + val variablesJson: String? = null, + val responseType: String? = null, + val decodePath: String? = null, + val options: String? = null + +) { + companion object { + @Suppress("UNCHECKED_CAST") + fun fromList(list: List): NativeGraphQLRequest { + val document = list[0] as String + val apiName = list[1] as String? + val variablesJson = list[2] as String? + val responseType = list[3] as String? + val decodePath = list[4] as String? + val options = list[5] as String? + return NativeGraphQLRequest(document, apiName, variablesJson, responseType, decodePath, options) } + } + fun toList(): List { + return listOf( + document, + apiName, + variablesJson, + responseType, + decodePath, + options, + ) + } } @Suppress("UNCHECKED_CAST") private object NativeAuthPluginCodec : StandardMessageCodec() { - override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { - return when (type) { - 128.toByte() -> { - return (readValue(buffer) as? List)?.let { - NativeAWSCredentials.fromList(it) - } - } - 129.toByte() -> { - return (readValue(buffer) as? List)?.let { - NativeAuthSession.fromList(it) - } - } - 130.toByte() -> { - return (readValue(buffer) as? List)?.let { - NativeUserPoolTokens.fromList(it) - } - } - else -> super.readValueOfType(type, buffer) + override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { + return when (type) { + 128.toByte() -> { + return (readValue(buffer) as? List)?.let { + NativeAWSCredentials.fromList(it) } - } - override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { - when (value) { - is NativeAWSCredentials -> { - stream.write(128) - writeValue(stream, value.toList()) - } - is NativeAuthSession -> { - stream.write(129) - writeValue(stream, value.toList()) - } - is NativeUserPoolTokens -> { - stream.write(130) - writeValue(stream, value.toList()) - } - else -> super.writeValue(stream, value) + } + 129.toByte() -> { + return (readValue(buffer) as? List)?.let { + NativeAuthSession.fromList(it) + } + } + 130.toByte() -> { + return (readValue(buffer) as? List)?.let { + NativeUserPoolTokens.fromList(it) } + } + else -> super.readValueOfType(type, buffer) + } + } + override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { + when (value) { + is NativeAWSCredentials -> { + stream.write(128) + writeValue(stream, value.toList()) + } + is NativeAuthSession -> { + stream.write(129) + writeValue(stream, value.toList()) + } + is NativeUserPoolTokens -> { + stream.write(130) + writeValue(stream, value.toList()) + } + else -> super.writeValue(stream, value) } + } } -/** Generated class from Pigeon that represents Flutter messages that can be called from Kotlin. */ +/** + * Bridge for calling Auth from Native into Flutter + * + * Generated class from Pigeon that represents Flutter messages that can be called from Kotlin. + */ @Suppress("UNCHECKED_CAST") class NativeAuthPlugin(private val binaryMessenger: BinaryMessenger) { - companion object { - /** The codec used by NativeAuthPlugin. */ - val codec: MessageCodec by lazy { - NativeAuthPluginCodec - } + companion object { + /** The codec used by NativeAuthPlugin. */ + val codec: MessageCodec by lazy { + NativeAuthPluginCodec + } + } + fun fetchAuthSession(callback: (NativeAuthSession) -> Unit) { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.amplify_datastore.NativeAuthPlugin.fetchAuthSession", codec) + channel.send(null) { + val result = it as NativeAuthSession + callback(result) } - fun fetchAuthSession(callback: (NativeAuthSession) -> Unit) { - val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.amplify_datastore.NativeAuthPlugin.fetchAuthSession", codec) - channel.send(null) { - val result = it as NativeAuthSession - callback(result) + } +} +@Suppress("UNCHECKED_CAST") +private object NativeApiPluginCodec : StandardMessageCodec() { + override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { + return when (type) { + 128.toByte() -> { + return (readValue(buffer) as? List)?.let { + NativeGraphQLRequest.fromList(it) } + } + 129.toByte() -> { + return (readValue(buffer) as? List)?.let { + NativeGraphQLResponse.fromList(it) + } + } + 130.toByte() -> { + return (readValue(buffer) as? List)?.let { + NativeGraphQLSubscriptionResponse.fromList(it) + } + } + else -> super.readValueOfType(type, buffer) + } + } + override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { + when (value) { + is NativeGraphQLRequest -> { + stream.write(128) + writeValue(stream, value.toList()) + } + is NativeGraphQLResponse -> { + stream.write(129) + writeValue(stream, value.toList()) + } + is NativeGraphQLSubscriptionResponse -> { + stream.write(130) + writeValue(stream, value.toList()) + } + else -> super.writeValue(stream, value) } + } } -/** Generated class from Pigeon that represents Flutter messages that can be called from Kotlin. */ +/** + * Bridge for calling API plugin from Native into Flutter + * + * Generated class from Pigeon that represents Flutter messages that can be called from Kotlin. + */ @Suppress("UNCHECKED_CAST") class NativeApiPlugin(private val binaryMessenger: BinaryMessenger) { - companion object { - /** The codec used by NativeApiPlugin. */ - val codec: MessageCodec by lazy { - StandardMessageCodec() - } + companion object { + /** The codec used by NativeApiPlugin. */ + val codec: MessageCodec by lazy { + NativeApiPluginCodec } - fun getLatestAuthToken(providerNameArg: String, callback: (String?) -> Unit) { - val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.amplify_datastore.NativeApiPlugin.getLatestAuthToken", codec) - channel.send(listOf(providerNameArg)) { - val result = it as String? - callback(result) - } + } + fun getLatestAuthToken(providerNameArg: String, callback: (String?) -> Unit) { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.amplify_datastore.NativeApiPlugin.getLatestAuthToken", codec) + channel.send(listOf(providerNameArg)) { + val result = it as String? + callback(result) + } + } + fun mutate(requestArg: NativeGraphQLRequest, callback: (NativeGraphQLResponse) -> Unit) { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.amplify_datastore.NativeApiPlugin.mutate", codec) + channel.send(listOf(requestArg)) { + val result = it as NativeGraphQLResponse + callback(result) + } + } + fun query(requestArg: NativeGraphQLRequest, callback: (NativeGraphQLResponse) -> Unit) { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.amplify_datastore.NativeApiPlugin.query", codec) + channel.send(listOf(requestArg)) { + val result = it as NativeGraphQLResponse + callback(result) + } + } + fun subscribe(requestArg: NativeGraphQLRequest, callback: (NativeGraphQLSubscriptionResponse) -> Unit) { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.amplify_datastore.NativeApiPlugin.subscribe", codec) + channel.send(listOf(requestArg)) { + val result = it as NativeGraphQLSubscriptionResponse + callback(result) } + } + fun unsubscribe(subscriptionIdArg: String, callback: () -> Unit) { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.amplify_datastore.NativeApiPlugin.unsubscribe", codec) + channel.send(listOf(subscriptionIdArg)) { + callback() + } + } } - -/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ +/** + * Bridge for calling Amplify from Flutter into Native + * + * Generated interface from Pigeon that represents a handler of messages from Flutter. + */ interface NativeAmplifyBridge { - fun configure(version: String, config: String, callback: (Result) -> Unit) + fun configure(version: String, config: String, callback: (Result) -> Unit) - companion object { - /** The codec used by NativeAmplifyBridge. */ - val codec: MessageCodec by lazy { - StandardMessageCodec() - } - - /** Sets up an instance of `NativeAmplifyBridge` to handle messages through the `binaryMessenger`. */ - @Suppress("UNCHECKED_CAST") - fun setUp(binaryMessenger: BinaryMessenger, api: NativeAmplifyBridge?) { - run { - val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.amplify_datastore.NativeAmplifyBridge.configure", codec) - if (api != null) { - channel.setMessageHandler { message, reply -> - val args = message as List - val versionArg = args[0] as String - val configArg = args[1] as String - api.configure(versionArg, configArg) { result: Result -> - val error = result.exceptionOrNull() - if (error != null) { - reply.reply(wrapError(error)) - } else { - reply.reply(wrapResult(null)) - } - } - } - } else { - channel.setMessageHandler(null) - } + companion object { + /** The codec used by NativeAmplifyBridge. */ + val codec: MessageCodec by lazy { + StandardMessageCodec() + } + /** Sets up an instance of `NativeAmplifyBridge` to handle messages through the `binaryMessenger`. */ + @Suppress("UNCHECKED_CAST") + fun setUp(binaryMessenger: BinaryMessenger, api: NativeAmplifyBridge?) { + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.amplify_datastore.NativeAmplifyBridge.configure", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val versionArg = args[0] as String + val configArg = args[1] as String + api.configure(versionArg, configArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(wrapError(error)) + } else { + reply.reply(wrapResult(null)) + } } + } + } else { + channel.setMessageHandler(null) } + } } + } } - @Suppress("UNCHECKED_CAST") private object NativeAuthBridgeCodec : StandardMessageCodec() { - override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { - return when (type) { - 128.toByte() -> { - return (readValue(buffer) as? List)?.let { - NativeAuthUser.fromList(it) - } - } - else -> super.readValueOfType(type, buffer) + override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { + return when (type) { + 128.toByte() -> { + return (readValue(buffer) as? List)?.let { + NativeAuthUser.fromList(it) } + } + else -> super.readValueOfType(type, buffer) } - override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { - when (value) { - is NativeAuthUser -> { - stream.write(128) - writeValue(stream, value.toList()) - } - else -> super.writeValue(stream, value) - } + } + override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { + when (value) { + is NativeAuthUser -> { + stream.write(128) + writeValue(stream, value.toList()) + } + else -> super.writeValue(stream, value) } + } } -/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ +/** + * Bridge for calling Auth plugin from Flutter into Native + * + * Generated interface from Pigeon that represents a handler of messages from Flutter. + */ interface NativeAuthBridge { - fun addAuthPlugin(callback: (Result) -> Unit) - fun updateCurrentUser(user: NativeAuthUser?) + fun addAuthPlugin(callback: (Result) -> Unit) + fun updateCurrentUser(user: NativeAuthUser?) - companion object { - /** The codec used by NativeAuthBridge. */ - val codec: MessageCodec by lazy { - NativeAuthBridgeCodec - } - - /** Sets up an instance of `NativeAuthBridge` to handle messages through the `binaryMessenger`. */ - @Suppress("UNCHECKED_CAST") - fun setUp(binaryMessenger: BinaryMessenger, api: NativeAuthBridge?) { - run { - val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.amplify_datastore.NativeAuthBridge.addAuthPlugin", codec) - if (api != null) { - channel.setMessageHandler { _, reply -> - api.addAuthPlugin() { result: Result -> - val error = result.exceptionOrNull() - if (error != null) { - reply.reply(wrapError(error)) - } else { - reply.reply(wrapResult(null)) - } - } - } - } else { - channel.setMessageHandler(null) - } + companion object { + /** The codec used by NativeAuthBridge. */ + val codec: MessageCodec by lazy { + NativeAuthBridgeCodec + } + /** Sets up an instance of `NativeAuthBridge` to handle messages through the `binaryMessenger`. */ + @Suppress("UNCHECKED_CAST") + fun setUp(binaryMessenger: BinaryMessenger, api: NativeAuthBridge?) { + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.amplify_datastore.NativeAuthBridge.addAuthPlugin", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + api.addAuthPlugin() { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(wrapError(error)) + } else { + reply.reply(wrapResult(null)) + } } - run { - val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.amplify_datastore.NativeAuthBridge.updateCurrentUser", codec) - if (api != null) { - channel.setMessageHandler { message, reply -> - val args = message as List - val userArg = args[0] as NativeAuthUser? - var wrapped: List - try { - api.updateCurrentUser(userArg) - wrapped = listOf(null) - } catch (exception: Throwable) { - wrapped = wrapError(exception) - } - reply.reply(wrapped) - } - } else { - channel.setMessageHandler(null) - } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.amplify_datastore.NativeAuthBridge.updateCurrentUser", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val userArg = args[0] as NativeAuthUser? + var wrapped: List + try { + api.updateCurrentUser(userArg) + wrapped = listOf(null) + } catch (exception: Throwable) { + wrapped = wrapError(exception) } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} +@Suppress("UNCHECKED_CAST") +private object NativeApiBridgeCodec : StandardMessageCodec() { + override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { + return when (type) { + 128.toByte() -> { + return (readValue(buffer) as? List)?.let { + NativeGraphQLSubscriptionResponse.fromList(it) } + } + else -> super.readValueOfType(type, buffer) } + } + override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { + when (value) { + is NativeGraphQLSubscriptionResponse -> { + stream.write(128) + writeValue(stream, value.toList()) + } + else -> super.writeValue(stream, value) + } + } } -/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ +/** + * Bridge for calling API methods from Flutter into Native + * + * Generated interface from Pigeon that represents a handler of messages from Flutter. + */ interface NativeApiBridge { - fun addApiPlugin(authProvidersList: List, callback: (Result) -> Unit) + fun addApiPlugin(authProvidersList: List, callback: (Result) -> Unit) + fun sendSubscriptionEvent(event: NativeGraphQLSubscriptionResponse, callback: (Result) -> Unit) - companion object { - /** The codec used by NativeApiBridge. */ - val codec: MessageCodec by lazy { - StandardMessageCodec() + companion object { + /** The codec used by NativeApiBridge. */ + val codec: MessageCodec by lazy { + NativeApiBridgeCodec + } + /** Sets up an instance of `NativeApiBridge` to handle messages through the `binaryMessenger`. */ + @Suppress("UNCHECKED_CAST") + fun setUp(binaryMessenger: BinaryMessenger, api: NativeApiBridge?) { + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.amplify_datastore.NativeApiBridge.addApiPlugin", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val authProvidersListArg = args[0] as List + api.addApiPlugin(authProvidersListArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(wrapError(error)) + } else { + reply.reply(wrapResult(null)) + } + } + } + } else { + channel.setMessageHandler(null) } - - /** Sets up an instance of `NativeApiBridge` to handle messages through the `binaryMessenger`. */ - @Suppress("UNCHECKED_CAST") - fun setUp(binaryMessenger: BinaryMessenger, api: NativeApiBridge?) { - run { - val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.amplify_datastore.NativeApiBridge.addApiPlugin", codec) - if (api != null) { - channel.setMessageHandler { message, reply -> - val args = message as List - val authProvidersListArg = args[0] as List - api.addApiPlugin(authProvidersListArg) { result: Result -> - val error = result.exceptionOrNull() - if (error != null) { - reply.reply(wrapError(error)) - } else { - reply.reply(wrapResult(null)) - } - } - } - } else { - channel.setMessageHandler(null) - } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.amplify_datastore.NativeApiBridge.sendSubscriptionEvent", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val eventArg = args[0] as NativeGraphQLSubscriptionResponse + api.sendSubscriptionEvent(eventArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(wrapError(error)) + } else { + reply.reply(wrapResult(null)) + } } + } + } else { + channel.setMessageHandler(null) } + } } + } } diff --git a/packages/amplify_datastore/example/ios/Runner.xcodeproj/project.pbxproj b/packages/amplify_datastore/example/ios/Runner.xcodeproj/project.pbxproj index d09cdfed52..1141635967 100644 --- a/packages/amplify_datastore/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/amplify_datastore/example/ios/Runner.xcodeproj/project.pbxproj @@ -45,6 +45,9 @@ 4B4F4F612982D1E100204FE6 /* model_name_with_all_query_parameters.json in Resources */ = {isa = PBXBuildFile; fileRef = 4B4F4F472982D1E000204FE6 /* model_name_with_all_query_parameters.json */; }; 4B4F4F622982D1E100204FE6 /* 1_result.json in Resources */ = {isa = PBXBuildFile; fileRef = 4B4F4F482982D1E000204FE6 /* 1_result.json */; }; 4B4F4F632982D1E100204FE6 /* instance_without_predicate.json in Resources */ = {isa = PBXBuildFile; fileRef = 4B4F4F4A2982D1E000204FE6 /* instance_without_predicate.json */; }; + 600B4A792BED53E4007AA1EA /* GraphQLResponse+DecodeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 600B4A782BED53E4007AA1EA /* GraphQLResponse+DecodeTests.swift */; }; + 60A82D5C2BF52FCC003065FC /* GraphQLRequestExtensionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60A82D5B2BF52FCC003065FC /* GraphQLRequestExtensionTests.swift */; }; + 60A82D5E2BF5348F003065FC /* PublisherExtensionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60A82D5D2BF5348F003065FC /* PublisherExtensionTests.swift */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; @@ -118,6 +121,9 @@ 4B4F4F482982D1E000204FE6 /* 1_result.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = 1_result.json; sourceTree = ""; }; 4B4F4F4A2982D1E000204FE6 /* instance_without_predicate.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = instance_without_predicate.json; sourceTree = ""; }; 513D66DA8B182E3FFDF8750D /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 600B4A782BED53E4007AA1EA /* GraphQLResponse+DecodeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GraphQLResponse+DecodeTests.swift"; sourceTree = ""; }; + 60A82D5B2BF52FCC003065FC /* GraphQLRequestExtensionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GraphQLRequestExtensionTests.swift; sourceTree = ""; }; + 60A82D5D2BF5348F003065FC /* PublisherExtensionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublisherExtensionTests.swift; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7A205B25C076AD0D2D0AEBA9 /* Pods-unit_tests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-unit_tests.profile.xcconfig"; path = "Target Support Files/Pods-unit_tests/Pods-unit_tests.profile.xcconfig"; sourceTree = ""; }; @@ -171,6 +177,9 @@ 4B4F4F1E2982D0CA00204FE6 /* QuerySortBuilderUnitTests.swift */, 4B4F4F162982D0C400204FE6 /* unit_tests-Bridging-Header.h */, 119A9D2529F997A6008BD2A8 /* NativeAuthPluginTests.swift */, + 600B4A782BED53E4007AA1EA /* GraphQLResponse+DecodeTests.swift */, + 60A82D5B2BF52FCC003065FC /* GraphQLRequestExtensionTests.swift */, + 60A82D5D2BF5348F003065FC /* PublisherExtensionTests.swift */, ); path = unit_tests; sourceTree = ""; @@ -576,9 +585,12 @@ 4B4F4F502982D1E000204FE6 /* FlutterSerializedModelData.swift in Sources */, 4B4F4F282982D0CB00204FE6 /* DataStorePluginUnitTests.swift in Sources */, 119A9D2629F997A6008BD2A8 /* NativeAuthPluginTests.swift in Sources */, + 600B4A792BED53E4007AA1EA /* GraphQLResponse+DecodeTests.swift in Sources */, + 60A82D5E2BF5348F003065FC /* PublisherExtensionTests.swift in Sources */, 4B4F4F272982D0CB00204FE6 /* QuerySortBuilderUnitTests.swift in Sources */, 4B4F4F202982D0CB00204FE6 /* DataStoreHubEventStreamHandlerTests.swift in Sources */, 4B4F4F242982D0CB00204FE6 /* GetJsonFromFileUtil.swift in Sources */, + 60A82D5C2BF52FCC003065FC /* GraphQLRequestExtensionTests.swift in Sources */, 4B4F4F222982D0CB00204FE6 /* AmplifySerializedModelUnitTests.swift in Sources */, 4B4F4F252982D0CB00204FE6 /* QueryPredicateBuilderUnitTests.swift in Sources */, 4B4F4F212982D0CB00204FE6 /* ModelSchemaEquatableExtensions.swift in Sources */, diff --git a/packages/amplify_datastore/example/ios/unit_tests/AmplifyModelSchemaUnitTests.swift b/packages/amplify_datastore/example/ios/unit_tests/AmplifyModelSchemaUnitTests.swift index f3066abf77..cd474ce031 100644 --- a/packages/amplify_datastore/example/ios/unit_tests/AmplifyModelSchemaUnitTests.swift +++ b/packages/amplify_datastore/example/ios/unit_tests/AmplifyModelSchemaUnitTests.swift @@ -2,8 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import XCTest -import Amplify -@testable import AmplifyPlugins @testable import amplify_datastore class AmplifyModelSchemaUnitTests: XCTestCase { diff --git a/packages/amplify_datastore/example/ios/unit_tests/AmplifySerializedModelUnitTests.swift b/packages/amplify_datastore/example/ios/unit_tests/AmplifySerializedModelUnitTests.swift index dffe5e7666..4e418b25a3 100644 --- a/packages/amplify_datastore/example/ios/unit_tests/AmplifySerializedModelUnitTests.swift +++ b/packages/amplify_datastore/example/ios/unit_tests/AmplifySerializedModelUnitTests.swift @@ -2,8 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import XCTest -import Amplify -@testable import AmplifyPlugins @testable import amplify_datastore class AmplifySerializedModelUnitTests: XCTestCase { diff --git a/packages/amplify_datastore/example/ios/unit_tests/DataStoreHubEventStreamHandlerTests.swift b/packages/amplify_datastore/example/ios/unit_tests/DataStoreHubEventStreamHandlerTests.swift index 64d14ddf62..d00376d36e 100644 --- a/packages/amplify_datastore/example/ios/unit_tests/DataStoreHubEventStreamHandlerTests.swift +++ b/packages/amplify_datastore/example/ios/unit_tests/DataStoreHubEventStreamHandlerTests.swift @@ -1,11 +1,9 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +import Flutter import XCTest -import Amplify import Combine -@testable import AmplifyPlugins -@testable import AWSPluginsCore @testable import amplify_datastore let testHubSchema: ModelSchema = SchemaData.PostSchema @@ -63,7 +61,7 @@ class DataStoreHubEventStreamHandlerTests: XCTestCase { waitForExpectations(timeout: 1.0) // cancellation needed to make sure that Hub token is invalidated to // prevent collisions between tests - hubHandler.onCancel(withArguments: nil) + let _ = hubHandler.onCancel(withArguments: nil) } func test_hub_readyEvent_success() throws { @@ -88,7 +86,7 @@ class DataStoreHubEventStreamHandlerTests: XCTestCase { let readyEventPayload = HubPayload(eventName: HubPayload.EventName.DataStore.ready) Amplify.Hub.dispatch(to: .dataStore, payload: readyEventPayload) waitForExpectations(timeout: 1.0) - hubHandler.onCancel(withArguments: nil) + let _ = hubHandler.onCancel(withArguments: nil) } func test_hub_subscriptionEstablishedEvent_success() throws { @@ -113,7 +111,7 @@ class DataStoreHubEventStreamHandlerTests: XCTestCase { let subscriptionEstablishedPayload = HubPayload(eventName: HubPayload.EventName.DataStore.subscriptionsEstablished) Amplify.Hub.dispatch(to: .dataStore, payload: subscriptionEstablishedPayload) waitForExpectations(timeout: 1.0) - hubHandler.onCancel(withArguments: nil) + let _ = hubHandler.onCancel(withArguments: nil) } func test_hub_syncQueriesReadyEvent_success() throws { @@ -138,7 +136,7 @@ class DataStoreHubEventStreamHandlerTests: XCTestCase { let syncQueriesReadyPayload = HubPayload(eventName: HubPayload.EventName.DataStore.syncQueriesReady) Amplify.Hub.dispatch(to: .dataStore, payload: syncQueriesReadyPayload) waitForExpectations(timeout: 1.0) - hubHandler.onCancel(withArguments: nil) + let _ = hubHandler.onCancel(withArguments: nil) } func test_hub_networkStatusEvent_success() throws { @@ -166,7 +164,7 @@ class DataStoreHubEventStreamHandlerTests: XCTestCase { let networkStatusPayload = HubPayload(eventName: HubPayload.EventName.DataStore.networkStatus, data: networkStatusEvent) Amplify.Hub.dispatch(to: .dataStore, payload: networkStatusPayload) waitForExpectations(timeout: 1.0) - hubHandler.onCancel(withArguments: nil) + let _ = hubHandler.onCancel(withArguments: nil) } func test_hub_outboxStatusEvent_success() throws { @@ -194,7 +192,7 @@ class DataStoreHubEventStreamHandlerTests: XCTestCase { let outboxStatusPayload = HubPayload(eventName: HubPayload.EventName.DataStore.outboxStatus, data: outboxStatusEvent) Amplify.Hub.dispatch(to: .dataStore, payload: outboxStatusPayload) waitForExpectations(timeout: 1.0) - hubHandler.onCancel(withArguments: nil) + let _ = hubHandler.onCancel(withArguments: nil) } func test_hub_syncQueriesStartedEvent_success() throws { @@ -221,7 +219,7 @@ class DataStoreHubEventStreamHandlerTests: XCTestCase { let syncQueriesStartedPayload = HubPayload(eventName: HubPayload.EventName.DataStore.syncQueriesStarted, data: syncQueriesStartedEvent) Amplify.Hub.dispatch(to: .dataStore, payload: syncQueriesStartedPayload) waitForExpectations(timeout: 1.0) - hubHandler.onCancel(withArguments: nil) + let _ = hubHandler.onCancel(withArguments: nil) } func test_hub_outboxMutationEnqueued_success() throws { @@ -264,7 +262,7 @@ class DataStoreHubEventStreamHandlerTests: XCTestCase { let outboxMutationEnqueuedPayload = HubPayload(eventName: HubPayload.EventName.DataStore.outboxMutationEnqueued, data: outboxMutationEnqueuedEvent) Amplify.Hub.dispatch(to: .dataStore, payload: outboxMutationEnqueuedPayload) waitForExpectations(timeout: 1.0) - hubHandler.onCancel(withArguments: nil) + let _ = hubHandler.onCancel(withArguments: nil) } func test_hub_outboxMutationProcessedEvent_success() throws { @@ -281,7 +279,7 @@ class DataStoreHubEventStreamHandlerTests: XCTestCase { let syncMetaData = element["syncMetadata"] as! [String: Any] XCTAssertEqual(flutterEvent["eventName"] as! String, "outboxMutationProcessed") XCTAssertEqual(flutterEvent["modelName"] as! String, "Post") - XCTAssertEqual(syncMetaData["_lastChangedAt"] as? Int, 123) + XCTAssertEqual(syncMetaData["_lastChangedAt"] as? Int64, 123) XCTAssertEqual(syncMetaData["_version"] as? Int, 1) XCTAssertEqual(syncMetaData["_deleted"] as? Bool, false) XCTAssertEqual(model["__modelName"] as! String, "Post") @@ -300,7 +298,8 @@ class DataStoreHubEventStreamHandlerTests: XCTestCase { let serializedModel = FlutterSerializedModel(map: try FlutterDataStoreRequestUtils.getJSONValue(modelMap), modelName: "Post") - let syncMetadata = MutationSyncMetadata(id: uuid, + let syncMetadata = MutationSyncMetadata(modelId: uuid, + modelName: "MutationSync", deleted: false, lastChangedAt: 123, version: 1) @@ -321,7 +320,7 @@ class DataStoreHubEventStreamHandlerTests: XCTestCase { expect.fulfill() } waitForExpectations(timeout: 1.0) - hubHandler.onCancel(withArguments: nil) + let _ = hubHandler.onCancel(withArguments: nil) } func test_hot_restart_replays_sync_and_ready_events() { diff --git a/packages/amplify_datastore/example/ios/unit_tests/DataStorePluginUnitTests.swift b/packages/amplify_datastore/example/ios/unit_tests/DataStorePluginUnitTests.swift index 4bdfe635d3..60d7bb1d15 100644 --- a/packages/amplify_datastore/example/ios/unit_tests/DataStorePluginUnitTests.swift +++ b/packages/amplify_datastore/example/ios/unit_tests/DataStorePluginUnitTests.swift @@ -1,17 +1,16 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +import Flutter import XCTest -import Amplify import Combine -@testable import AmplifyPlugins @testable import amplify_datastore let testSchema: ModelSchema = SchemaData.PostSchema let amplifySuccessResults: [FlutterSerializedModel] = (try! readJsonArray(filePath: "2_results") as! [[String: Any]]).map { serializedModel in FlutterSerializedModel.init( - map: try! getJSONValue(serializedModel as! [String: Any]), + map: try! getJSONValue(serializedModel), modelName: serializedModel["__modelName"] as! String ) } diff --git a/packages/amplify_datastore/example/ios/unit_tests/GetJsonFromFileUtil.swift b/packages/amplify_datastore/example/ios/unit_tests/GetJsonFromFileUtil.swift index d2ddcdefa4..6996de181f 100644 --- a/packages/amplify_datastore/example/ios/unit_tests/GetJsonFromFileUtil.swift +++ b/packages/amplify_datastore/example/ios/unit_tests/GetJsonFromFileUtil.swift @@ -2,7 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import Foundation -import Amplify func readJsonMap(filePath: String) throws -> [String: Any] { if let object = try readJson(filePath: filePath) as? [String: Any] { diff --git a/packages/amplify_datastore/example/ios/unit_tests/GraphQLRequestExtensionTests.swift b/packages/amplify_datastore/example/ios/unit_tests/GraphQLRequestExtensionTests.swift new file mode 100644 index 0000000000..1231d15bdc --- /dev/null +++ b/packages/amplify_datastore/example/ios/unit_tests/GraphQLRequestExtensionTests.swift @@ -0,0 +1,60 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + + +import XCTest +@testable import amplify_datastore + +class GraphQLRequestExtensionTests: XCTestCase { + func testToNativeGraphQLRequest_withCorrectData_convertToNativeGraphQLRequestSuccessfully() { + + let graphQLRequest = GraphQLRequest( + apiName: UUID().uuidString, + document: UUID().uuidString, + variables: [UUID().uuidString: UUID().uuidString], + responseType: MutationEvent.self + ) + + let nativeGrqphQLRquest = graphQLRequest.toNativeGraphQLRequest() + XCTAssertEqual(graphQLRequest.apiName, nativeGrqphQLRquest.apiName) + XCTAssertEqual(graphQLRequest.document, nativeGrqphQLRquest.document) + XCTAssertEqual(String(describing: graphQLRequest.responseType), nativeGrqphQLRquest.responseType) + let graphQLVariablesJson = """ + {"\(graphQLRequest.variables!.keys.first!)":"\(graphQLRequest.variables!.values.first!)"} + """ + XCTAssertEqual(graphQLVariablesJson, nativeGrqphQLRquest.variablesJson) + } + + func testToNativeGraphQLRequest_withNilVariables_convertToNativeGraphQLRequestWithEmptyVariablesJsonObject() { + let graphQLRequest = GraphQLRequest( + apiName: UUID().uuidString, + document: UUID().uuidString, + variables: nil, + responseType: MutationEvent.self + ) + + let nativeGrqphQLRquest = graphQLRequest.toNativeGraphQLRequest() + XCTAssertEqual(graphQLRequest.apiName, nativeGrqphQLRquest.apiName) + XCTAssertEqual(graphQLRequest.document, nativeGrqphQLRquest.document) + XCTAssertEqual(String(describing: graphQLRequest.responseType), nativeGrqphQLRquest.responseType) + let graphQLVariablesJson = "{}" + XCTAssertEqual(graphQLVariablesJson, nativeGrqphQLRquest.variablesJson) + } + + func testToNativeGraphQLRequest_withEmptyVariables_convertToNativeGraphQLRequestWithEmptyVariablesJsonObject() { + let graphQLRequest = GraphQLRequest( + apiName: UUID().uuidString, + document: UUID().uuidString, + variables: [:], + responseType: MutationEvent.self + ) + + let nativeGrqphQLRquest = graphQLRequest.toNativeGraphQLRequest() + XCTAssertEqual(graphQLRequest.apiName, nativeGrqphQLRquest.apiName) + XCTAssertEqual(graphQLRequest.document, nativeGrqphQLRquest.document) + XCTAssertEqual(String(describing: graphQLRequest.responseType), nativeGrqphQLRquest.responseType) + let graphQLVariablesJson = "{}" + XCTAssertEqual(graphQLVariablesJson, nativeGrqphQLRquest.variablesJson) + } + +} diff --git a/packages/amplify_datastore/example/ios/unit_tests/GraphQLResponse+DecodeTests.swift b/packages/amplify_datastore/example/ios/unit_tests/GraphQLResponse+DecodeTests.swift new file mode 100644 index 0000000000..3c5d1071af --- /dev/null +++ b/packages/amplify_datastore/example/ios/unit_tests/GraphQLResponse+DecodeTests.swift @@ -0,0 +1,432 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + + +import XCTest +@testable import amplify_datastore + +class GraphQLResponseDecodeTests: XCTestCase { + + override func tearDown() async throws { + SchemaData.modelSchemaRegistry.modelSchemas = [:] + ModelRegistry.reset() + } + + func testToJson_decodeDataToJSONValue_success() { + let testJson = """ + {"a": "b", "c": ["d"]} + """ + let expectedJson: JSONValue = [ + "a": "b", + "c": ["d"] + ] + let testData = testJson.data(using: .utf8) + XCTAssertNotNil(testData) + let decodeJsonValue = GraphQLResponse.toJson(data: testData!) + XCTAssertNoThrow(try decodeJsonValue.get()) + XCTAssertEqual(expectedJson, try decodeJsonValue.get()) + } + + func testToJson_decodeWrongJsonDataToJSONValue_failure() { + let testJson = """ + {"a": "b", "c": ["d"} + """ + let testData = testJson.data(using: .utf8) + XCTAssertNotNil(testData) + let decodeJsonValue = GraphQLResponse.toJson(data: testData!) + XCTAssertThrowsError(try decodeJsonValue.get()) + } + + func testFromJson_encodeJsonValueToData_success() { + let json: JSONValue = [ + "a": ["b"] + ] + + let expectedJsonData = "{\"a\":[\"b\"]}".data(using: .utf8) + let encodedJsonData = GraphQLResponse.fromJson(json) + XCTAssertNoThrow(try encodedJsonData.get()) + XCTAssertEqual(expectedJsonData, try encodedJsonData.get()) + } + + func testEncodeDataPayloadToString_withCorrectJSONValue_success() { + let json: JSONValue = [ + "a": ["b"] + ] + + let expectedJson = "{\"a\":[\"b\"]}" + let encodedJson = GraphQLResponse.encodeDataPayloadToString(json) + XCTAssertNoThrow(try encodedJson.get()) + XCTAssertEqual(expectedJson, try encodedJson.get()) + } + + func testDecodeDataPayloadToAnyModel_withCorrectJson_Success() { + SchemaData.modelSchemaRegistry.registerModels(registry: ModelRegistry.self) + let json: JSONValue = [ + "__typename": "Post", + "id": "123", + "title": "test", + "author": "authorId", + ] + + let expectedModel: AnyModel = AnyModel(FlutterSerializedModel(map: [ + "__typename": "Post", + "id": "123", + "title": "test", + "author": "authorId", + ], modelName: "Post")) + + let result = GraphQLResponse.decodeDataPayloadToAnyModel(json) + XCTAssertNoThrow(try result.get()) + XCTAssertEqual(expectedModel.modelName, try result.get().modelName) + XCTAssertEqual(expectedModel.id, try result.get().id) + } + + func testDecodeDataPayloadToAnyModel_withJsonWithoutTypename_failure() { + let json: JSONValue = [ + "a": ["b"] + ] + + let result = GraphQLResponse.decodeDataPayloadToAnyModel(json) + XCTAssertThrowsError(try result.get()) { error in + XCTAssertTrue(error is APIError) + XCTAssertEqual((error as! APIError).errorDescription, "Could not retrieve __typename from object") + } + } + + func testDecodeDataPayloadToAnyModel_withJsonWithWrongTypename_failure() { + let json: JSONValue = [ + "a": ["b"], + "__typename": "Post" + ] + + let result = GraphQLResponse.decodeDataPayloadToAnyModel(json) + XCTAssertThrowsError(try result.get()) { error in + XCTAssertTrue(error is APIError) + XCTAssertTrue((error as! APIError).errorDescription.starts(with: "Could not decode to Post")) + } + } + + func testDecodeDataPayload_withDecodableAsString_getString() { + SchemaData.modelSchemaRegistry.registerModels(registry: ModelRegistry.self) + let json: JSONValue = [ + "a": ["b"] + ] + let expectedJson = "{\"a\":[\"b\"]}" + let result: Result = GraphQLResponse.decodeDataPayload(json, modelName: nil) + XCTAssertNoThrow(try result.get()) + XCTAssertEqual(expectedJson, try result.get()) + } + + func testDecodeDataPayload_withDecodableAsAnyModel_getAnyModel() { + SchemaData.modelSchemaRegistry.registerModels(registry: ModelRegistry.self) + let json: JSONValue = [ + "__typename": "Post", + "id": "123", + "title": "test", + "author": "authorId", + ] + + let expectedModel: AnyModel = AnyModel(FlutterSerializedModel(map: [ + "__typename": "Post", + "id": "123", + "title": "test", + "author": "authorId", + ], modelName: "Post")) + + let result: Result = GraphQLResponse.decodeDataPayload(json, modelName: nil) + XCTAssertNoThrow(try result.get()) + XCTAssertEqual(expectedModel.modelName, try result.get().modelName) + XCTAssertEqual(expectedModel.id, try result.get().id) + } + + func testDecodeDataPayload_withSpecifiedModelName_decodedWithThatType() { + SchemaData.modelSchemaRegistry.registerModels(registry: ModelRegistry.self) + let json: JSONValue = [ + "id": "123", + "title": "test", + "author": "authorId", + ] + + let expectedModel: AnyModel = AnyModel(FlutterSerializedModel(map: [ + "__typename": "Post", + "id": "123", + "title": "test", + "author": "authorId", + ], modelName: "Post")) + + let result: Result = GraphQLResponse.decodeDataPayload(json, modelName: "Post") + XCTAssertNoThrow(try result.get()) + XCTAssertEqual(expectedModel.modelName, try result.get().modelName) + XCTAssertEqual(expectedModel.id, try result.get().id) + } + + func testDecodeDataPayload_withTypeR_decodedToR() { + SchemaData.modelSchemaRegistry.registerModels(registry: ModelRegistry.self) + let json: JSONValue = [ + "__typename": "Post", + "id": "123", + "title": "test", + "author": "authorId", + "_version": 1, + "_deleted": nil, + "_lastChangedAt": 1707773705221 + ] + + let expectedModel: AnyModel = AnyModel(FlutterSerializedModel(map: [ + "__typename": "Post", + "id": "123", + "title": "test", + "author": "authorId", + ], modelName: "Post")) + + let result: Result, APIError> = GraphQLResponse.decodeDataPayload(json, modelName: "Post") + XCTAssertNoThrow(try result.get()) + XCTAssertEqual(expectedModel.modelName, try result.get().model.modelName) + XCTAssertEqual(expectedModel.id, try result.get().model.identifier) + } + + func testParseGraphQLError_withNonObjectJsonValue_returnNil() { + let errorJson: JSONValue = "test" + XCTAssertNil(GraphQLResponse.parseGraphQLError(error: errorJson)) + } + + func testParseGraphQLError_withCorrectJsonValue_buildGraphQLErrorSuccessfully() { + let errorJson: JSONValue = [ + "message": "test", + "errorType": "UnknownError" + ] + + let result = GraphQLResponse.parseGraphQLError(error: errorJson) + XCTAssertNotNil(result) + XCTAssertEqual(errorJson.message?.stringValue, result?.message) + XCTAssertEqual(["errorType": "UnknownError"], result?.extensions) + } + + func testFromAppSyncResponse_withOnlyData_decodePayload() { + SchemaData.modelSchemaRegistry.registerModels(registry: ModelRegistry.self) + let json: JSONValue = [ + "onCreatePost": [ + "__typename": "Post", + "id": "123", + "title": "test", + "author": "authorId", + "_version": 1, + "_deleted": nil, + "_lastChangedAt": 1707773705221 + ] + ] + + let expectedModel: AnyModel = AnyModel(FlutterSerializedModel(map: [ + "__typename": "Post", + "id": "123", + "title": "test", + "author": "authorId", + ], modelName: "Post")) + + let result: Result>, APIError> = .fromAppSyncResponse(json: json, decodePath: "onCreatePost", modelName: nil) + XCTAssertNoThrow(try result.get()) + let mutationSync = try! result.get() + XCTAssertNoThrow(try mutationSync.get()) + XCTAssertEqual(expectedModel.modelName, try mutationSync.get().model.modelName) + XCTAssertEqual(expectedModel.id, try mutationSync.get().model.identifier) + } + + func testFromAppSyncResponse_withOnlyErrors_decodePayload() { + SchemaData.modelSchemaRegistry.registerModels(registry: ModelRegistry.self) + let json: JSONValue = [ + "errors": [ + ["message": "error1"], + ["message": "error2"] + ] + ] + + let result: Result>, APIError> = .fromAppSyncResponse(json: json, decodePath: "onCreatePost", modelName: nil) + XCTAssertNoThrow(try result.get()) + let mutationSync = try! result.get() + XCTAssertThrowsError(try mutationSync.get()) { error in + if let graphQLResponseError = error as? GraphQLResponseError>, + case .error(let graphQLErrors) = graphQLResponseError + { + XCTAssertEqual(graphQLErrors.map(\.message), ["error1", "error2"]) + } else { + XCTFail("Errors are not decoded correctly") + } + } + } + + func testFromAppSyncResponse_withDataAndErrors_decodePayload() { + SchemaData.modelSchemaRegistry.registerModels(registry: ModelRegistry.self) + let json: JSONValue = [ + "onCreatePost": [ + "__typename": "Post", + "id": "123", + "title": "test", + "author": "authorId", + "_version": 1, + "_deleted": nil, + "_lastChangedAt": 1707773705221 + ], + "errors": [ + ["message": "error1"], + ["message": "error2"] + ] + ] + + let expectedModel: AnyModel = AnyModel(FlutterSerializedModel(map: [ + "__typename": "Post", + "id": "123", + "title": "test", + "author": "authorId", + ], modelName: "Post")) + + let result: Result>, APIError> = .fromAppSyncResponse(json: json, decodePath: "onCreatePost", modelName: nil) + XCTAssertNoThrow(try result.get()) + let mutationSync = try! result.get() + XCTAssertThrowsError(try mutationSync.get()) { error in + if let graphQLResponseError = error as? GraphQLResponseError>, + case .partial(let mutationSync, let graphQLErrors) = graphQLResponseError + { + XCTAssertEqual(graphQLErrors.map(\.message), ["error1", "error2"]) + XCTAssertEqual(expectedModel.modelName, mutationSync.model.modelName) + XCTAssertEqual(expectedModel.id, mutationSync.model.identifier) + } else { + XCTFail("Errors are not decoded correctly") + } + } + } + + func testFromAppSyncResponse_withNoDataNoErrors_decodePayload() { + SchemaData.modelSchemaRegistry.registerModels(registry: ModelRegistry.self) + let json: JSONValue = [ + "a": "b" + ] + + let result: Result>, APIError> = .fromAppSyncResponse(json: json, decodePath: "onCreatePost", modelName: nil) + XCTAssertThrowsError(try result.get()) { error in + if case .unknown(let description, _, _) = (error as! APIError) { + XCTAssertEqual("Failed to get data object or errors from GraphQL response", description) + } else { + XCTFail("Should throw APIError.unknown") + } + } + } + + func testFromAppSyncResponse_withJsonString_decodeCorrectly() { + SchemaData.modelSchemaRegistry.registerModels(registry: ModelRegistry.self) + let json: JSONValue = [ + "__typename": "Post", + "id": "123", + "title": "test", + "author": "authorId", + ] + + let jsonString = String(data: try! JSONEncoder().encode(json), encoding: .utf8)! + let response: GraphQLResponse = .fromAppSyncResponse(string: jsonString, decodePath: nil) + XCTAssertNoThrow(try response.get()) + XCTAssertEqual(json.id?.stringValue, try response.get().identifier) + XCTAssertEqual(json.__typename?.stringValue, try response.get().modelName) + } + + func testFromAppSyncResponse_withBrokenJsonString_failWithTransformationError() { + SchemaData.modelSchemaRegistry.registerModels(registry: ModelRegistry.self) + let jsonString = "{" + let response: GraphQLResponse = .fromAppSyncResponse(string: jsonString, decodePath: nil) + XCTAssertThrowsError(try response.get()) { error in + guard case .transformationError = error as! GraphQLResponseError else { + XCTFail("Should failed with transformationError") + return + } + } + } + + func testFromAppSyncSubscriptionResponse_withJsonString_decodeCorrectly() { + SchemaData.modelSchemaRegistry.registerModels(registry: ModelRegistry.self) + let payloadJson: JSONValue = [ + "onCreatePost": [ + "__typename": "Post", + "id": "123", + "title": "test", + "author": "authorId", + "_version": 1, + "_deleted": nil, + "_lastChangedAt": 1707773705221 + ] + ] + + let jsonString = String(data: try! JSONEncoder().encode(payloadJson), encoding: .utf8)! + let response: GraphQLResponse> = .fromAppSyncResponse(string: jsonString, decodePath: "onCreatePost") + XCTAssertNoThrow(try response.get()) + let mutationSync = try! response.get() + XCTAssertEqual(payloadJson.onCreatePost?.id?.stringValue, mutationSync.model.identifier) + XCTAssertEqual(payloadJson.onCreatePost?.__typename?.stringValue, mutationSync.model.modelName) + } + + func testFromAppSyncSubscriptionResponse_withWrongJsonString_failWithTransformationError() { + SchemaData.modelSchemaRegistry.registerModels(registry: ModelRegistry.self) + let jsonString = "{" + let response: GraphQLResponse> = .fromAppSyncSubscriptionResponse(string: jsonString, decodePath: nil) + XCTAssertThrowsError(try response.get()) { error in + guard case .transformationError = error as! GraphQLResponseError> else { + XCTFail("Should failed with transformationError") + return + } + } + } + + func testFromAppSyncSubscriptionErrorResponse_withErrorJsonPayload_decodeCorrectly() { + SchemaData.modelSchemaRegistry.registerModels(registry: ModelRegistry.self) + let errorJsonPayload: JSONValue = [ + "errors": [ + ["message": "error1"], + ["message": "error2"] + ] + ] + let errorJsonPayloadString = String(data: try! JSONEncoder().encode(errorJsonPayload), encoding: .utf8)! + let result: GraphQLResponse> = .fromAppSyncSubscriptionErrorResponse(string: errorJsonPayloadString) + XCTAssertThrowsError(try result.get()) { error in + if let graphQLResponseError = error as? GraphQLResponseError>, + case .error(let graphQLErrors) = graphQLResponseError + { + XCTAssertEqual(graphQLErrors.map(\.message), ["error1", "error2"]) + } else { + XCTFail("Errors are not decoded correctly") + } + } + } + + func testFromAppSyncSubscriptionErrorResponse_withSingleErrorJsonPayload_decodeCorrectly() { + SchemaData.modelSchemaRegistry.registerModels(registry: ModelRegistry.self) + let errorJsonPayload: JSONValue = [ + "errors": [ + "message": "error1" + ] + ] + let errorJsonPayloadString = String(data: try! JSONEncoder().encode(errorJsonPayload), encoding: .utf8)! + let result: GraphQLResponse> = .fromAppSyncSubscriptionErrorResponse(string: errorJsonPayloadString) + XCTAssertThrowsError(try result.get()) { error in + if let graphQLResponseError = error as? GraphQLResponseError>, + case .error(let graphQLErrors) = graphQLResponseError + { + XCTAssertEqual(graphQLErrors.map(\.message), ["error1"]) + } else { + XCTFail("Errors are not decoded correctly") + } + } + } + + func testFromAppSyncSubscriptionErrorResponse_withBrokenErrorJsonPayload_failWithTransformationError() { + SchemaData.modelSchemaRegistry.registerModels(registry: ModelRegistry.self) + let errorJsonString = "{" + let response: GraphQLResponse> = .fromAppSyncSubscriptionErrorResponse(string: errorJsonString) + XCTAssertThrowsError(try response.get()) { error in + guard case .transformationError = error as! GraphQLResponseError> else { + XCTFail("Should failed with transformationError") + return + } + } + } +} diff --git a/packages/amplify_datastore/example/ios/unit_tests/ModelSchemaEquatableExtensions.swift b/packages/amplify_datastore/example/ios/unit_tests/ModelSchemaEquatableExtensions.swift index a239eccfe5..d903391ce4 100644 --- a/packages/amplify_datastore/example/ios/unit_tests/ModelSchemaEquatableExtensions.swift +++ b/packages/amplify_datastore/example/ios/unit_tests/ModelSchemaEquatableExtensions.swift @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import Foundation -import Amplify +@testable import amplify_datastore extension ModelSchema: Equatable { public static func == (lhs: ModelSchema, rhs: ModelSchema) -> Bool { diff --git a/packages/amplify_datastore/example/ios/unit_tests/PublisherExtensionTests.swift b/packages/amplify_datastore/example/ios/unit_tests/PublisherExtensionTests.swift new file mode 100644 index 0000000000..6f9b4bd76f --- /dev/null +++ b/packages/amplify_datastore/example/ios/unit_tests/PublisherExtensionTests.swift @@ -0,0 +1,105 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + + +import XCTest +import Combine +@testable import amplify_datastore + +class PublisherExtensionTests: XCTestCase { + func testToAmplifyAsyncThrowingSequence_createAsyncSequenceCorrect() async throws { + let subject = PassthroughSubject() + let (sequence, cancellable) = subject.eraseToAnyPublisher().toAmplifyAsyncThrowingSequence() + + let expectation1 = expectation(description: "element 1 received") + let expectation2 = expectation(description: "element 2 received") + let expectation3 = expectation(description: "element 3 received") + Task { + for try await element in sequence { + switch element { + case 1: expectation1.fulfill() + case 2: expectation2.fulfill() + case 3: expectation3.fulfill() + default: break + } + } + } + + Task { + subject.send(1) + subject.send(2) + subject.send(3) + subject.send(completion: .finished) + } + + await fulfillment(of: [expectation1, expectation2, expectation3], timeout: 1) + } + + func testToAmplifyAsyncThrowingSequence_withThrowingError_createAsyncSequenceCorrect() async throws { + let subject = PassthroughSubject() + let (sequence, cancellable) = subject.eraseToAnyPublisher().toAmplifyAsyncThrowingSequence() + + let expectation1 = expectation(description: "element 1 received") + let expectation2 = expectation(description: "element 2 received") + let expectation3 = expectation(description: "element 3 received") + expectation3.isInverted = true + Task { + do { + for try await element in sequence { + switch element { + case 1: expectation1.fulfill() + case 2: expectation2.fulfill() + case 3: expectation3.fulfill() + default: break + } + } + } catch { + XCTAssertTrue(error is TestError) + } + } + + subject.send(1) + subject.send(2) + subject.send(completion: .failure(.error)) + await fulfillment(of: [expectation1, expectation2, expectation3], timeout: 1) + } + + func testToAmplifyAsyncThrowingSequence_withCancelling_createAsyncSequenceCorrect() async throws { + let subject = PassthroughSubject() + let (sequence, cancellable) = subject.eraseToAnyPublisher().toAmplifyAsyncThrowingSequence() + + let expectation1 = expectation(description: "element 1 received") + let expectation2 = expectation(description: "element 2 received") + let expectation3 = expectation(description: "element 3 received") + expectation3.isInverted = true + let expectation4 = expectation(description: "element 4 received") + expectation4.isInverted = true + Task { + for try await element in sequence { + switch element { + case 1: expectation1.fulfill() + case 2: expectation2.fulfill() + case 3: expectation3.fulfill() + case 4: expectation4.fulfill() + default: break + } + } + } + + subject.send(1) + subject.send(2) + cancellable.cancel() + subject.send(3) + subject.send(4) + await fulfillment(of: [ + expectation1, + expectation2, + expectation3, + expectation4 + ], timeout: 1) + } +} + +fileprivate enum TestError: Error { + case error +} diff --git a/packages/amplify_datastore/example/ios/unit_tests/QueryPaginationUnitTests.swift b/packages/amplify_datastore/example/ios/unit_tests/QueryPaginationUnitTests.swift index fc8fdd0985..94ff600de3 100644 --- a/packages/amplify_datastore/example/ios/unit_tests/QueryPaginationUnitTests.swift +++ b/packages/amplify_datastore/example/ios/unit_tests/QueryPaginationUnitTests.swift @@ -2,8 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import XCTest -import Amplify -import AmplifyPlugins @testable import amplify_datastore extension QueryPaginationInput: Equatable { diff --git a/packages/amplify_datastore/example/ios/unit_tests/QueryPredicateBuilderUnitTests.swift b/packages/amplify_datastore/example/ios/unit_tests/QueryPredicateBuilderUnitTests.swift index 78a831f10b..cdf5e531e5 100644 --- a/packages/amplify_datastore/example/ios/unit_tests/QueryPredicateBuilderUnitTests.swift +++ b/packages/amplify_datastore/example/ios/unit_tests/QueryPredicateBuilderUnitTests.swift @@ -2,7 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import XCTest -import Amplify @testable import amplify_datastore class QueryPredicateBuilderUnitTests: XCTestCase { diff --git a/packages/amplify_datastore/example/ios/unit_tests/QuerySortBuilderUnitTests.swift b/packages/amplify_datastore/example/ios/unit_tests/QuerySortBuilderUnitTests.swift index bb0ce4b0d0..dbd40cc0e3 100644 --- a/packages/amplify_datastore/example/ios/unit_tests/QuerySortBuilderUnitTests.swift +++ b/packages/amplify_datastore/example/ios/unit_tests/QuerySortBuilderUnitTests.swift @@ -2,8 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import XCTest -import Amplify -@testable import AmplifyPlugins @testable import amplify_datastore // Extending for simple assertions on the objects equality. diff --git a/packages/amplify_datastore/example/ios/unit_tests/resources/FlutterSerializedModelData.swift b/packages/amplify_datastore/example/ios/unit_tests/resources/FlutterSerializedModelData.swift index 3e28867a84..9e3b969c19 100644 --- a/packages/amplify_datastore/example/ios/unit_tests/resources/FlutterSerializedModelData.swift +++ b/packages/amplify_datastore/example/ios/unit_tests/resources/FlutterSerializedModelData.swift @@ -1,8 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import Amplify -@testable import AmplifyPlugins @testable import amplify_datastore struct FlutterSerializedModelData { diff --git a/packages/amplify_datastore/example/ios/unit_tests/resources/SchemaData.swift b/packages/amplify_datastore/example/ios/unit_tests/resources/SchemaData.swift index 7b2c30cb75..de0fb9dcca 100644 --- a/packages/amplify_datastore/example/ios/unit_tests/resources/SchemaData.swift +++ b/packages/amplify_datastore/example/ios/unit_tests/resources/SchemaData.swift @@ -1,8 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import Amplify -@testable import AmplifyPlugins @testable import amplify_datastore struct SchemaData { diff --git a/packages/amplify_datastore/example/lib/queries_display_widgets.dart b/packages/amplify_datastore/example/lib/queries_display_widgets.dart index f400e9b8cf..1e8013a7e7 100644 --- a/packages/amplify_datastore/example/lib/queries_display_widgets.dart +++ b/packages/amplify_datastore/example/lib/queries_display_widgets.dart @@ -119,7 +119,10 @@ Widget getWidgetToDisplayPost( _postsToView[i].rating.toString() + ", blog: " + allBlogs - .firstWhere((blog) => blog.id == _postsToView[i].blog?.id) + .firstWhere( + (blog) => blog.id == _postsToView[i].blog?.id, + orElse: () => Blog(name: "Blog not found"), + ) .name, style: TextStyle(fontSize: 14.0), ), diff --git a/packages/amplify_datastore/ios/.gitignore b/packages/amplify_datastore/ios/.gitignore index aa479fd3ce..803b1de3bc 100644 --- a/packages/amplify_datastore/ios/.gitignore +++ b/packages/amplify_datastore/ios/.gitignore @@ -29,9 +29,10 @@ xcuserdata *.moved-aside *.pyc -*sync/ +*sync/* Icon? .tags* /Flutter/Generated.xcconfig -/Flutter/flutter_export_environment.sh \ No newline at end of file +/Flutter/flutter_export_environment.sh +!internal/* diff --git a/packages/amplify_datastore/ios/Classes/CognitoPlugin.swift b/packages/amplify_datastore/ios/Classes/CognitoPlugin.swift index eda79c43d5..377fab8181 100644 --- a/packages/amplify_datastore/ios/Classes/CognitoPlugin.swift +++ b/packages/amplify_datastore/ios/Classes/CognitoPlugin.swift @@ -3,8 +3,6 @@ import Flutter import UIKit -import Amplify -import AWSPluginsCore extension NativeAuthUser: AuthUser { } @@ -22,259 +20,119 @@ public class CognitoPlugin: AuthCategoryPlugin { self.nativeAuthPlugin = nativeAuthPlugin } - - public func signIn( - withUrlUrl url: String, - callbackUrlScheme: String, - preferPrivateSession: NSNumber, - browserPackageName: String? - ) async -> ([String : String]?, FlutterError?) { - preconditionFailure("signIn is not supported") - } - - public func signOut( - withUrlUrl url: String, - callbackUrlScheme: String, - preferPrivateSession: NSNumber, - browserPackageName: String? - ) async -> FlutterError? { - preconditionFailure("signOut is not supported") - } - public let key: PluginKey = "awsCognitoAuthPlugin" public func configure(using configuration: Any?) throws {} - public func reset(onComplete: @escaping BasicClosure) { - onComplete() - } + public func reset() async {} - public func fetchAuthSession( - options: AuthFetchSessionRequest.Options?, - listener: ((AmplifyOperation.OperationResult) - -> Void)? - ) -> AuthFetchSessionOperation { - let operation = NativeAuthFetchSessionOperation( - categoryType: .auth, - eventName: HubPayload.EventName.Auth.fetchSessionAPI, - request: AuthFetchSessionRequest(options: options ?? AuthFetchSessionRequest.Options()), - resultListener: listener - ) - DispatchQueue.main.async { - self.nativeAuthPlugin.fetchAuthSession { session in - let result = NativeAWSAuthCognitoSession(from: session) - operation.dispatch(result: .success(result)) + public func fetchAuthSession(options: AuthFetchSessionRequest.Options?) async throws -> any AuthSession { + await withCheckedContinuation { continuation in + DispatchQueue.main.async { + self.nativeAuthPlugin.fetchAuthSession { session in + let result = NativeAWSAuthCognitoSession(from: session) + continuation.resume(returning: result) + } } } - return operation } - public func signUp( - username: String, - password: String?, - options: AuthSignUpRequest.Options?, - listener: ((AmplifyOperation.OperationResult) - -> Void)? - ) -> AuthSignUpOperation { - preconditionFailure("signUp is not supported") + public func getCurrentUser() async throws -> any AuthUser { + guard let user = currentUser else { + throw FlutterError(code: "NO_CURRENT_USER", message: "No current user is signed in.", details: nil) + } + return user + } + + public func signUp(username: String, password: String?, options: AuthSignUpRequest.Options?) async throws -> AuthSignUpResult { + preconditionFailure("method not supported") } - public func getCurrentUser() -> AuthUser? { - return currentUser + public func confirmSignUp(for username: String, confirmationCode: String, options: AuthConfirmSignUpRequest.Options?) async throws -> AuthSignUpResult { + preconditionFailure("method not supported") } - public func fetchDevices( - options: AuthFetchDevicesRequest.Options?, - listener: ((AmplifyOperation, AuthError>.OperationResult) - -> Void)? - ) -> AuthFetchDevicesOperation { - preconditionFailure("fetchDevices is not supported") + public func resendSignUpCode(for username: String, options: AuthResendSignUpCodeRequest.Options?) async throws -> AuthCodeDeliveryDetails { + preconditionFailure("method not supported") } - public func confirmSignUp( - for username: String, - confirmationCode: String, - options: AuthConfirmSignUpRequest.Options?, - listener: ((AmplifyOperation.OperationResult) - -> Void)? - ) -> AuthConfirmSignUpOperation { - preconditionFailure("confirmSignUp is not supported") + public func signIn(username: String?, password: String?, options: AuthSignInRequest.Options?) async throws -> AuthSignInResult { + preconditionFailure("method not supported") } - public func fetchUserAttributes( - options: AuthFetchUserAttributesRequest.Options?, - listener: ((AmplifyOperation, - AuthError>.OperationResult) - -> Void)? - ) -> AuthFetchUserAttributeOperation { - preconditionFailure("fetchUserAttributes is not supported") + public func signInWithWebUI(presentationAnchor: AuthUIPresentationAnchor?, options: AuthWebUISignInRequest.Options?) async throws -> AuthSignInResult { + preconditionFailure("method not supported") } - public func forgetDevice( - _ device: AuthDevice?, - options: AuthForgetDeviceRequest.Options?, - listener: ((AmplifyOperation.OperationResult) - -> Void)? - ) -> AuthForgetDeviceOperation { - preconditionFailure("forgetDevice is not supported") + public func signInWithWebUI(for authProvider: AuthProvider, presentationAnchor: AuthUIPresentationAnchor?, options: AuthWebUISignInRequest.Options?) async throws -> AuthSignInResult { + preconditionFailure("method not supported") } - public func resendSignUpCode( - for username: String, - options: AuthResendSignUpCodeRequest.Options?, - listener: ((AmplifyOperation.OperationResult) - -> Void)? - ) -> AuthResendSignUpCodeOperation { - preconditionFailure("resendSignUpCode is not supported") + public func confirmSignIn(challengeResponse: String, options: AuthConfirmSignInRequest.Options?) async throws -> AuthSignInResult { + preconditionFailure("method not supported") } - public func update( - userAttribute: AuthUserAttribute, - options: AuthUpdateUserAttributeRequest.Options?, - listener: ((AmplifyOperation.OperationResult) - -> Void)? - ) -> AuthUpdateUserAttributeOperation { - preconditionFailure("update is not supported") + public func signOut(options: AuthSignOutRequest.Options?) async -> any AuthSignOutResult { + preconditionFailure("method not supported") } - public func rememberDevice( - options: AuthRememberDeviceRequest.Options?, - listener: ((AmplifyOperation.OperationResult) - -> Void)? - ) -> AuthRememberDeviceOperation { - preconditionFailure("rememberDevice is not supported") + public func deleteUser() async throws { + preconditionFailure("method not supported") } - public func signIn( - username: String?, - password: String?, - options: AuthSignInRequest.Options?, - listener: ((AmplifyOperation.OperationResult) - -> Void)? - ) -> AuthSignInOperation { - preconditionFailure("signIn is not supported") + public func resetPassword(for username: String, options: AuthResetPasswordRequest.Options?) async throws -> AuthResetPasswordResult { + preconditionFailure("method not supported") } - public func update( - userAttributes: [AuthUserAttribute], - options: AuthUpdateUserAttributesRequest.Options?, - listener: ((AmplifyOperation, - AuthError>.OperationResult) - -> Void)? - ) -> AuthUpdateUserAttributesOperation { - preconditionFailure("update is not supported") + public func confirmResetPassword(for username: String, with newPassword: String, confirmationCode: String, options: AuthConfirmResetPasswordRequest.Options?) async throws { + preconditionFailure("method not supported") } - public func signInWithWebUI( - presentationAnchor: AuthUIPresentationAnchor, - options: AuthWebUISignInRequest.Options?, - listener: ((AmplifyOperation.OperationResult) - -> Void)? - ) -> AuthWebUISignInOperation { - preconditionFailure("signInWithWebUI is not supported") + public func setUpTOTP() async throws -> TOTPSetupDetails { + preconditionFailure("method not supported") } - public func resendConfirmationCode( - for attributeKey: AuthUserAttributeKey, - options: AuthAttributeResendConfirmationCodeRequest.Options?, - listener: ((AmplifyOperation.OperationResult) - -> Void)? - ) -> AuthAttributeResendConfirmationCodeOperation { - preconditionFailure("resendConfirmationCode is not supported") + public func verifyTOTPSetup(code: String, options: VerifyTOTPSetupRequest.Options?) async throws { + preconditionFailure("method not supported") } - public func signInWithWebUI( - for authProvider: AuthProvider, - presentationAnchor: AuthUIPresentationAnchor, - options: AuthWebUISignInRequest.Options?, - listener: ((AmplifyOperation.OperationResult) - -> Void)? - ) -> AuthSocialWebUISignInOperation { - preconditionFailure("signInWithWebUI is not supported") + public func fetchUserAttributes(options: AuthFetchUserAttributesRequest.Options?) async throws -> [AuthUserAttribute] { + preconditionFailure("method not supported") } - public func confirm( - userAttribute: AuthUserAttributeKey, - confirmationCode: String, - options: AuthConfirmUserAttributeRequest.Options?, - listener: ((AmplifyOperation.OperationResult) - -> Void)? - ) -> AuthConfirmUserAttributeOperation { - preconditionFailure("confirm is not supported") + public func update(userAttribute: AuthUserAttribute, options: AuthUpdateUserAttributeRequest.Options?) async throws -> AuthUpdateAttributeResult { + preconditionFailure("method not supported") } - public func confirmSignIn( - challengeResponse: String, - options: AuthConfirmSignInRequest.Options?, - listener: ((AmplifyOperation.OperationResult) - -> Void)? - ) -> AuthConfirmSignInOperation { - preconditionFailure("confirmSignIn is not supported") + public func update(userAttributes: [AuthUserAttribute], options: AuthUpdateUserAttributesRequest.Options?) async throws -> [AuthUserAttributeKey : AuthUpdateAttributeResult] { + preconditionFailure("method not supported") } - public func update( - oldPassword: String, - to newPassword: String, - options: AuthChangePasswordRequest.Options?, - listener: ((AmplifyOperation.OperationResult) - -> Void)? - ) -> AuthChangePasswordOperation { - preconditionFailure("update is not supported") + public func resendConfirmationCode(forUserAttributeKey userAttributeKey: AuthUserAttributeKey, options: AuthAttributeResendConfirmationCodeRequest.Options?) async throws -> AuthCodeDeliveryDetails { + preconditionFailure("method not supported") } - public func signOut( - options: AuthSignOutRequest.Options?, - listener: ((AmplifyOperation.OperationResult) - -> Void)? - ) -> AuthSignOutOperation { - preconditionFailure("signOut is not supported") + public func sendVerificationCode(forUserAttributeKey userAttributeKey: AuthUserAttributeKey, options: AuthSendUserAttributeVerificationCodeRequest.Options?) async throws -> AuthCodeDeliveryDetails { + preconditionFailure("method not supported") } - public func deleteUser( - listener: ((AmplifyOperation.OperationResult) - -> Void)? - ) -> AuthDeleteUserOperation { - preconditionFailure("deleteUser is not supported") + public func confirm(userAttribute: AuthUserAttributeKey, confirmationCode: String, options: AuthConfirmUserAttributeRequest.Options?) async throws { + preconditionFailure("method not supported") } - public func resetPassword( - for username: String, - options: AuthResetPasswordRequest.Options?, - listener: ((AmplifyOperation.OperationResult) - -> Void)? - ) -> AuthResetPasswordOperation { - preconditionFailure("resetPassword is not supported") + public func update(oldPassword: String, to newPassword: String, options: AuthChangePasswordRequest.Options?) async throws { + preconditionFailure("method not supported") } - public func confirmResetPassword( - for username: String, - with newPassword: String, - confirmationCode: String, - options: AuthConfirmResetPasswordRequest.Options?, - listener: ((AmplifyOperation.OperationResult) - -> Void)? - ) -> AuthConfirmResetPasswordOperation { - preconditionFailure("resetPassword is not supported") + public func fetchDevices(options: AuthFetchDevicesRequest.Options?) async throws -> [any AuthDevice] { + preconditionFailure("method not supported") } + public func forgetDevice(_ device: (any AuthDevice)?, options: AuthForgetDeviceRequest.Options?) async throws { + preconditionFailure("method not supported") + } + + public func rememberDevice(options: AuthRememberDeviceRequest.Options?) async throws { + preconditionFailure("method not supported") + } } diff --git a/packages/amplify_datastore/ios/Classes/DataStoreBridge.swift b/packages/amplify_datastore/ios/Classes/DataStoreBridge.swift index 57da717a6a..93449585d7 100644 --- a/packages/amplify_datastore/ios/Classes/DataStoreBridge.swift +++ b/packages/amplify_datastore/ios/Classes/DataStoreBridge.swift @@ -2,8 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import Foundation -import Amplify -import AmplifyPlugins import Combine public class DataStoreBridge { diff --git a/packages/amplify_datastore/ios/Classes/DataStoreHubEventStreamHandler.swift b/packages/amplify_datastore/ios/Classes/DataStoreHubEventStreamHandler.swift index ca5c7b5ad2..d2c8e4052d 100644 --- a/packages/amplify_datastore/ios/Classes/DataStoreHubEventStreamHandler.swift +++ b/packages/amplify_datastore/ios/Classes/DataStoreHubEventStreamHandler.swift @@ -1,9 +1,8 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +import Flutter import Foundation -import Amplify -import AmplifyPlugins public class DataStoreHubEventStreamHandler: NSObject, FlutterStreamHandler { private var eventSink: FlutterEventSink? diff --git a/packages/amplify_datastore/ios/Classes/DataStoreObserveEventStreamHandler.swift b/packages/amplify_datastore/ios/Classes/DataStoreObserveEventStreamHandler.swift index 78bcc66b74..e4ae0c5095 100644 --- a/packages/amplify_datastore/ios/Classes/DataStoreObserveEventStreamHandler.swift +++ b/packages/amplify_datastore/ios/Classes/DataStoreObserveEventStreamHandler.swift @@ -1,6 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +import Flutter import Foundation public class DataStoreObserveEventStreamHandler: NSObject, FlutterStreamHandler { @@ -12,11 +13,15 @@ public class DataStoreObserveEventStreamHandler: NSObject, FlutterStreamHandler } func sendEvent(flutterEvent: [String: Any]) { - eventSink?(flutterEvent) + DispatchQueue.main.async { + self.eventSink?(flutterEvent) + } } func sendError(flutterError: FlutterError) { - eventSink?(flutterError) + DispatchQueue.main.async { + self.eventSink?(flutterError) + } } public func onCancel(withArguments arguments: Any?) -> FlutterError? { diff --git a/packages/amplify_datastore/ios/Classes/FlutterApiPlugin.swift b/packages/amplify_datastore/ios/Classes/FlutterApiPlugin.swift new file mode 100644 index 0000000000..bc447bc77e --- /dev/null +++ b/packages/amplify_datastore/ios/Classes/FlutterApiPlugin.swift @@ -0,0 +1,186 @@ +import Foundation +import Flutter +import Combine + + + +public class FlutterApiPlugin: APICategoryPlugin +{ + public var key: PluginKey = "awsAPIPlugin" + private let apiAuthFactory: APIAuthProviderFactory + private let nativeApiPlugin: NativeApiPlugin + private let nativeSubscriptionEvents: PassthroughSubject + private var cancellables = Set() + + init( + apiAuthProviderFactory: APIAuthProviderFactory, + nativeApiPlugin: NativeApiPlugin, + subscriptionEventBus: PassthroughSubject + ) { + self.apiAuthFactory = apiAuthProviderFactory + self.nativeApiPlugin = nativeApiPlugin + self.nativeSubscriptionEvents = subscriptionEventBus + } + + public func query(request: GraphQLRequest) async throws -> GraphQLTask.Success where R : Decodable { + let response = await asyncQuery(nativeRequest: request.toNativeGraphQLRequest()) + return try decodeGraphQLPayloadJson(request: request, payload: response.payloadJson) + } + + + public func mutate(request: GraphQLRequest) async throws -> GraphQLTask.Success where R : Decodable { + let response = await asyncMutate(nativeRequest: request.toNativeGraphQLRequest()) + return try decodeGraphQLPayloadJson(request: request, payload: response.payloadJson) + } + + public func subscribe( + request: GraphQLRequest + ) -> AmplifyAsyncThrowingSequence> where R : Decodable { + var subscriptionId: String? = "" + + // TODO: write a e2e test to ensure we don't go over 100 AppSync connections + func unsubscribe(subscriptionId: String?){ + if let subscriptionId { + DispatchQueue.main.async { + self.nativeApiPlugin.unsubscribe(subscriptionId: subscriptionId) {} + } + } + } + + // TODO: shouldn't there be a timeout if there is no start_ack returned in a certain period of time + let (sequence, cancellable) = nativeSubscriptionEvents + .receive(on: DispatchQueue.global()) + .filter { $0.subscriptionId == subscriptionId } + .handleEvents(receiveCompletion: {_ in + unsubscribe(subscriptionId: subscriptionId) + }, receiveCancel: { + unsubscribe(subscriptionId: subscriptionId) + }) + .compactMap { [weak self] event -> GraphQLSubscriptionEvent? in + switch event.type { + case "connecting": + return .connection(.connecting) + case "start_ack": + return .connection(.connected) + case "complete": + return .connection(.disconnected) + case "data": + if let responseDecoded: GraphQLResponse = + try? self?.decodeGraphQLPayloadJson(request: request, payload: event.payloadJson) + { + return .data(responseDecoded) + } + return nil + case "error": + if let payload = event.payloadJson { + return .data(.fromAppSyncSubscriptionErrorResponse(string: payload)) + } + return nil + default: + print("ERROR unsupported subscription event type! \(String(describing: event.type))") + return nil + } + } + .toAmplifyAsyncThrowingSequence() + cancellables.insert(cancellable) // the subscription is bind with class instance lifecycle, it should be released when stream is finished or unsubscribed + sequence.send(.connection(.connecting)) + DispatchQueue.main.async { + self.nativeApiPlugin.subscribe(request: request.toNativeGraphQLRequest()) { response in + subscriptionId = response.subscriptionId + } + } + return sequence + } + + private func decodeGraphQLPayloadJson( + request: GraphQLRequest, + payload: String? + ) throws -> GraphQLResponse { + guard let payload else { + throw DataStoreError.decodingError("Request payload could not be empty", "") + } + + return GraphQLResponse.fromAppSyncResponse( + string: payload, + decodePath: request.decodePath + ) + } + + private func decodeGraphQLSubscriptionPayloadJson( + request: GraphQLRequest, + payload: String? + ) throws -> GraphQLResponse { + guard let payload else { + throw DataStoreError.decodingError("Request payload could not be empty", "") + } + + return GraphQLResponse.fromAppSyncSubscriptionResponse( + string: payload, + decodePath: request.decodePath + ) + } + + func asyncQuery(nativeRequest: NativeGraphQLRequest) async -> NativeGraphQLResponse { + await withCheckedContinuation { continuation in + DispatchQueue.main.async { + self.nativeApiPlugin.query(request: nativeRequest) { response in + continuation.resume(returning: response) + } + } + } + } + + func asyncMutate(nativeRequest: NativeGraphQLRequest) async -> NativeGraphQLResponse{ + await withCheckedContinuation { continuation in + DispatchQueue.main.async { + self.nativeApiPlugin.mutate(request: nativeRequest) { response in + continuation.resume(returning: response) + } + } + } + } + + public func configure(using configuration: Any?) throws { } + + public func apiAuthProviderFactory() -> APIAuthProviderFactory { + return self.apiAuthFactory + } + + public func add(interceptor: any URLRequestInterceptor, for apiName: String) throws { + preconditionFailure("method not supported") + } + + public func get(request: RESTRequest) async throws -> RESTTask.Success { + preconditionFailure("method not supported") + } + + public func put(request: RESTRequest) async throws -> RESTTask.Success { + preconditionFailure("method not supported") + } + + public func post(request: RESTRequest) async throws -> RESTTask.Success { + preconditionFailure("method not supported") + } + + public func delete(request: RESTRequest) async throws -> RESTTask.Success { + preconditionFailure("method not supported") + } + + public func head(request: RESTRequest) async throws -> RESTTask.Success { + preconditionFailure("method not supported") + } + + public func patch(request: RESTRequest) async throws -> RESTTask.Success { + preconditionFailure("method not supported") + } + + public func reachabilityPublisher(for apiName: String?) throws -> AnyPublisher? { + preconditionFailure("method not supported") + } + + public func reachabilityPublisher() throws -> AnyPublisher? { + return nil + } + + +} diff --git a/packages/amplify_datastore/ios/Classes/FlutterDataStoreErrorHandler.swift b/packages/amplify_datastore/ios/Classes/FlutterDataStoreErrorHandler.swift index 8cb8d5b163..8f9411b2db 100644 --- a/packages/amplify_datastore/ios/Classes/FlutterDataStoreErrorHandler.swift +++ b/packages/amplify_datastore/ios/Classes/FlutterDataStoreErrorHandler.swift @@ -1,9 +1,8 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +import Flutter import Foundation -import Amplify -import AmplifyPlugins class FlutterDataStoreErrorHandler { static func handleDataStoreError(error: DataStoreError, flutterResult: FlutterResult) { diff --git a/packages/amplify_datastore/ios/Classes/FlutterDataStoreRequestUtils.swift b/packages/amplify_datastore/ios/Classes/FlutterDataStoreRequestUtils.swift index 9ae3b3f947..e0d8b989af 100644 --- a/packages/amplify_datastore/ios/Classes/FlutterDataStoreRequestUtils.swift +++ b/packages/amplify_datastore/ios/Classes/FlutterDataStoreRequestUtils.swift @@ -2,8 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import Foundation -import Amplify -import AmplifyPlugins public enum FlutterDataStoreRequestUtils { static func getJSONValue(_ jsonDict: [String: Any]) throws -> [String: JSONValue] { diff --git a/packages/amplify_datastore/ios/Classes/FlutterSchemaRegistry.swift b/packages/amplify_datastore/ios/Classes/FlutterSchemaRegistry.swift index 30a6392b0c..66023371a8 100644 --- a/packages/amplify_datastore/ios/Classes/FlutterSchemaRegistry.swift +++ b/packages/amplify_datastore/ios/Classes/FlutterSchemaRegistry.swift @@ -1,7 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import Amplify import Foundation // Contains the set of classes that conforms to the `Model` protocol. diff --git a/packages/amplify_datastore/ios/Classes/SwiftAmplifyDataStorePlugin.swift b/packages/amplify_datastore/ios/Classes/SwiftAmplifyDataStorePlugin.swift index 0b42447b07..e94d41e0ed 100644 --- a/packages/amplify_datastore/ios/Classes/SwiftAmplifyDataStorePlugin.swift +++ b/packages/amplify_datastore/ios/Classes/SwiftAmplifyDataStorePlugin.swift @@ -3,10 +3,6 @@ import Flutter import UIKit -import Amplify -import AmplifyPlugins -import AWSPluginsCore -import AWSCore import Combine public class SwiftAmplifyDataStorePlugin: NSObject, FlutterPlugin, NativeAmplifyBridge, NativeAuthBridge, NativeApiBridge { @@ -15,6 +11,7 @@ public class SwiftAmplifyDataStorePlugin: NSObject, FlutterPlugin, NativeAmplify private let customTypeSchemaRegistry: FlutterSchemaRegistry private let dataStoreObserveEventStreamHandler: DataStoreObserveEventStreamHandler? private let dataStoreHubEventStreamHandler: DataStoreHubEventStreamHandler? + private let nativeSubscriptionEventBus = PassthroughSubject() private var channel: FlutterMethodChannel? private var observeSubscription: AnyCancellable? private let nativeAuthPlugin: NativeAuthPlugin @@ -89,11 +86,13 @@ public class SwiftAmplifyDataStorePlugin: NSObject, FlutterPlugin, NativeAmplify AWSAuthorizationType(rawValue: $0) } try Amplify.add( - plugin: AWSAPIPlugin( + plugin: FlutterApiPlugin( apiAuthProviderFactory: FlutterAuthProviders( authProviders: authProviders, nativeApiPlugin: nativeApiPlugin - ) + ), + nativeApiPlugin: nativeApiPlugin, + subscriptionEventBus: nativeSubscriptionEventBus ) ) return completion(.success(())) @@ -146,7 +145,8 @@ public class SwiftAmplifyDataStorePlugin: NSObject, FlutterPlugin, NativeAmplify } let amplifyConfiguration = try JSONDecoder().decode(AmplifyConfiguration.self, from: data) - AmplifyAWSServiceConfiguration.addUserAgentPlatform(.flutter, version: "\(version) /datastore") + // TODO: Migrate to Async Swift v2 + // AmplifyAWSServiceConfiguration.addUserAgentPlatform(.flutter, version: "\(version) /datastore") try Amplify.configure(amplifyConfiguration) return completion(.success(())) } catch let error as ConfigurationError { @@ -296,6 +296,17 @@ public class SwiftAmplifyDataStorePlugin: NSObject, FlutterPlugin, NativeAmplify modelSchemas.forEach { modelSchema in modelSchemaRegistry.addModelSchema(modelName: modelSchema.name, modelSchema: modelSchema) } + + // Amplify Swift DataStore system schemas must be added manually + let systemSchemas = [ + ModelSyncMetadata.schema, + MutationEvent.schema, + MutationSyncMetadata.schema + ] + + systemSchemas.forEach { modelSchema in + modelSchemaRegistry.addModelSchema(modelName: modelSchema.name, modelSchema: modelSchema) + } modelSchemaRegistry.version = modelProviderVersion @@ -611,6 +622,10 @@ public class SwiftAmplifyDataStorePlugin: NSObject, FlutterPlugin, NativeAmplify flutterResult: flutterResult) } } + + func sendSubscriptionEvent(event: NativeGraphQLSubscriptionResponse, completion: @escaping (Result) -> Void) { + nativeSubscriptionEventBus.send(event) + } private func checkArguments(args: Any) throws -> [String: Any] { guard let res = args as? [String: Any] else { diff --git a/packages/amplify_datastore/ios/Classes/api/GraphQLRequest+Extension.swift b/packages/amplify_datastore/ios/Classes/api/GraphQLRequest+Extension.swift new file mode 100644 index 0000000000..9aedf6fc22 --- /dev/null +++ b/packages/amplify_datastore/ios/Classes/api/GraphQLRequest+Extension.swift @@ -0,0 +1,25 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + + +import Foundation + +extension GraphQLRequest { + func toNativeGraphQLRequest() -> NativeGraphQLRequest { + let variablesJson = self.variables + .flatMap { try? JSONSerialization.data(withJSONObject: $0, options: []) } + .flatMap { String(data: $0, encoding: .utf8) } + + return NativeGraphQLRequest( + document: self.document, + apiName: self.apiName, + variablesJson: variablesJson ?? "{}", + responseType: String(describing: self.responseType), + decodePath: self.decodePath + ) + } +} diff --git a/packages/amplify_datastore/ios/Classes/api/GraphQLResponse+Decode.swift b/packages/amplify_datastore/ios/Classes/api/GraphQLResponse+Decode.swift new file mode 100644 index 0000000000..ebc8a68819 --- /dev/null +++ b/packages/amplify_datastore/ios/Classes/api/GraphQLResponse+Decode.swift @@ -0,0 +1,240 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + + +import Foundation + +extension GraphQLResponse { + static var jsonDecoder: JSONDecoder { + let decoder = JSONDecoder(dateDecodingStrategy: ModelDateFormatting.decodingStrategy) + return decoder + } + + static var jsonEncoder: JSONEncoder { + let encoder = JSONEncoder(dateEncodingStrategy: ModelDateFormatting.encodingStrategy) + return encoder + } + + public static func fromAppSyncResponse( + string: String, + decodePath: String?, + modelName: String? = nil + ) -> GraphQLResponse { + guard let data = string.data(using: .utf8) else { + return .failure(.transformationError( + string, + .unknown("Unable to deserialize json data", "Check the event structure.") + )) + } + + let result: Result, APIError> = toJson(data: data).flatMap { + fromAppSyncResponse(json: $0, decodePath: decodePath, modelName: modelName) + } + + switch result { + case .success(let response): return response + case .failure(let error): return .failure(.transformationError(string, error)) + } + } + + public static func fromAppSyncSubscriptionResponse( + string: String, + decodePath: String?, + modelName: String? = nil + ) -> GraphQLResponse { + guard let data = string.data(using: .utf8) else { + return .failure(.transformationError( + string, + .unknown("Unable to deserialize json data", "Check the event structure.") + )) + } + + return toJson(data: data) + .flatMap { + if let decodePath, let data = $0.value(at: decodePath) { + return .success(data) + } else { + return .failure(APIError.unknown("Failed to get data from AppSync response", "")) + } + } + .flatMap { decodeDataPayload($0, modelName: modelName) } + .mapError { .transformationError(string, $0) } + } + + public static func fromAppSyncSubscriptionErrorResponse( + string: String + ) -> GraphQLResponse { + guard let data = string.data(using: .utf8) else { + return .failure(.transformationError( + string, + .unknown("Unable to deserialize json data", "Check the event structure.") + )) + } + + let result = toJson(data: data) + .flatMap { + let errors = $0.errors + if errors != nil { + return .success(errors?.asArray ?? [errors!]) + } else { + return .failure(.unknown("Failed to get errors from AppSync response", "")) + } + } + .map { $0.compactMap(parseGraphQLError(error:)) } + + switch result { + case .success(let errors): return .failure(.error(errors)) + case .failure(let apiError): return .failure(.transformationError(string, apiError)) + } + } + +// MARK: - internal methods + // following logic in + // https://github.com/aws-amplify/amplify-swift/blob/main/AmplifyPlugins/API/Sources/AWSAPIPlugin/Support/Internal/AWSAppSyncGraphQLResponse.swift#L18-L38 + static func fromAppSyncResponse( + json: JSONValue, + decodePath: String?, + modelName: String? + ) -> Result, APIError> { + let data = decodePath != nil ? json.value(at: decodePath!) : json + let errors = json.errors?.asArray + switch (data, errors) { + case (.some(let data), .none): + return decodeDataPayload(data, modelName: modelName).map { .success($0) } + case (.none, .some(let errors)): + return .success(.failure(.error(errors.compactMap(parseGraphQLError(error:))))) + case (.some(let data), .some(let errors)): + return decodeDataPayload(data, modelName: modelName).map { + .failure(.partial($0, errors.compactMap(parseGraphQLError(error:)))) + } + case (.none, .none): + return .failure(.unknown( + "Failed to get data object or errors from GraphQL response", + "The AppSync service returned a malformed GraphQL response" + )) + } + } + + // folowing logic in + // https://github.com/aws-amplify/amplify-swift/blob/main/AmplifyPlugins/API/Sources/AWSAPIPlugin/Support/Decode/GraphQLErrorDecoder.swift + static func parseGraphQLError( + error: JSONValue + ) -> GraphQLError? { + guard let errorObject = error.asObject else { + return nil + } + + let extensions = errorObject.enumerated().filter { !["message", "locations", "path", "extensions"].contains($0.element.key) } + .reduce([String: JSONValue]()) { partialResult, item in + partialResult.merging([item.element.key: item.element.value]) { $1 } + } + + return (try? jsonEncoder.encode(error)) + .flatMap { try? jsonDecoder.decode(GraphQLError.self, from: $0) } + .map { + GraphQLError( + message: $0.message, + locations: $0.locations, + path: $0.path, + extensions: extensions.isEmpty ? nil : extensions + ) + } + } + + static func decodeDataPayload( + _ dataPayload: JSONValue, + modelName: String? + ) -> Result { + if R.self == String.self { + return encodeDataPayloadToString(dataPayload).map { $0 as! R } + } + + let dataPayloadWithTypeName = modelName.flatMap { + dataPayload.asObject?.merging( + ["__typename": .string($0)] + ) { a, _ in a } + }.map { JSONValue.object($0) } ?? dataPayload + + if R.self == AnyModel.self { + return decodeDataPayloadToAnyModel(dataPayloadWithTypeName).map { $0 as! R } + } + + return fromJson(dataPayloadWithTypeName) + .flatMap { data in + Result { try jsonDecoder.decode(R.self, from: data) } + .mapError { APIError.operationError("Could not decode json to type \(R.self)", "", $0)} + } + } + + static func decodeDataPayloadToAnyModel( + _ dataPayload: JSONValue + ) -> Result { + guard let typeName = dataPayload.__typename?.stringValue else { + return .failure(.operationError( + "Could not retrieve __typename from object", + """ + Could not retrieve the `__typename` attribute from the return value. Be sure to include __typename in \ + the selection set of the GraphQL operation. GraphQL: + \(dataPayload) + """ + )) + } + + return encodeDataPayloadToString(dataPayload).flatMap { underlyingModelString in + do { + return .success(.init(try ModelRegistry.decode( + modelName: typeName, + from: underlyingModelString, + jsonDecoder: jsonDecoder + ))) + } catch { + return .failure(.operationError( + "Could not decode to \(typeName) with \(underlyingModelString)", + "" + )) + } + } + } + + static func encodeDataPayloadToString( + _ dataPayload: JSONValue + ) -> Result { + fromJson(dataPayload).flatMap { + guard let string = String(data: $0, encoding: .utf8) else { + return .failure( + .operationError("Could not get String from Data", "", nil) + ) + } + return .success(string) + } + } + + static func toJson(data: Data) -> Result { + do { + return .success(try jsonDecoder.decode(JSONValue.self, from: data)) + } catch { + return .failure(.operationError( + "Could not decode to JSONValue from GraphQL Response", + "Service issue", + error + )) + } + } + + static func fromJson(_ json: JSONValue) -> Result { + do { + return .success(try jsonEncoder.encode(json)) + } catch { + return .failure(.operationError( + "Could not encode JSONValue to Data", + "", + error + )) + } + } + +} diff --git a/packages/amplify_datastore/ios/Classes/api/Publisher+Extension.swift b/packages/amplify_datastore/ios/Classes/api/Publisher+Extension.swift new file mode 100644 index 0000000000..a7929d992d --- /dev/null +++ b/packages/amplify_datastore/ios/Classes/api/Publisher+Extension.swift @@ -0,0 +1,28 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + + +import Foundation +import Combine + +extension Publisher { + func toAmplifyAsyncThrowingSequence() -> (AmplifyAsyncThrowingSequence, AnyCancellable) { + let sequence = AmplifyAsyncThrowingSequence() + let cancellable = self.sink { completion in + switch completion { + case .finished: + sequence.finish() + case .failure(let error): + sequence.fail(error) + } + } receiveValue: { data in + sequence.send(data) + } + + return (sequence, cancellable) + } +} diff --git a/packages/amplify_datastore/ios/Classes/auth/FlutterAuthProviders.swift b/packages/amplify_datastore/ios/Classes/auth/FlutterAuthProviders.swift index 4d7e3c2523..53be119532 100644 --- a/packages/amplify_datastore/ios/Classes/auth/FlutterAuthProviders.swift +++ b/packages/amplify_datastore/ios/Classes/auth/FlutterAuthProviders.swift @@ -2,9 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import Foundation -import Amplify -import AmplifyPlugins -import AWSPluginsCore import Flutter /// A factory of [FlutterAuthProvider] instances. Manages shared state for all providers. @@ -90,8 +87,14 @@ class FlutterAuthProviders: APIAuthProviderFactory { struct FlutterAuthProvider: AmplifyOIDCAuthProvider, AmplifyFunctionAuthProvider { let flutterAuthProviders: FlutterAuthProviders let type: AWSAuthorizationType - - func getLatestAuthToken() -> Result { - return flutterAuthProviders.getToken(for: type) + + func getLatestAuthToken() async throws -> String { + let result = flutterAuthProviders.getToken(for: type) + switch result { + case .success(let token): + return token + case .failure(let error): + throw error + } } } diff --git a/packages/amplify_datastore/ios/Classes/bridge/NativeAWSAuthCognitoSession.swift b/packages/amplify_datastore/ios/Classes/bridge/NativeAWSAuthCognitoSession.swift index c76acb2a31..b93ee5e678 100644 --- a/packages/amplify_datastore/ios/Classes/bridge/NativeAWSAuthCognitoSession.swift +++ b/packages/amplify_datastore/ios/Classes/bridge/NativeAWSAuthCognitoSession.swift @@ -2,8 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import Foundation -import Amplify -import AWSPluginsCore struct NativeAWSAuthCognitoSession: AuthSession, AuthAWSCredentialsProvider, @@ -11,7 +9,7 @@ struct NativeAWSAuthCognitoSession: AuthSession, AuthCognitoIdentityProvider { let isSignedIn: Bool let identityIdResult: Result - let awsCredentialsResult: Result + let awsCredentialsResult: Result let userSubResult: Result let cognitoTokensResult: Result @@ -31,7 +29,7 @@ struct NativeAWSAuthCognitoSession: AuthSession, .failure(.unknown("Could not retrieve AWS credentials", nil)) } - public func getAWSCredentials() -> Result { + public func getAWSCredentials() -> Result { awsCredentialsResult } diff --git a/packages/amplify_datastore/ios/Classes/bridge/NativeAWSCredentials+AuthAWSCredentials.swift b/packages/amplify_datastore/ios/Classes/bridge/NativeAWSCredentials+AuthAWSCredentials.swift index ffb4df74dc..2b6988ae1e 100644 --- a/packages/amplify_datastore/ios/Classes/bridge/NativeAWSCredentials+AuthAWSCredentials.swift +++ b/packages/amplify_datastore/ios/Classes/bridge/NativeAWSCredentials+AuthAWSCredentials.swift @@ -2,17 +2,16 @@ // SPDX-License-Identifier: Apache-2.0 import Foundation -import AWSPluginsCore -struct NativeAWSPermanentCredentials: AuthAWSCredentials { - let accessKey: String - let secretKey: String +struct NativeAWSPermanentCredentials: AWSCredentials { + let accessKeyId: String + let secretAccessKey: String } -struct NativeAWSTemporaryCredentials: AuthAWSCredentials, AuthAWSTemporaryCredentials { - let accessKey: String - let secretKey: String - let sessionKey: String +struct NativeAWSTemporaryCredentials: AWSCredentials, AWSTemporaryCredentials { + let accessKeyId: String + let secretAccessKey: String + let sessionToken: String let expiration: Date init( @@ -21,9 +20,9 @@ struct NativeAWSTemporaryCredentials: AuthAWSCredentials, AuthAWSTemporaryCreden sessionToken: String, expiration: Date? ) { - self.accessKey = accessKeyId - self.secretKey = secretAccessKey - self.sessionKey = sessionToken + self.accessKeyId = accessKeyId + self.secretAccessKey = secretAccessKey + self.sessionToken = sessionToken self.expiration = expiration ?? Date.distantFuture } } @@ -31,7 +30,7 @@ struct NativeAWSTemporaryCredentials: AuthAWSCredentials, AuthAWSTemporaryCreden extension NativeAWSCredentials { static private let dateFormatter = ISO8601DateFormatter() - var asAuthAWSCredentials: AuthAWSCredentials { + var asAuthAWSCredentials: AWSCredentials { if let sessionKey = sessionToken { let expirationStr = expirationIso8601Utc let expiration = expirationStr == nil ? nil : @@ -44,8 +43,8 @@ extension NativeAWSCredentials { ) } return NativeAWSPermanentCredentials( - accessKey: accessKeyId, - secretKey: secretAccessKey + accessKeyId: accessKeyId, + secretAccessKey: secretAccessKey ) } } diff --git a/packages/amplify_datastore/ios/Classes/bridge/NativeAuthFetchSessionOperation.swift b/packages/amplify_datastore/ios/Classes/bridge/NativeAuthFetchSessionOperation.swift deleted file mode 100644 index 93af17a14c..0000000000 --- a/packages/amplify_datastore/ios/Classes/bridge/NativeAuthFetchSessionOperation.swift +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -import Foundation -import Amplify - -class NativeAuthFetchSessionOperation: AmplifyOperation, - AuthFetchSessionOperation {} diff --git a/packages/amplify_datastore/ios/Classes/bridge/NativeUserPoolTokens+AuthCognitoTokens.swift b/packages/amplify_datastore/ios/Classes/bridge/NativeUserPoolTokens+AuthCognitoTokens.swift index c67036ab4c..5c219145bf 100644 --- a/packages/amplify_datastore/ios/Classes/bridge/NativeUserPoolTokens+AuthCognitoTokens.swift +++ b/packages/amplify_datastore/ios/Classes/bridge/NativeUserPoolTokens+AuthCognitoTokens.swift @@ -2,6 +2,5 @@ // SPDX-License-Identifier: Apache-2.0 import Foundation -import AWSPluginsCore extension NativeUserPoolTokens: AuthCognitoTokens { } diff --git a/packages/amplify_datastore/ios/Classes/pigeons/NativePluginBindings.swift b/packages/amplify_datastore/ios/Classes/pigeons/NativePluginBindings.swift index 94d8936d1d..be5850a1a8 100644 --- a/packages/amplify_datastore/ios/Classes/pigeons/NativePluginBindings.swift +++ b/packages/amplify_datastore/ios/Classes/pigeons/NativePluginBindings.swift @@ -1,7 +1,7 @@ // // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -// Autogenerated from Pigeon (v11.0.0), do not edit directly. +// Autogenerated from Pigeon (v11.0.1), do not edit directly. // See also: https://pub.dev/packages/pigeon import Foundation @@ -159,6 +159,92 @@ struct NativeAWSCredentials { } } +/// Generated class from Pigeon that represents data sent in messages. +struct NativeGraphQLResponse { + var payloadJson: String? = nil + var errorsJson: String? = nil + + static func fromList(_ list: [Any?]) -> NativeGraphQLResponse? { + let payloadJson: String? = nilOrValue(list[0]) + let errorsJson: String? = nilOrValue(list[1]) + + return NativeGraphQLResponse( + payloadJson: payloadJson, + errorsJson: errorsJson + ) + } + func toList() -> [Any?] { + return [ + payloadJson, + errorsJson, + ] + } +} + +/// Generated class from Pigeon that represents data sent in messages. +struct NativeGraphQLSubscriptionResponse { + var type: String + var subscriptionId: String + var payloadJson: String? = nil + + static func fromList(_ list: [Any?]) -> NativeGraphQLSubscriptionResponse? { + let type = list[0] as! String + let subscriptionId = list[1] as! String + let payloadJson: String? = nilOrValue(list[2]) + + return NativeGraphQLSubscriptionResponse( + type: type, + subscriptionId: subscriptionId, + payloadJson: payloadJson + ) + } + func toList() -> [Any?] { + return [ + type, + subscriptionId, + payloadJson, + ] + } +} + +/// Generated class from Pigeon that represents data sent in messages. +struct NativeGraphQLRequest { + var document: String + var apiName: String? = nil + var variablesJson: String? = nil + var responseType: String? = nil + var decodePath: String? = nil + var options: String? = nil + + static func fromList(_ list: [Any?]) -> NativeGraphQLRequest? { + let document = list[0] as! String + let apiName: String? = nilOrValue(list[1]) + let variablesJson: String? = nilOrValue(list[2]) + let responseType: String? = nilOrValue(list[3]) + let decodePath: String? = nilOrValue(list[4]) + let options: String? = nilOrValue(list[5]) + + return NativeGraphQLRequest( + document: document, + apiName: apiName, + variablesJson: variablesJson, + responseType: responseType, + decodePath: decodePath, + options: options + ) + } + func toList() -> [Any?] { + return [ + document, + apiName, + variablesJson, + responseType, + decodePath, + options, + ] + } +} + private class NativeAuthPluginCodecReader: FlutterStandardReader { override func readValue(ofType type: UInt8) -> Any? { switch type { @@ -205,6 +291,8 @@ class NativeAuthPluginCodec: FlutterStandardMessageCodec { static let shared = NativeAuthPluginCodec(readerWriter: NativeAuthPluginCodecReaderWriter()) } +/// Bridge for calling Auth from Native into Flutter +/// /// Generated class from Pigeon that represents Flutter messages that can be called from Swift. class NativeAuthPlugin { private let binaryMessenger: FlutterBinaryMessenger @@ -222,20 +310,100 @@ class NativeAuthPlugin { } } } +private class NativeApiPluginCodecReader: FlutterStandardReader { + override func readValue(ofType type: UInt8) -> Any? { + switch type { + case 128: + return NativeGraphQLRequest.fromList(self.readValue() as! [Any?]) + case 129: + return NativeGraphQLResponse.fromList(self.readValue() as! [Any?]) + case 130: + return NativeGraphQLSubscriptionResponse.fromList(self.readValue() as! [Any?]) + default: + return super.readValue(ofType: type) + } + } +} + +private class NativeApiPluginCodecWriter: FlutterStandardWriter { + override func writeValue(_ value: Any) { + if let value = value as? NativeGraphQLRequest { + super.writeByte(128) + super.writeValue(value.toList()) + } else if let value = value as? NativeGraphQLResponse { + super.writeByte(129) + super.writeValue(value.toList()) + } else if let value = value as? NativeGraphQLSubscriptionResponse { + super.writeByte(130) + super.writeValue(value.toList()) + } else { + super.writeValue(value) + } + } +} + +private class NativeApiPluginCodecReaderWriter: FlutterStandardReaderWriter { + override func reader(with data: Data) -> FlutterStandardReader { + return NativeApiPluginCodecReader(data: data) + } + + override func writer(with data: NSMutableData) -> FlutterStandardWriter { + return NativeApiPluginCodecWriter(data: data) + } +} + +class NativeApiPluginCodec: FlutterStandardMessageCodec { + static let shared = NativeApiPluginCodec(readerWriter: NativeApiPluginCodecReaderWriter()) +} + +/// Bridge for calling API plugin from Native into Flutter +/// /// Generated class from Pigeon that represents Flutter messages that can be called from Swift. class NativeApiPlugin { private let binaryMessenger: FlutterBinaryMessenger init(binaryMessenger: FlutterBinaryMessenger){ self.binaryMessenger = binaryMessenger } + var codec: FlutterStandardMessageCodec { + return NativeApiPluginCodec.shared + } func getLatestAuthToken(providerName providerNameArg: String, completion: @escaping (String?) -> Void) { - let channel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.amplify_datastore.NativeApiPlugin.getLatestAuthToken", binaryMessenger: binaryMessenger) + let channel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.amplify_datastore.NativeApiPlugin.getLatestAuthToken", binaryMessenger: binaryMessenger, codec: codec) channel.sendMessage([providerNameArg] as [Any?]) { response in let result: String? = nilOrValue(response) completion(result) } } + func mutate(request requestArg: NativeGraphQLRequest, completion: @escaping (NativeGraphQLResponse) -> Void) { + let channel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.amplify_datastore.NativeApiPlugin.mutate", binaryMessenger: binaryMessenger, codec: codec) + channel.sendMessage([requestArg] as [Any?]) { response in + let result = response as! NativeGraphQLResponse + completion(result) + } + } + func query(request requestArg: NativeGraphQLRequest, completion: @escaping (NativeGraphQLResponse) -> Void) { + let channel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.amplify_datastore.NativeApiPlugin.query", binaryMessenger: binaryMessenger, codec: codec) + channel.sendMessage([requestArg] as [Any?]) { response in + let result = response as! NativeGraphQLResponse + completion(result) + } + } + func subscribe(request requestArg: NativeGraphQLRequest, completion: @escaping (NativeGraphQLSubscriptionResponse) -> Void) { + let channel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.amplify_datastore.NativeApiPlugin.subscribe", binaryMessenger: binaryMessenger, codec: codec) + channel.sendMessage([requestArg] as [Any?]) { response in + let result = response as! NativeGraphQLSubscriptionResponse + completion(result) + } + } + func unsubscribe(subscriptionId subscriptionIdArg: String, completion: @escaping () -> Void) { + let channel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.amplify_datastore.NativeApiPlugin.unsubscribe", binaryMessenger: binaryMessenger, codec: codec) + channel.sendMessage([subscriptionIdArg] as [Any?]) { _ in + completion() + } + } } +/// Bridge for calling Amplify from Flutter into Native +/// /// Generated protocol from Pigeon that represents a handler of messages from Flutter. protocol NativeAmplifyBridge { func configure(version: String, config: String, completion: @escaping (Result) -> Void) @@ -302,6 +470,8 @@ class NativeAuthBridgeCodec: FlutterStandardMessageCodec { static let shared = NativeAuthBridgeCodec(readerWriter: NativeAuthBridgeCodecReaderWriter()) } +/// Bridge for calling Auth plugin from Flutter into Native +/// /// Generated protocol from Pigeon that represents a handler of messages from Flutter. protocol NativeAuthBridge { func addAuthPlugin(completion: @escaping (Result) -> Void) @@ -346,17 +516,57 @@ class NativeAuthBridgeSetup { } } } +private class NativeApiBridgeCodecReader: FlutterStandardReader { + override func readValue(ofType type: UInt8) -> Any? { + switch type { + case 128: + return NativeGraphQLSubscriptionResponse.fromList(self.readValue() as! [Any?]) + default: + return super.readValue(ofType: type) + } + } +} + +private class NativeApiBridgeCodecWriter: FlutterStandardWriter { + override func writeValue(_ value: Any) { + if let value = value as? NativeGraphQLSubscriptionResponse { + super.writeByte(128) + super.writeValue(value.toList()) + } else { + super.writeValue(value) + } + } +} + +private class NativeApiBridgeCodecReaderWriter: FlutterStandardReaderWriter { + override func reader(with data: Data) -> FlutterStandardReader { + return NativeApiBridgeCodecReader(data: data) + } + + override func writer(with data: NSMutableData) -> FlutterStandardWriter { + return NativeApiBridgeCodecWriter(data: data) + } +} + +class NativeApiBridgeCodec: FlutterStandardMessageCodec { + static let shared = NativeApiBridgeCodec(readerWriter: NativeApiBridgeCodecReaderWriter()) +} + +/// Bridge for calling API methods from Flutter into Native +/// /// Generated protocol from Pigeon that represents a handler of messages from Flutter. protocol NativeApiBridge { func addApiPlugin(authProvidersList: [String], completion: @escaping (Result) -> Void) + func sendSubscriptionEvent(event: NativeGraphQLSubscriptionResponse, completion: @escaping (Result) -> Void) } /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. class NativeApiBridgeSetup { /// The codec used by NativeApiBridge. + static var codec: FlutterStandardMessageCodec { NativeApiBridgeCodec.shared } /// Sets up an instance of `NativeApiBridge` to handle messages through the `binaryMessenger`. static func setUp(binaryMessenger: FlutterBinaryMessenger, api: NativeApiBridge?) { - let addApiPluginChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.amplify_datastore.NativeApiBridge.addApiPlugin", binaryMessenger: binaryMessenger) + let addApiPluginChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.amplify_datastore.NativeApiBridge.addApiPlugin", binaryMessenger: binaryMessenger, codec: codec) if let api = api { addApiPluginChannel.setMessageHandler { message, reply in let args = message as! [Any?] @@ -373,5 +583,22 @@ class NativeApiBridgeSetup { } else { addApiPluginChannel.setMessageHandler(nil) } + let sendSubscriptionEventChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.amplify_datastore.NativeApiBridge.sendSubscriptionEvent", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + sendSubscriptionEventChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let eventArg = args[0] as! NativeGraphQLSubscriptionResponse + api.sendSubscriptionEvent(event: eventArg) { result in + switch result { + case .success: + reply(wrapResult(nil)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + sendSubscriptionEventChannel.setMessageHandler(nil) + } } } diff --git a/packages/amplify_datastore/ios/Classes/types/hub/FlutterHubElement.swift b/packages/amplify_datastore/ios/Classes/types/hub/FlutterHubElement.swift index c0e6925b21..0be641331e 100644 --- a/packages/amplify_datastore/ios/Classes/types/hub/FlutterHubElement.swift +++ b/packages/amplify_datastore/ios/Classes/types/hub/FlutterHubElement.swift @@ -4,15 +4,12 @@ import Foundation import Flutter import UIKit -import Amplify -import AmplifyPlugins -import AWSCore import Combine public struct FlutterHubElement { var model: [String: Any] var version: Int? - var lastChangedAt: Int? + var lastChangedAt: Int64? var deleted: Bool init( @@ -52,11 +49,11 @@ public struct FlutterHubElement { self.version = hubElement.version self.deleted = self.model["_deleted"] as? Bool ?? false if let value = self.model["_lastChangedAt"] as? Double { - self.lastChangedAt = Int(value) + self.lastChangedAt = Int64(value) } else if let value = self.model["_lastChangedAt"] as? String { - self.lastChangedAt = Int(value) + self.lastChangedAt = Int64(value) } else if let value = self.model["_lastChangedAt"] as? Int { - self.lastChangedAt = value + self.lastChangedAt = Int64(value) } } catch { throw FlutterDataStoreError.hubEventCast diff --git a/packages/amplify_datastore/ios/Classes/types/hub/FlutterModelSyncedEvent.swift b/packages/amplify_datastore/ios/Classes/types/hub/FlutterModelSyncedEvent.swift index f073704133..ca3cc514d5 100644 --- a/packages/amplify_datastore/ios/Classes/types/hub/FlutterModelSyncedEvent.swift +++ b/packages/amplify_datastore/ios/Classes/types/hub/FlutterModelSyncedEvent.swift @@ -2,8 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import Foundation -import Amplify -import AmplifyPlugins struct FlutterModelSyncedEvent: FlutterHubEvent { var eventName: String diff --git a/packages/amplify_datastore/ios/Classes/types/hub/FlutterNetworkStatusEvent.swift b/packages/amplify_datastore/ios/Classes/types/hub/FlutterNetworkStatusEvent.swift index c1f48a5d68..4514d8ad33 100644 --- a/packages/amplify_datastore/ios/Classes/types/hub/FlutterNetworkStatusEvent.swift +++ b/packages/amplify_datastore/ios/Classes/types/hub/FlutterNetworkStatusEvent.swift @@ -2,8 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import Foundation -import Amplify -import AmplifyPlugins struct FlutterNetworkStatusEvent: FlutterHubEvent { var eventName: String diff --git a/packages/amplify_datastore/ios/Classes/types/hub/FlutterOutboxMutationEnqueuedEvent.swift b/packages/amplify_datastore/ios/Classes/types/hub/FlutterOutboxMutationEnqueuedEvent.swift index 6f066efcea..d5ad7b24d2 100644 --- a/packages/amplify_datastore/ios/Classes/types/hub/FlutterOutboxMutationEnqueuedEvent.swift +++ b/packages/amplify_datastore/ios/Classes/types/hub/FlutterOutboxMutationEnqueuedEvent.swift @@ -2,8 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import Foundation -import Amplify -import AmplifyPlugins struct FlutterOutboxMutationEnqueuedEvent: FlutterHubEvent { var eventName: String diff --git a/packages/amplify_datastore/ios/Classes/types/hub/FlutterOutboxMutationProcessedEvent.swift b/packages/amplify_datastore/ios/Classes/types/hub/FlutterOutboxMutationProcessedEvent.swift index 82bb4f3c50..959a6ede6a 100644 --- a/packages/amplify_datastore/ios/Classes/types/hub/FlutterOutboxMutationProcessedEvent.swift +++ b/packages/amplify_datastore/ios/Classes/types/hub/FlutterOutboxMutationProcessedEvent.swift @@ -2,9 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import Foundation -import Amplify -import AmplifyPlugins -import AWSCore import Combine struct FlutterOutboxMutationProcessedEvent: FlutterHubEvent { diff --git a/packages/amplify_datastore/ios/Classes/types/hub/FlutterOutboxStatusEvent.swift b/packages/amplify_datastore/ios/Classes/types/hub/FlutterOutboxStatusEvent.swift index 00b51b4b16..beafbb507f 100644 --- a/packages/amplify_datastore/ios/Classes/types/hub/FlutterOutboxStatusEvent.swift +++ b/packages/amplify_datastore/ios/Classes/types/hub/FlutterOutboxStatusEvent.swift @@ -2,8 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import Foundation -import Amplify -import AmplifyPlugins struct FlutterOutboxStatusEvent: FlutterHubEvent { var eventName: String diff --git a/packages/amplify_datastore/ios/Classes/types/hub/FlutterReadyEvent.swift b/packages/amplify_datastore/ios/Classes/types/hub/FlutterReadyEvent.swift index 6567cb3dc7..dd90f5ed95 100644 --- a/packages/amplify_datastore/ios/Classes/types/hub/FlutterReadyEvent.swift +++ b/packages/amplify_datastore/ios/Classes/types/hub/FlutterReadyEvent.swift @@ -2,8 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import Foundation -import Amplify -import AmplifyPlugins struct FlutterReadyEvent: FlutterHubEvent { var eventName: String diff --git a/packages/amplify_datastore/ios/Classes/types/hub/FlutterSubscriptionsEstablishedEvent.swift b/packages/amplify_datastore/ios/Classes/types/hub/FlutterSubscriptionsEstablishedEvent.swift index fad150ea42..aa72700439 100644 --- a/packages/amplify_datastore/ios/Classes/types/hub/FlutterSubscriptionsEstablishedEvent.swift +++ b/packages/amplify_datastore/ios/Classes/types/hub/FlutterSubscriptionsEstablishedEvent.swift @@ -2,8 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import Foundation -import Amplify -import AmplifyPlugins struct FlutterSubscriptionsEstablishedEvent: FlutterHubEvent { var eventName: String diff --git a/packages/amplify_datastore/ios/Classes/types/hub/FlutterSyncQueriesReadyEvent.swift b/packages/amplify_datastore/ios/Classes/types/hub/FlutterSyncQueriesReadyEvent.swift index ef9abfbf93..e5fc8d702a 100644 --- a/packages/amplify_datastore/ios/Classes/types/hub/FlutterSyncQueriesReadyEvent.swift +++ b/packages/amplify_datastore/ios/Classes/types/hub/FlutterSyncQueriesReadyEvent.swift @@ -2,8 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import Foundation -import Amplify -import AmplifyPlugins struct FlutterSyncQueriesReadyEvent: FlutterHubEvent { var eventName: String diff --git a/packages/amplify_datastore/ios/Classes/types/hub/FlutterSyncQueriesStartedEvent.swift b/packages/amplify_datastore/ios/Classes/types/hub/FlutterSyncQueriesStartedEvent.swift index 5597fd1cad..8ed302391f 100644 --- a/packages/amplify_datastore/ios/Classes/types/hub/FlutterSyncQueriesStartedEvent.swift +++ b/packages/amplify_datastore/ios/Classes/types/hub/FlutterSyncQueriesStartedEvent.swift @@ -2,8 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import Foundation -import Amplify -import AmplifyPlugins struct FlutterSyncQueriesStartedEvent: FlutterHubEvent { var eventName: String diff --git a/packages/amplify_datastore/ios/Classes/types/hub/FlutterSyncReceivedEvent.swift b/packages/amplify_datastore/ios/Classes/types/hub/FlutterSyncReceivedEvent.swift index 5a2018ef21..2c27a0e036 100644 --- a/packages/amplify_datastore/ios/Classes/types/hub/FlutterSyncReceivedEvent.swift +++ b/packages/amplify_datastore/ios/Classes/types/hub/FlutterSyncReceivedEvent.swift @@ -2,8 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import Foundation -import Amplify -import AmplifyPlugins struct FlutterSyncReceivedEvent: FlutterHubEvent { var eventName: String diff --git a/packages/amplify_datastore/ios/Classes/types/model/FlutterAuthRule.swift b/packages/amplify_datastore/ios/Classes/types/model/FlutterAuthRule.swift index 37d15efe91..d5d1fc2ef3 100644 --- a/packages/amplify_datastore/ios/Classes/types/model/FlutterAuthRule.swift +++ b/packages/amplify_datastore/ios/Classes/types/model/FlutterAuthRule.swift @@ -2,7 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import Foundation -import Amplify public struct FlutterAuthRule { private var authStrategy: String diff --git a/packages/amplify_datastore/ios/Classes/types/model/FlutterModelAssociation.swift b/packages/amplify_datastore/ios/Classes/types/model/FlutterModelAssociation.swift index fb946fdbee..a5d4ed7bf0 100644 --- a/packages/amplify_datastore/ios/Classes/types/model/FlutterModelAssociation.swift +++ b/packages/amplify_datastore/ios/Classes/types/model/FlutterModelAssociation.swift @@ -2,7 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import Foundation -import Amplify public struct FlutterModelAssociation { private let associationType: String diff --git a/packages/amplify_datastore/ios/Classes/types/model/FlutterModelAttribute.swift b/packages/amplify_datastore/ios/Classes/types/model/FlutterModelAttribute.swift index bcd5a3dddc..7a676329a8 100644 --- a/packages/amplify_datastore/ios/Classes/types/model/FlutterModelAttribute.swift +++ b/packages/amplify_datastore/ios/Classes/types/model/FlutterModelAttribute.swift @@ -2,7 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import Foundation -import Amplify public enum FlutterModelAttribute { case index(fields: [String], name: String?) diff --git a/packages/amplify_datastore/ios/Classes/types/model/FlutterModelField.swift b/packages/amplify_datastore/ios/Classes/types/model/FlutterModelField.swift index ef3e4b4edb..b6b9c2b3a9 100644 --- a/packages/amplify_datastore/ios/Classes/types/model/FlutterModelField.swift +++ b/packages/amplify_datastore/ios/Classes/types/model/FlutterModelField.swift @@ -3,7 +3,6 @@ import Flutter import Foundation -import Amplify public struct FlutterModelField { public let name: String diff --git a/packages/amplify_datastore/ios/Classes/types/model/FlutterModelFieldType.swift b/packages/amplify_datastore/ios/Classes/types/model/FlutterModelFieldType.swift index d93f76e32b..2fbc654b66 100644 --- a/packages/amplify_datastore/ios/Classes/types/model/FlutterModelFieldType.swift +++ b/packages/amplify_datastore/ios/Classes/types/model/FlutterModelFieldType.swift @@ -2,7 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import Foundation -import Amplify public struct FlutterModelFieldType { public let fieldType: String diff --git a/packages/amplify_datastore/ios/Classes/types/model/FlutterModelSchema.swift b/packages/amplify_datastore/ios/Classes/types/model/FlutterModelSchema.swift index 8e676661c6..a4f63621f7 100644 --- a/packages/amplify_datastore/ios/Classes/types/model/FlutterModelSchema.swift +++ b/packages/amplify_datastore/ios/Classes/types/model/FlutterModelSchema.swift @@ -3,8 +3,6 @@ import Flutter import Foundation -import Amplify -import AWSPluginsCore struct FlutterModelSchema { let name: String @@ -100,10 +98,11 @@ struct FlutterModelSchema { } } +// TODO: Migrate to Async Swift v2 // This enables custom selection set behavior within Amplify-Swift v1. -// Which allows models to be decoded when created on Android and received to iOS -extension FlutterModelSchema: SubscriptionSelectionSetBehavior { - public var includePrimaryKeysOnly: Bool { - return true - } -} +// Which allows models to be decoded when created on Android and received to iOS +//extension FlutterModelSchema: SubscriptionSelectionSetBehavior { +// public var includePrimaryKeysOnly: Bool { +// return true +// } +//} diff --git a/packages/amplify_datastore/ios/Classes/types/model/FlutterSerializedModel.swift b/packages/amplify_datastore/ios/Classes/types/model/FlutterSerializedModel.swift index 3cca3cd7b1..914400b2d5 100644 --- a/packages/amplify_datastore/ios/Classes/types/model/FlutterSerializedModel.swift +++ b/packages/amplify_datastore/ios/Classes/types/model/FlutterSerializedModel.swift @@ -3,7 +3,6 @@ import Flutter import Foundation -import Amplify public struct FlutterSerializedModel: Model, ModelIdentifiable, JSONValueHolder { public typealias IdentifierFormat = ModelIdentifierFormat.Custom diff --git a/packages/amplify_datastore/ios/Classes/types/model/FlutterSubscriptionEvent.swift b/packages/amplify_datastore/ios/Classes/types/model/FlutterSubscriptionEvent.swift index 6dd1f3b05d..ee443e54fc 100644 --- a/packages/amplify_datastore/ios/Classes/types/model/FlutterSubscriptionEvent.swift +++ b/packages/amplify_datastore/ios/Classes/types/model/FlutterSubscriptionEvent.swift @@ -3,7 +3,6 @@ import Flutter import Foundation -import Amplify struct FlutterSubscriptionEvent { let item: FlutterSerializedModel diff --git a/packages/amplify_datastore/ios/Classes/types/query/QueryPaginationBuilder.swift b/packages/amplify_datastore/ios/Classes/types/query/QueryPaginationBuilder.swift index 1d86470697..05c410977b 100644 --- a/packages/amplify_datastore/ios/Classes/types/query/QueryPaginationBuilder.swift +++ b/packages/amplify_datastore/ios/Classes/types/query/QueryPaginationBuilder.swift @@ -2,7 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import Foundation -import Amplify public enum QueryPaginationBuilder { static func fromSerializedMap(_ serializedMap: [String: Any]?) -> QueryPaginationInput? { diff --git a/packages/amplify_datastore/ios/Classes/types/query/QueryPredicateBuilder.swift b/packages/amplify_datastore/ios/Classes/types/query/QueryPredicateBuilder.swift index 1d5eb496c8..a32b290c38 100644 --- a/packages/amplify_datastore/ios/Classes/types/query/QueryPredicateBuilder.swift +++ b/packages/amplify_datastore/ios/Classes/types/query/QueryPredicateBuilder.swift @@ -3,7 +3,6 @@ import Flutter import Foundation -import Amplify public enum QueryPredicateBuilder { static func fromSerializedMap(_ serializedMap: [String: Any]?) throws -> QueryPredicate { diff --git a/packages/amplify_datastore/ios/Classes/types/query/QuerySortBuilder.swift b/packages/amplify_datastore/ios/Classes/types/query/QuerySortBuilder.swift index a96fbfcde0..b9a9a63ab8 100644 --- a/packages/amplify_datastore/ios/Classes/types/query/QuerySortBuilder.swift +++ b/packages/amplify_datastore/ios/Classes/types/query/QuerySortBuilder.swift @@ -2,8 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import Foundation -import Amplify -import AmplifyPlugins public enum QuerySortBuilder { static func fromSerializedList(_ serializedList: [[String: Any]]?) throws -> [QuerySortDescriptor]? { diff --git a/packages/amplify_datastore/ios/Classes/types/temporal/FlutterTemporal.swift b/packages/amplify_datastore/ios/Classes/types/temporal/FlutterTemporal.swift index 83b604e495..7693d9e2bf 100644 --- a/packages/amplify_datastore/ios/Classes/types/temporal/FlutterTemporal.swift +++ b/packages/amplify_datastore/ios/Classes/types/temporal/FlutterTemporal.swift @@ -1,7 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import Amplify import Foundation diff --git a/packages/amplify_datastore/ios/Classes/utils/AtomicResult.swift b/packages/amplify_datastore/ios/Classes/utils/AtomicResult.swift index f7528367ab..d63bd078f1 100644 --- a/packages/amplify_datastore/ios/Classes/utils/AtomicResult.swift +++ b/packages/amplify_datastore/ios/Classes/utils/AtomicResult.swift @@ -1,6 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +import Flutter import Foundation // swiftlint:disable identifier_name type_name diff --git a/packages/amplify_datastore/ios/Classes/utils/modelHelpers.swift b/packages/amplify_datastore/ios/Classes/utils/modelHelpers.swift index 30f1d8ee37..0305f8682f 100644 --- a/packages/amplify_datastore/ios/Classes/utils/modelHelpers.swift +++ b/packages/amplify_datastore/ios/Classes/utils/modelHelpers.swift @@ -2,7 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import Foundation -import Amplify public func convertToAmplifyPersistable(value: Any?) -> Persistable? { if value == nil { diff --git a/packages/amplify_datastore/ios/amplify_datastore.podspec b/packages/amplify_datastore/ios/amplify_datastore.podspec index 7fcad7d79c..893b7797b3 100644 --- a/packages/amplify_datastore/ios/amplify_datastore.podspec +++ b/packages/amplify_datastore/ios/amplify_datastore.podspec @@ -13,15 +13,30 @@ The DataStore module for Amplify Flutter. s.license = 'Apache License, Version 2.0' s.author = { 'Amazon Web Services' => 'amazonwebservices' } s.source = { :git => 'https://github.com/aws-amplify/amplify-flutter.git' } - s.source_files = 'Classes/**/*' - s.dependency 'Flutter' - s.dependency 'Amplify', '1.30.7' - s.dependency 'AmplifyPlugins/AWSAPIPlugin', '1.30.7' - s.dependency 'AmplifyPlugins/AWSDataStorePlugin', '1.30.7' - s.dependency 'Starscream', '4.0.4' + s.source_files = 'Classes/**/*.{swift}', 'internal/Amplify/**/*.{swift}', 'internal/AWSPluginsCore/**/*.{swift}', 'internal/AWSDataStorePlugin/**/*.{swift}' s.platform = :ios, '13.0' - # Flutter.framework does not contain a i386 slice. Only x86_64 simulators are supported. s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'VALID_ARCHS[sdk=iphonesimulator*]' => 'x86_64' } s.swift_version = '5.9' + + s.dependency 'Flutter' + s.dependency 'Starscream', '4.0.4' + + # Internal Amplify Swift Plugins + s.subspec 'Amplify' do |amplify| + amplify.source_files = 'internal/Amplify/**/*' + end + + s.subspec 'AWSPluginsCore' do |awsPluginsCore| + awsPluginsCore.source_files = 'internal/AWSPluginsCore/**/*' + awsPluginsCore.dependency 'amplify_datastore/Amplify' + end + + s.subspec 'AWSDataStorePlugin' do |awsDataStorePlugin| + awsDataStorePlugin.source_files = 'internal/AWSDataStorePlugin/**/*' + awsDataStorePlugin.dependency 'amplify_datastore/AWSPluginsCore' + awsDataStorePlugin.dependency 'amplify_datastore/Amplify' + awsDataStorePlugin.dependency 'SQLite.swift', '0.13.2' + end + end diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/AWSDataStorePlugin+DataStoreBaseBehavior.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/AWSDataStorePlugin+DataStoreBaseBehavior.swift new file mode 100644 index 0000000000..56b34cc859 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/AWSDataStorePlugin+DataStoreBaseBehavior.swift @@ -0,0 +1,599 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + + +extension AWSDataStorePlugin: DataStoreBaseBehavior { + + // MARK: - Save + public func save(_ model: M, + where condition: QueryPredicate? = nil, + completion: @escaping DataStoreCallback) { + save(model, modelSchema: model.schema, where: condition, completion: completion) + } + + public func save(_ model: M, + where condition: QueryPredicate? = nil) async throws -> M { + try await save(model, modelSchema: model.schema, where: condition) + } + + public func save( + _ model: M, + modelSchema: ModelSchema, + where condition: QueryPredicate? = nil, + completion: @escaping DataStoreCallback + ) { + log.verbose("Saving: \(model) with condition: \(String(describing: condition))") + let prepareSaveResult = initStorageEngineAndTryStartSync().flatMap { storageEngineBehavior in + mutationTypeOfModel(model, modelSchema: modelSchema, storageEngine: storageEngineBehavior) + .map { (storageEngineBehavior, $0) } + } + + switch prepareSaveResult { + case .success(let (storageEngineBehavior, mutationType)): + storageEngineBehavior.save( + model, + modelSchema: modelSchema, + condition: condition, + eagerLoad: configuration.isEagerLoad + ) { result in + switch result { + case .success(let model): + // TODO: Differentiate between save & update + // TODO: Handle errors from mutation event creation + self.publishMutationEvent(from: model, modelSchema: modelSchema, mutationType: mutationType) + case .failure: + break + } + completion(result) + } + case .failure(let error): + completion(.failure(error)) + } + } + + public func save(_ model: M, + modelSchema: ModelSchema, + where condition: QueryPredicate? = nil) async throws -> M { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + save(model, modelSchema: model.schema, where: condition) { result in + continuation.resume(with: result) + } + } + } + + // MARK: - Query + + @available(*, deprecated, renamed: "query(byIdentifier:completion:)") + public func query(_ modelType: M.Type, + byId id: String, + completion: DataStoreCallback) { + let predicate: QueryPredicate = field("id") == id + query(modelType, where: predicate, paginate: .firstResult) { + switch $0 { + case .success(let models): + do { + let first = try models.unique() + completion(.success(first)) + } catch { + completion(.failure(causedBy: error)) + } + case .failure(let error): + completion(.failure(causedBy: error)) + } + } + } + @available(*, deprecated, renamed: "query(byIdentifier:)") + public func query(_ modelType: M.Type, + byId id: String) async throws -> M? { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + query(modelType, byId: id) { result in + continuation.resume(with: result) + } + } + } + + public func query(_ modelType: M.Type, + byIdentifier identifier: String, + completion: DataStoreCallback) where M: ModelIdentifiable, + M.IdentifierFormat == ModelIdentifierFormat.Default { + queryByIdentifier(modelType, + modelSchema: modelType.schema, + identifier: DefaultModelIdentifier.makeDefault(id: identifier), + completion: completion) + } + + public func query(_ modelType: M.Type, + byIdentifier identifier: String) async throws -> M? + where M: ModelIdentifiable, M.IdentifierFormat == ModelIdentifierFormat.Default { + try await queryByIdentifier(modelType, + modelSchema: modelType.schema, + identifier: DefaultModelIdentifier.makeDefault(id: identifier)) + } + + public func query(_ modelType: M.Type, + byIdentifier identifier: ModelIdentifier, + completion: DataStoreCallback) where M: ModelIdentifiable { + queryByIdentifier(modelType, + modelSchema: modelType.schema, + identifier: identifier, + completion: completion) + } + + public func query(_ modelType: M.Type, + byIdentifier identifier: ModelIdentifier) async throws -> M? + where M: ModelIdentifiable { + try await queryByIdentifier(modelType, + modelSchema: modelType.schema, + identifier: identifier) + } + + private func queryByIdentifier(_ modelType: M.Type, + modelSchema: ModelSchema, + identifier: ModelIdentifierProtocol, + completion: DataStoreCallback) { + query(modelType, + modelSchema: modelSchema, + where: identifier.predicate, + paginate: .firstResult) { + switch $0 { + case .success(let models): + do { + let first = try models.unique() + completion(.success(first)) + } catch { + completion(.failure(causedBy: error)) + } + case .failure(let error): + completion(.failure(causedBy: error)) + } + } + } + + private func queryByIdentifier(_ modelType: M.Type, + modelSchema: ModelSchema, + identifier: ModelIdentifierProtocol) async throws -> M? { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + queryByIdentifier(modelType, modelSchema: modelSchema, identifier: identifier) { result in + continuation.resume(with: result) + } + } + } + + public func query(_ modelType: M.Type, + where predicate: QueryPredicate? = nil, + sort sortInput: QuerySortInput? = nil, + paginate paginationInput: QueryPaginationInput? = nil, + completion: DataStoreCallback<[M]>) { + query(modelType, + modelSchema: modelType.schema, + where: predicate, + sort: sortInput?.asSortDescriptors(), + paginate: paginationInput, + completion: completion) + } + + public func query(_ modelType: M.Type, + where predicate: QueryPredicate? = nil, + sort sortInput: QuerySortInput? = nil, + paginate paginationInput: QueryPaginationInput? = nil) async throws -> [M] { + try await query(modelType, + modelSchema: modelType.schema, + where: predicate, + sort: sortInput?.asSortDescriptors(), + paginate: paginationInput) + } + + public func query( + _ modelType: M.Type, + modelSchema: ModelSchema, + where predicate: QueryPredicate? = nil, + sort sortInput: [QuerySortDescriptor]? = nil, + paginate paginationInput: QueryPaginationInput? = nil, + completion: DataStoreCallback<[M]> + ) { + switch initStorageEngineAndTryStartSync() { + case .success(let storageEngineBehavior): + storageEngineBehavior.query( + modelType, + modelSchema: modelSchema, + predicate: predicate, + sort: sortInput, + paginationInput: paginationInput, + eagerLoad: configuration.isEagerLoad, + completion: completion + ) + case .failure(let error): + completion(.failure(error)) + } + } + + public func query(_ modelType: M.Type, + modelSchema: ModelSchema, + where predicate: QueryPredicate? = nil, + sort sortInput: [QuerySortDescriptor]? = nil, + paginate paginationInput: QueryPaginationInput? = nil) async throws -> [M] { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<[M], Error>) in + query(modelType, modelSchema: modelSchema, where: predicate, sort: sortInput, paginate: paginationInput) { result in + continuation.resume(with: result) + } + } + } + + // MARK: - Delete + @available(*, deprecated, renamed: "delete(withIdentifier:)") + public func delete(_ modelType: M.Type, + withId id: String, + where predicate: QueryPredicate?) async throws { + try await delete(modelType, modelSchema: modelType.schema, withId: id, where: predicate) + + } + + @available(*, deprecated, renamed: "delete(withIdentifier:)") + public func delete(_ modelType: M.Type, + modelSchema: ModelSchema, + withId id: String, + where predicate: QueryPredicate? = nil) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + switch initStorageEngineAndTryStartSync() { + case .success(let storageEngineBehavior): + storageEngineBehavior.delete(modelType, modelSchema: modelSchema, withId: id, condition: predicate) { result in + self.onDeleteCompletion(result: result, modelSchema: modelSchema) { result in + continuation.resume(with: result) + } + } + case .failure(let error): + continuation.resume(with: .failure(error)) + } + + } + } + public func delete(_ modelType: M.Type, + withIdentifier identifier: String, + where predicate: QueryPredicate? = nil, + completion: @escaping DataStoreCallback) where M: ModelIdentifiable, + M.IdentifierFormat == ModelIdentifierFormat.Default { + deleteByIdentifier(modelType, + modelSchema: modelType.schema, + identifier: DefaultModelIdentifier.makeDefault(id: identifier), + where: predicate, + completion: completion) + } + + public func delete(_ modelType: M.Type, + withIdentifier identifier: String, + where predicate: QueryPredicate? = nil) async throws + where M: ModelIdentifiable, M.IdentifierFormat == ModelIdentifierFormat.Default { + try await deleteByIdentifier(modelType, + modelSchema: modelType.schema, + identifier: DefaultModelIdentifier.makeDefault(id: identifier), + where: predicate) + } + + public func delete(_ modelType: M.Type, + withIdentifier identifier: ModelIdentifier, + where predicate: QueryPredicate? = nil, + completion: @escaping DataStoreCallback) where M: ModelIdentifiable { + deleteByIdentifier(modelType, + modelSchema: modelType.schema, + identifier: identifier, + where: predicate, + completion: completion) + } + + public func delete(_ modelType: M.Type, + withIdentifier identifier: ModelIdentifier, + where predicate: QueryPredicate? = nil) async throws where M: ModelIdentifiable { + try await deleteByIdentifier(modelType, + modelSchema: modelType.schema, + identifier: identifier, + where: predicate) + } + + private func deleteByIdentifier(_ modelType: M.Type, + modelSchema: ModelSchema, + identifier: ModelIdentifierProtocol, + where predicate: QueryPredicate?, + completion: @escaping DataStoreCallback) where M: ModelIdentifiable { + switch initStorageEngineAndTryStartSync() { + case .success(let storageEngineBehavior): + storageEngineBehavior.delete( + modelType, + modelSchema: modelSchema, + withIdentifier: identifier, + condition: predicate + ) { result in + self.onDeleteCompletion( + result: result, + modelSchema: modelSchema, + completion: completion + ) + } + case .failure(let error): + completion(.failure(error)) + } + + } + + private func deleteByIdentifier(_ modelType: M.Type, + modelSchema: ModelSchema, + identifier: ModelIdentifierProtocol, + where predicate: QueryPredicate?) async throws where M: ModelIdentifiable { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + deleteByIdentifier(modelType, modelSchema: modelSchema, identifier: identifier, where: predicate) { result in + continuation.resume(with: result) + } + } + } + + public func delete(_ model: M, + where predicate: QueryPredicate? = nil, + completion: @escaping DataStoreCallback) { + delete(model, modelSchema: model.schema, where: predicate, completion: completion) + } + + public func delete(_ model: M, + where predicate: QueryPredicate? = nil) async throws { + try await delete(model, modelSchema: model.schema, where: predicate) + } + + public func delete(_ model: M, + modelSchema: ModelSchema, + where predicate: QueryPredicate? = nil, + completion: @escaping DataStoreCallback) { + switch initStorageEngineAndTryStartSync() { + case .success(let storageEngineBehavior): + storageEngineBehavior.delete( + type(of: model), + modelSchema: modelSchema, + withIdentifier: model.identifier(schema: modelSchema), + condition: predicate + ) { result in + self.onDeleteCompletion(result: result, modelSchema: modelSchema, completion: completion) + } + case .failure(let error): + completion(.failure(error)) + } + + } + + public func delete(_ model: M, + modelSchema: ModelSchema, + where predicate: QueryPredicate? = nil) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + delete(model, modelSchema: modelSchema, where: predicate) { result in + continuation.resume(with: result) + } + } + } + + public func delete(_ modelType: M.Type, + where predicate: QueryPredicate, + completion: @escaping DataStoreCallback) { + delete(modelType, modelSchema: modelType.schema, where: predicate, completion: completion) + } + + public func delete(_ modelType: M.Type, + where predicate: QueryPredicate) async throws { + try await delete(modelType, modelSchema: modelType.schema, where: predicate) + } + + public func delete(_ modelType: M.Type, + modelSchema: ModelSchema, + where predicate: QueryPredicate, + completion: @escaping DataStoreCallback) { + switch initStorageEngineAndTryStartSync() { + case .success(let storageEngineBehavior): + storageEngineBehavior.delete( + modelType, + modelSchema: modelSchema, + filter: predicate + ) { result in + switch result { + case .success(let models): + for model in models { + self.publishMutationEvent(from: model, modelSchema: modelSchema, mutationType: .delete) + } + completion(.emptyResult) + case .failure(let error): + completion(.failure(error)) + } + } + case .failure(let error): + completion(.failure(error)) + } + } + + public func delete(_ modelType: M.Type, + modelSchema: ModelSchema, + where predicate: QueryPredicate) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + delete(modelType, modelSchema: modelSchema, where: predicate) { result in + continuation.resume(with: result) + } + } + } + + public func start(completion: @escaping DataStoreCallback) { + let result = initStorageEngineAndStartSync().map { _ in () } + self.queue.async { + completion(result) + } + } + + public func start() async throws { + _ = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation, Error>) in + start { result in + continuation.resume(returning: result) + } + } + } + + public func stop(completion: @escaping DataStoreCallback) { + storageEngineInitQueue.sync { + self.dataStoreStateSubject.send(.stop) + dispatchedModelSyncedEvents.forEach { _, dispatchedModelSynced in + dispatchedModelSynced.set(false) + } + if storageEngine == nil { + queue.async { + completion(.successfulVoid) + } + return + } + + storageEngine.stopSync { result in + self.queue.async { + completion(result) + } + } + } + } + + public func stop() async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + stop { result in + continuation.resume(with: result) + } + } + } + + public func clear(completion: @escaping DataStoreCallback) { + if case let .failure(error) = initStorageEngine() { + completion(.failure(causedBy: error)) + return + } + + storageEngineInitQueue.sync { + self.dataStoreStateSubject.send(.clear) + dispatchedModelSyncedEvents.forEach { _, dispatchedModelSynced in + dispatchedModelSynced.set(false) + } + if storageEngine == nil { + queue.async { + completion(.successfulVoid) + } + return + } + storageEngine.clear { result in + self.storageEngine = nil + self.queue.async { + completion(result) + } + } + } + } + + public func clear() async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + clear { result in + continuation.resume(with: result) + } + } + } + + // MARK: Private + + private func onDeleteCompletion(result: DataStoreResult, + modelSchema: ModelSchema, + completion: @escaping DataStoreCallback) { + switch result { + case .success(let modelOptional): + if let model = modelOptional { + publishMutationEvent(from: model, modelSchema: modelSchema, mutationType: .delete) + } + completion(.emptyResult) + case .failure(let error): + completion(.failure(error)) + } + } + + private func mutationTypeOfModel( + _ model: M, + modelSchema: ModelSchema, + storageEngine: StorageEngineBehavior + ) -> Result { + let modelExists: Bool + do { + guard let engine = storageEngine as? StorageEngine else { + throw DataStoreError.configuration("Unable to get storage adapter", "") + } + modelExists = try engine.storageAdapter.exists(modelSchema, + withIdentifier: model.identifier(schema: modelSchema), + predicate: nil) + } catch { + if let dataStoreError = error as? DataStoreError { + return .failure(dataStoreError) + } + + let dataStoreError = DataStoreError.invalidOperation(causedBy: error) + return .failure(dataStoreError) + } + + return .success(modelExists ? MutationEvent.MutationType.update : .create) + } + + private func publishMutationEvent(from model: M, + modelSchema: ModelSchema, + mutationType: MutationEvent.MutationType) { + guard let storageEngine = storageEngine else { + log.info( + """ + StorageEngine is nil; + Skip publishing the mutaitonEvent for \(mutationType) - \(modelSchema.name) + """ + ) + return + } + + let metadata = MutationSyncMetadata.keys + let metadataId = MutationSyncMetadata.identifier(modelName: modelSchema.name, + modelId: model.identifier(schema: modelSchema).stringValue) + storageEngine.query(MutationSyncMetadata.self, + predicate: metadata.id == metadataId, + sort: nil, + paginationInput: .firstResult, + eagerLoad: true) { + do { + let result = try $0.get() + let syncMetadata = try result.unique() + let mutationEvent = try MutationEvent(model: model, + modelSchema: modelSchema, + mutationType: mutationType, + version: syncMetadata?.version) + self.dataStorePublisher?.send(input: mutationEvent) + } catch { + self.log.error(error: error) + } + } + } + +} + +/// Overrides needed by platforms using a serialized version of models (i.e. Flutter) +extension AWSDataStorePlugin { + public func query(_ modelType: M.Type, + modelSchema: ModelSchema, + byIdentifier identifier: ModelIdentifier, + completion: DataStoreCallback) where M: ModelIdentifiable { + queryByIdentifier(modelType, + modelSchema: modelSchema, + identifier: identifier, + completion: completion) + } + + public func delete(_ modelType: M.Type, + modelSchema: ModelSchema, + withIdentifier identifier: ModelIdentifier, + where predicate: QueryPredicate?, + completion: @escaping DataStoreCallback) where M: ModelIdentifiable { + deleteByIdentifier(modelType, + modelSchema: modelSchema, + identifier: identifier, + where: predicate, + completion: completion) + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/AWSDataStorePlugin+DataStoreSubscribeBehavior.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/AWSDataStorePlugin+DataStoreSubscribeBehavior.swift new file mode 100644 index 0000000000..e0d0aa2864 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/AWSDataStorePlugin+DataStoreSubscribeBehavior.swift @@ -0,0 +1,58 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Combine + +extension AWSDataStorePlugin: DataStoreSubscribeBehavior { + + public var publisher: AnyPublisher { + _ = initStorageEngineAndStartSync() + // Force-unwrapping: The optional 'dataStorePublisher' is expected + // to exist for deployment targets >=iOS13.0 + return dataStorePublisher!.publisher + } + + public func publisher(for modelName: ModelName) -> AnyPublisher { + return publisher.filter { $0.modelName == modelName }.eraseToAnyPublisher() + } + + public func observe(_ modelType: M.Type) -> AmplifyAsyncThrowingSequence { + let runner = ObserveTaskRunner(publisher: publisher(for: modelType.modelName)) + return runner.sequence + } + + public func observeQuery(for modelType: M.Type, + where predicate: QueryPredicate?, + sort sortInput: QuerySortInput?) -> AmplifyAsyncThrowingSequence> { + switch initStorageEngineAndTryStartSync() { + case .success(let storageEngineBehavior): + let modelSchema = modelType.schema + guard let dataStorePublisher = dataStorePublisher else { + return Fatal.preconditionFailure("`dataStorePublisher` is expected to exist for deployment targets >=iOS13.0") + } + guard let dispatchedModelSyncedEvent = dispatchedModelSyncedEvents[modelSchema.name] else { + return Fatal.preconditionFailure("`dispatchedModelSyncedEvent` is expected to exist for \(modelSchema.name)") + } + let request = ObserveQueryRequest(options: []) + let taskRunner = ObserveQueryTaskRunner(request: request, + modelType: modelType, + modelSchema: modelType.schema, + predicate: predicate, + sortInput: sortInput?.asSortDescriptors(), + storageEngine: storageEngineBehavior, + dataStorePublisher: dataStorePublisher, + dataStoreConfiguration: configuration.pluginConfiguration, + dispatchedModelSyncedEvent: dispatchedModelSyncedEvent, + dataStoreStatePublisher: dataStoreStateSubject.eraseToAnyPublisher()) + return taskRunner.sequence + case .failure(let error): + return Fatal.preconditionFailure("Unable to get storage adapter \(error.localizedDescription)") + } + + } + +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/AWSDataStorePlugin+DefaultLogger.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/AWSDataStorePlugin+DefaultLogger.swift new file mode 100644 index 0000000000..91530e4c55 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/AWSDataStorePlugin+DefaultLogger.swift @@ -0,0 +1,16 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + + +extension AWSDataStorePlugin: DefaultLogger { + public static var log: Logger { + Amplify.Logging.logger(forCategory: CategoryType.dataStore.displayName, forNamespace: String(describing: self)) + } + public var log: Logger { + Self.log + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/AWSDataStorePlugin.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/AWSDataStorePlugin.swift new file mode 100644 index 0000000000..b493ed3933 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/AWSDataStorePlugin.swift @@ -0,0 +1,291 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Combine +import Foundation + +enum DataStoreState { + case start(storageEngine: StorageEngineBehavior) + case stop + case clear +} + +final public class AWSDataStorePlugin: DataStoreCategoryPlugin { + + public var key: PluginKey = "awsDataStorePlugin" + + /// The Publisher that sends mutation events to subscribers + var dataStorePublisher: ModelSubcriptionBehavior? + + var dataStoreStateSubject = PassthroughSubject() + + var dispatchedModelSyncedEvents: [ModelName: AtomicValue] + + let modelRegistration: AmplifyModelRegistration + + /// The DataStore configuration + var configuration: InternalDatastoreConfiguration + + var storageEngine: StorageEngineBehavior! + + /// A queue to allow synchronize access to the storage engine for start/stop/clear operations. + var storageEngineInitQueue = DispatchQueue(label: "AWSDataStorePlugin.storageEngineInitQueue") + + /// A queue used for async callback out from`storageEngineInitQueue` + var queue = DispatchQueue(label: "AWSDataStorePlugin.queue", target: DispatchQueue.global()) + + var storageEngineBehaviorFactory: StorageEngineBehaviorFactory + + var iStorageEngineSink: Any? + var storageEngineSink: AnyCancellable? { + get { + if let iStorageEngineSink = iStorageEngineSink as? AnyCancellable { + return iStorageEngineSink + } + return nil + } + set { + iStorageEngineSink = newValue + } + } + + #if os(watchOS) + /// Initializer + /// - Parameters: + /// - modelRegistration: Register DataStore models. + /// - dataStoreConfiguration: Configuration object for DataStore + public init(modelRegistration: AmplifyModelRegistration, + configuration dataStoreConfiguration: DataStoreConfiguration) { + self.modelRegistration = modelRegistration + self.configuration = InternalDatastoreConfiguration( + isSyncEnabled: false, + validAPIPluginKey: "awsAPIPlugin", + validAuthPluginKey: "awsCognitoAuthPlugin", + pluginConfiguration: dataStoreConfiguration) + + self.storageEngineBehaviorFactory = + StorageEngine.init( + isSyncEnabled: + dataStoreConfiguration: + validAPIPluginKey: + validAuthPluginKey: + modelRegistryVersion: + userDefault: + ) + self.dataStorePublisher = DataStorePublisher() + self.dispatchedModelSyncedEvents = [:] + } + #else + /// Initializer + /// - Parameters: + /// - modelRegistration: Register DataStore models. + /// - dataStoreConfiguration: Configuration object for DataStore + public init(modelRegistration: AmplifyModelRegistration, + configuration dataStoreConfiguration: DataStoreConfiguration = .default) { + self.modelRegistration = modelRegistration + self.configuration = InternalDatastoreConfiguration( + isSyncEnabled: false, + validAPIPluginKey: "awsAPIPlugin", + validAuthPluginKey: "awsCognitoAuthPlugin", + pluginConfiguration: dataStoreConfiguration) + + self.storageEngineBehaviorFactory = + StorageEngine.init( + isSyncEnabled: + dataStoreConfiguration: + validAPIPluginKey: + validAuthPluginKey: + modelRegistryVersion: + userDefault: + ) + self.dataStorePublisher = DataStorePublisher() + self.dispatchedModelSyncedEvents = [:] + } + #endif + + /// Internal initializer for testing + init(modelRegistration: AmplifyModelRegistration, + configuration dataStoreConfiguration: DataStoreConfiguration = .testDefault(), + storageEngineBehaviorFactory: StorageEngineBehaviorFactory? = nil, + dataStorePublisher: ModelSubcriptionBehavior, + operationQueue: OperationQueue = OperationQueue(), + validAPIPluginKey: String, + validAuthPluginKey: String) { + self.modelRegistration = modelRegistration + self.configuration = InternalDatastoreConfiguration( + isSyncEnabled: false, + validAPIPluginKey: validAPIPluginKey, + validAuthPluginKey: validAuthPluginKey, + pluginConfiguration: dataStoreConfiguration) + + self.storageEngineBehaviorFactory = storageEngineBehaviorFactory ?? + StorageEngine.init( + isSyncEnabled: + dataStoreConfiguration: + validAPIPluginKey: + validAuthPluginKey: + modelRegistryVersion: + userDefault: + ) + self.dataStorePublisher = dataStorePublisher + self.dispatchedModelSyncedEvents = [:] + } + + /// By the time this method gets called, DataStore will already have invoked + /// `AmplifyModelRegistration.registerModels`, so we can inspect those models to derive isSyncEnabled, and pass + /// them to `StorageEngine.setUp(modelSchemas:)` + public func configure(using amplifyConfiguration: Any?) throws { + modelRegistration.registerModels(registry: ModelRegistry.self) + + for modelSchema in ModelRegistry.modelSchemas { + dispatchedModelSyncedEvents[modelSchema.name] = AtomicValue(initialValue: false) + configuration.updateIsEagerLoad(modelSchema: modelSchema) + } + resolveSyncEnabled() + ModelListDecoderRegistry.registerDecoder(DataStoreListDecoder.self) + ModelProviderRegistry.registerDecoder(DataStoreModelDecoder.self) + } + + /// Initializes the underlying storage engine + /// - Returns: success if the engine is successfully initialized or + /// a failure with a DataStoreError + func initStorageEngine() -> Result { + if storageEngine != nil { + return .success(storageEngine) + } + + do { + if self.dataStorePublisher == nil { + self.dataStorePublisher = DataStorePublisher() + } + try resolveStorageEngine(dataStoreConfiguration: configuration.pluginConfiguration) + try storageEngine.setUp(modelSchemas: ModelRegistry.modelSchemas) + try storageEngine.applyModelMigrations(modelSchemas: ModelRegistry.modelSchemas) + + return .success(storageEngine) + } catch { + log.error(error: error) + return .failure(.invalidOperation(causedBy: error)) + } + } + + /// Initializes the underlying storage engine and starts the syncing process + /// - Returns: The StorageEngineBehavior instance just get initialized + func initStorageEngineAndStartSync() -> Result { + storageEngineInitQueue.sync { + initStorageEngine().flatMap { storageEngine in + storageEngine.startSync().flatMap { result in + switch result { + case .alreadyInitialized: + return .success(storageEngine) + case .successfullyInitialized: + self.dataStoreStateSubject.send(.start(storageEngine: storageEngine)) + return .success(storageEngine) + case .failure(let error): + return .failure(error) + } + } + } + } + } + + /// Initializes the underlying storage engine and try starts the syncing process + /// If the start sync process failed due to missing other plugin configurations, we recover from failure for local only operations. + /// - Returns: The StorageEngineBehavior instance just get initialized + func initStorageEngineAndTryStartSync() -> Result { + initStorageEngineAndStartSync().flatMapError { error in + switch error { + case .configuration: + return .success(storageEngine) + default: + return .failure(error) + } + } + } + + func resolveStorageEngine(dataStoreConfiguration: DataStoreConfiguration) throws { + guard storageEngine == nil else { + return + } + + storageEngine = try storageEngineBehaviorFactory( + configuration.isSyncEnabled, + dataStoreConfiguration, + configuration.validAPIPluginKey, + configuration.validAuthPluginKey, + modelRegistration.version, + UserDefaults.standard + ) + + setupStorageSink() + } + + // MARK: Private + + private func resolveSyncEnabled() { + configuration.updateIsSyncEnabled(ModelRegistry.hasSyncableModels) + } + + private func setupStorageSink() { + storageEngineSink = storageEngine + .publisher + .sink( + receiveCompletion: { [weak self] in self?.onReceiveCompletion(completed: $0) }, + receiveValue: { [weak self] in self?.onReceiveValue(receiveValue: $0) } + ) + } + + private func onReceiveCompletion(completed: Subscribers.Completion) { + switch completed { + case .failure(let dataStoreError): + log.error("StorageEngine completed with error: \(dataStoreError)") + case .finished: + log.debug("StorageEngine completed without error") + } + } + + func onReceiveValue(receiveValue: StorageEngineEvent) { + guard let dataStorePublisher = self.dataStorePublisher else { + log.error("Data store publisher not initalized") + return + } + + switch receiveValue { + case .started: + break + case .mutationEvent(let mutationEvent): + dataStorePublisher.send(input: mutationEvent) + case .modelSyncedEvent(let modelSyncedEvent): + log.verbose("Emitting DataStore event: modelSyncedEvent \(modelSyncedEvent)") + dispatchedModelSyncedEvents[modelSyncedEvent.modelName]?.set(true) + let modelSyncedEventPayload = HubPayload(eventName: HubPayload.EventName.DataStore.modelSynced, + data: modelSyncedEvent) + Amplify.Hub.dispatch(to: .dataStore, payload: modelSyncedEventPayload) + case .syncQueriesReadyEvent: + log.verbose("[Lifecycle event 4]: syncQueriesReady") + let syncQueriesReadyEventPayload = HubPayload(eventName: HubPayload.EventName.DataStore.syncQueriesReady) + Amplify.Hub.dispatch(to: .dataStore, payload: syncQueriesReadyEventPayload) + case .readyEvent: + log.verbose("[Lifecycle event 6]: ready") + let readyEventPayload = HubPayload(eventName: HubPayload.EventName.DataStore.ready) + Amplify.Hub.dispatch(to: .dataStore, payload: readyEventPayload) + } + } + + public func reset() async { + dispatchedModelSyncedEvents = [:] + dataStorePublisher?.sendFinished() + if let resettable = storageEngine as? Resettable { + log.verbose("Resetting storageEngine") + await resettable.reset() + self.log.verbose("Resetting storageEngine: finished") + } + } + +} + +extension AWSDataStorePlugin: AmplifyVersionable { } diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Configuration/DataStoreConfiguration+Helper.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Configuration/DataStoreConfiguration+Helper.swift new file mode 100644 index 0000000000..95c4d94768 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Configuration/DataStoreConfiguration+Helper.swift @@ -0,0 +1,111 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension DataStoreConfiguration { + + public static let defaultSyncInterval: TimeInterval = .hours(24) + public static let defaultSyncMaxRecords: UInt = 10_000 + public static let defaultSyncPageSize: UInt = 1_000 + + #if os(watchOS) + /// Creates a custom configuration. The only required property is `conflictHandler`. + /// + /// - Parameters: + /// - errorHandler: a callback function called on unhandled errors + /// - conflictHandler: a callback called when a conflict could not be resolved by the service + /// - syncInterval: how often the sync engine will run (in seconds) + /// - syncMaxRecords: the number of records to sync per execution + /// - syncPageSize: the page size of each sync execution + /// - authModeStrategy: authorization strategy (.default | multiauth) + /// - disableSubscriptions: called before establishing subscriptions. Return true to disable subscriptions. + /// - Returns: an instance of `DataStoreConfiguration` with the passed parameters. + public static func custom( + errorHandler: @escaping DataStoreErrorHandler = { error in + Amplify.Logging.error(error: error) + }, + conflictHandler: @escaping DataStoreConflictHandler = { _, resolve in + resolve(.applyRemote) + }, + syncInterval: TimeInterval = DataStoreConfiguration.defaultSyncInterval, + syncMaxRecords: UInt = DataStoreConfiguration.defaultSyncMaxRecords, + syncPageSize: UInt = DataStoreConfiguration.defaultSyncPageSize, + syncExpressions: [DataStoreSyncExpression] = [], + authModeStrategy: AuthModeStrategyType = .default, + disableSubscriptions: @escaping () -> Bool + ) -> DataStoreConfiguration { + return DataStoreConfiguration(errorHandler: errorHandler, + conflictHandler: conflictHandler, + syncInterval: syncInterval, + syncMaxRecords: syncMaxRecords, + syncPageSize: syncPageSize, + syncExpressions: syncExpressions, + authModeStrategy: authModeStrategy, + disableSubscriptions: disableSubscriptions) + } + #else + /// Creates a custom configuration. The only required property is `conflictHandler`. + /// + /// - Parameters: + /// - errorHandler: a callback function called on unhandled errors + /// - conflictHandler: a callback called when a conflict could not be resolved by the service + /// - syncInterval: how often the sync engine will run (in seconds) + /// - syncMaxRecords: the number of records to sync per execution + /// - syncPageSize: the page size of each sync execution + /// - authModeStrategy: authorization strategy (.default | multiauth) + /// - Returns: an instance of `DataStoreConfiguration` with the passed parameters. + public static func custom( + errorHandler: @escaping DataStoreErrorHandler = { error in + Amplify.Logging.error(error: error) + }, + conflictHandler: @escaping DataStoreConflictHandler = { _, resolve in + resolve(.applyRemote) + }, + syncInterval: TimeInterval = DataStoreConfiguration.defaultSyncInterval, + syncMaxRecords: UInt = DataStoreConfiguration.defaultSyncMaxRecords, + syncPageSize: UInt = DataStoreConfiguration.defaultSyncPageSize, + syncExpressions: [DataStoreSyncExpression] = [], + authModeStrategy: AuthModeStrategyType = .default + ) -> DataStoreConfiguration { + return DataStoreConfiguration(errorHandler: errorHandler, + conflictHandler: conflictHandler, + syncInterval: syncInterval, + syncMaxRecords: syncMaxRecords, + syncPageSize: syncPageSize, + syncExpressions: syncExpressions, + authModeStrategy: authModeStrategy) + } + #endif + + #if os(watchOS) + /// Default configuration with subscriptions disabled for watchOS. DataStore uses subscriptions via websockets, + /// which work on the watchOS simulator but not on the device. Running DataStore on watchOS with subscriptions + /// enabled is only possible during special circumstances such as actively streaming audio. + /// See https://github.com/aws-amplify/amplify-swift/pull/3368 for more details. + public static var subscriptionsDisabled: DataStoreConfiguration { + .custom(disableSubscriptions: { true }) + } + #else + /// The default configuration. + public static var `default`: DataStoreConfiguration { + .custom() + } + #endif + + #if os(watchOS) + /// Internal method for testing + static func testDefault(disableSubscriptions: @escaping () -> Bool = { false }) -> DataStoreConfiguration { + .custom(disableSubscriptions: disableSubscriptions) + } + #else + /// Internal method for testing + static func testDefault() -> DataStoreConfiguration { + .custom() + } + #endif +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Configuration/DataStoreConfiguration.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Configuration/DataStoreConfiguration.swift new file mode 100644 index 0000000000..e8e896287a --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Configuration/DataStoreConfiguration.swift @@ -0,0 +1,109 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Error Handler function typealias +public typealias DataStoreErrorHandler = (AmplifyError) -> Void + +/// Holds a reference to both the local `Model` and the remote one during a conflict +/// resolution. Implementations of the `DataStoreConflictHandler` use this to decide +/// what the outcome of a conflict should be. +public struct DataStoreConflictData { + public let local: Model + public let remote: Model +} + +/// The `DataStoreConflictHandler` is an asynchronous callback which allows consumers to decide how to resolve conflicts +/// between the frontend and backend. This can be configured on the `conflictHandler` of the `DataStoreConfiguration` +/// by implementing the body of the closure, processing `DataStoreConflictData` and resolving the conflict by calling +/// `DataStoreConflictHandlerResolver` +public typealias DataStoreConflictHandler = (DataStoreConflictData, @escaping DataStoreConflictHandlerResolver) -> Void + +/// Callback for the `DataStoreConflictHandler`. +public typealias DataStoreConflictHandlerResolver = (DataStoreConflictHandlerResult) -> Void + +/// The conflict resolution result enum. +public enum DataStoreConflictHandlerResult { + + /// Discard the local changes in favor of the remote ones. Semantically the same as `DISCARD` on Amplify-JS + case applyRemote + + /// Keep the local changes. (semantic shortcut to `retry(local)`). + case retryLocal + + /// Return a new `Model` instance that should used instead of the local and remote changes. + case retry(Model) +} + +/// The `DataStore` plugin configuration object. +public struct DataStoreConfiguration { + + /// A callback function called on unhandled errors + public let errorHandler: DataStoreErrorHandler + + /// A callback called when a conflict could not be resolved by the service + public let conflictHandler: DataStoreConflictHandler + + /// The maximum interval (in seconds) the system will continue to perform delta queries. + /// After this interval expires, the system performs a base query to retrieve all data. + /// This defaults to 24 hours, and developers should rarely need to customize this. + /// More information can be found here: + /// https://docs.amplify.aws/lib/datastore/how-it-works/q/platform/ios#sync-data-to-cloud + public let syncInterval: TimeInterval + + /// The number of records to sync per execution + public let syncMaxRecords: UInt + + /// The page size of each sync execution + public let syncPageSize: UInt + + /// Selective sync expressions + public let syncExpressions: [DataStoreSyncExpression] + + /// Authorization mode strategy + public var authModeStrategyType: AuthModeStrategyType + + public let disableSubscriptions: () -> Bool + + #if os(watchOS) + init(errorHandler: @escaping DataStoreErrorHandler, + conflictHandler: @escaping DataStoreConflictHandler, + syncInterval: TimeInterval, + syncMaxRecords: UInt, + syncPageSize: UInt, + syncExpressions: [DataStoreSyncExpression], + authModeStrategy: AuthModeStrategyType = .default, + disableSubscriptions: @escaping () -> Bool) { + self.errorHandler = errorHandler + self.conflictHandler = conflictHandler + self.syncInterval = syncInterval + self.syncMaxRecords = syncMaxRecords + self.syncPageSize = syncPageSize + self.syncExpressions = syncExpressions + self.authModeStrategyType = authModeStrategy + self.disableSubscriptions = disableSubscriptions + } + #else + init(errorHandler: @escaping DataStoreErrorHandler, + conflictHandler: @escaping DataStoreConflictHandler, + syncInterval: TimeInterval, + syncMaxRecords: UInt, + syncPageSize: UInt, + syncExpressions: [DataStoreSyncExpression], + authModeStrategy: AuthModeStrategyType = .default) { + self.errorHandler = errorHandler + self.conflictHandler = conflictHandler + self.syncInterval = syncInterval + self.syncMaxRecords = syncMaxRecords + self.syncPageSize = syncPageSize + self.syncExpressions = syncExpressions + self.authModeStrategyType = authModeStrategy + self.disableSubscriptions = { false } + } + #endif +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Configuration/InternalDatastoreConfiguration.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Configuration/InternalDatastoreConfiguration.swift new file mode 100644 index 0000000000..37ec4969aa --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Configuration/InternalDatastoreConfiguration.swift @@ -0,0 +1,46 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +struct InternalDatastoreConfiguration { + + /// `true` if any models are syncable. Resolved during configuration phase + var isSyncEnabled: Bool + + /// Configuration of the query against the local storage, whether it should load + /// the belongs-to/has-one associations or not. + /// + /// `isEagerLoad` is true by default, unless the models contain the rootPath + /// which is indication of the codegen that supports for lazy loading. + var isEagerLoad: Bool = true + + /// Identifier used to access the API plugin added to Amplify by api + /// `Amplify.API.getPlugin(for: identifier)` + let validAPIPluginKey: String + + /// Identifier used to access the Auth plugin added to Amplify by api + /// `Amplify.Auth.getPlugin(for: identifier)` + let validAuthPluginKey: String + + /// Configuration provided during Datastore initialization, this is a `public` configuration. + let pluginConfiguration: DataStoreConfiguration + + mutating func updateIsSyncEnabled(_ isEnabled: Bool) { + self.isSyncEnabled = isEnabled + } + + mutating func updateIsEagerLoad(modelSchema: ModelSchema) { + guard isEagerLoad else { + return + } + + if ModelRegistry.modelType(from: modelSchema.name)?.rootPath != nil { + isEagerLoad = false + } + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Core/DataStoreListDecoder.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Core/DataStoreListDecoder.swift new file mode 100644 index 0000000000..f880dcf7f4 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Core/DataStoreListDecoder.swift @@ -0,0 +1,49 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import Combine + +public struct DataStoreListDecoder: ModelListDecoder { + + struct Metadata: Codable { + let dataStoreAssociatedIdentifiers: [String] + let dataStoreAssociatedFields: [String] + } + + /// Creates a data structure that is capable of initializing a `List` with + /// lazy-load capabilities when the list is being decoded. + static func lazyInit(associatedIds: [String], associatedWith: [String]) -> [String: Any?] { + return [ + "dataStoreAssociatedIdentifiers": associatedIds, + "dataStoreAssociatedFields": associatedWith + ] + } + + public static func decode(modelType: ModelType.Type, decoder: Decoder) -> AnyModelListProvider? { + shouldDecodeToDataStoreListProvider(modelType: modelType, decoder: decoder)?.eraseToAnyModelListProvider() + } + + public static func shouldDecodeToDataStoreListProvider(modelType: ModelType.Type, decoder: Decoder) -> DataStoreListProvider? { + if let metadata = try? Metadata.init(from: decoder) { + return DataStoreListProvider(metadata: metadata) + } + + let json = try? JSONValue(from: decoder) + switch json { + case .array: + do { + let elements = try [ModelType](from: decoder) + return DataStoreListProvider(elements) + } catch { + return nil + } + default: + return nil + } + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Core/DataStoreListProvider.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Core/DataStoreListProvider.swift new file mode 100644 index 0000000000..bdf4263743 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Core/DataStoreListProvider.swift @@ -0,0 +1,109 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import Combine + +/// `DataStoreList` is a DataStore-aware custom `Collection` that is capable of loading +/// records from the `DataStore` on-demand. This is especially useful when dealing with +/// Model associations that need to be lazy loaded. +/// +/// When using `DataStore.query(_ modelType:)` some models might contain associations +/// with other models and those aren't fetched automatically. This collection keeps track +/// of the associated `id` and `field` and fetches the associated data on demand. +public class DataStoreListProvider: ModelListProvider { + + var loadedState: ModelListProviderState + + init(metadata: DataStoreListDecoder.Metadata) { + self.loadedState = .notLoaded(associatedIdentifiers: metadata.dataStoreAssociatedIdentifiers, + associatedFields: metadata.dataStoreAssociatedFields) + } + + init(_ elements: [Element]) { + self.loadedState = .loaded(elements) + } + + public func getState() -> ModelListProviderState { + switch loadedState { + case .notLoaded(let associatedIdentifiers, let associatedFields): + return .notLoaded(associatedIdentifiers: associatedIdentifiers, associatedFields: associatedFields) + case .loaded(let elements): + return .loaded(elements) + } + } + + public func load() async throws -> [Element] { + switch loadedState { + case .loaded(let elements): + return elements + case .notLoaded(let associatedIdentifiers, let associatedFields): + let predicate: QueryPredicate + if associatedIdentifiers.count == 1, + let associatedId = associatedIdentifiers.first, + let associatedField = associatedFields.first { + self.log.verbose("Loading List of \(Element.schema.name) by \(associatedField) == \(associatedId) ") + predicate = field(associatedField) == associatedId + } else { + let predicateValues = zip(associatedFields, associatedIdentifiers) + var queryPredicates: [QueryPredicateOperation] = [] + for (identifierName, identifierValue) in predicateValues { + queryPredicates.append(QueryPredicateOperation(field: identifierName, + operator: .equals(identifierValue))) + } + self.log.verbose("Loading List of \(Element.schema.name) by \(associatedFields) == \(associatedIdentifiers) ") + predicate = QueryPredicateGroup(type: .and, predicates: queryPredicates) + } + + do { + let elements = try await Amplify.DataStore.query(Element.self, where: predicate) + self.loadedState = .loaded(elements) + return elements + } catch let error as DataStoreError { + self.log.error(error: error) + throw CoreError.listOperation("Failed to Query DataStore.", + "See underlying DataStoreError for more details.", + error) + } catch { + throw error + + } + } + } + + public func hasNextPage() -> Bool { + false + } + + public func getNextPage() async throws -> List { + throw CoreError.clientValidation("There is no next page.", + "Only call `getNextPage()` when `hasNextPage()` is true.", + nil) + } + + public func encode(to encoder: Encoder) throws { + switch loadedState { + case .notLoaded(let associatedIdentifiers, + let associatedFields): + let metadata = DataStoreListDecoder.Metadata(dataStoreAssociatedIdentifiers: associatedIdentifiers, + dataStoreAssociatedFields: associatedFields) + var container = encoder.singleValueContainer() + try container.encode(metadata) + case .loaded(let elements): + try elements.encode(to: encoder) + } + } +} + +extension DataStoreListProvider: DefaultLogger { + public static var log: Logger { + Amplify.Logging.logger(forCategory: CategoryType.dataStore.displayName, forNamespace: String(describing: self)) + } + public var log: Logger { + Self.log + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Core/DataStoreModelDecoder.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Core/DataStoreModelDecoder.swift new file mode 100644 index 0000000000..6101c0d699 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Core/DataStoreModelDecoder.swift @@ -0,0 +1,52 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import SQLite + +public struct DataStoreModelDecoder: ModelProviderDecoder { + + /// Metadata that contains the foreign key value of a parent model, which is the primary key of the model to be loaded. + struct Metadata: Codable { + let identifiers: [LazyReferenceIdentifier] + let source: String + + init(identifiers: [LazyReferenceIdentifier], + source: String = ModelProviderRegistry.DecoderSource.dataStore) { + self.identifiers = identifiers + self.source = source + } + + func toJsonObject() -> Any? { + try? JSONSerialization.jsonObject(with: JSONEncoder().encode(self)) + } + } + + /// Create a SQLite payload that is capable of initializting a LazyReference, by decoding to `DataStoreModelDecoder.Metadata`. + static func lazyInit(identifiers: [LazyReferenceIdentifier]) -> Metadata? { + if identifiers.isEmpty { + return nil + } + return Metadata(identifiers: identifiers) + } + + public static func decode(modelType: ModelType.Type, decoder: Decoder) -> AnyModelProvider? { + if let metadata = try? DataStoreModelDecoder.Metadata(from: decoder) { + if metadata.source == ModelProviderRegistry.DecoderSource.dataStore { + return DataStoreModelProvider(metadata: metadata).eraseToAnyModelProvider() + } else { + return nil + } + } + + if let model = try? ModelType.init(from: decoder) { + return DataStoreModelProvider(model: model).eraseToAnyModelProvider() + } + + return nil + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Core/DataStoreModelProvider.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Core/DataStoreModelProvider.swift new file mode 100644 index 0000000000..8c00c08b9b --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Core/DataStoreModelProvider.swift @@ -0,0 +1,62 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import Combine + +public class DataStoreModelProvider: ModelProvider { + var loadedState: ModelProviderState + + // Create a "not loaded" model provider with the identifier metadata, useful for hydrating the model + init(metadata: DataStoreModelDecoder.Metadata) { + self.loadedState = .notLoaded(identifiers: metadata.identifiers) + } + + // Create a "loaded" model provider with the model instance + init(model: ModelType?) { + self.loadedState = .loaded(model: model) + } + + // MARK: - APIs + + public func load() async throws -> ModelType? { + switch loadedState { + case .notLoaded(let identifiers): + guard let identifiers = identifiers, !identifiers.isEmpty else { + return nil + } + + let identifierValue = identifiers.count == 1 + ? identifiers.first?.value + : identifiers.map({ "\"\($0.value)\""}).joined(separator: ModelIdentifierFormat.Custom.separator) + + let queryPredicate: QueryPredicate = field(ModelType.schema.primaryKey.sqlName).eq(identifierValue) + let models = try await Amplify.DataStore.query(ModelType.self, where: queryPredicate) + guard let model = models.first else { + return nil + } + self.loadedState = .loaded(model: model) + return model + case .loaded(let model): + return model + } + } + + public func getState() -> ModelProviderState { + loadedState + } + + public func encode(to encoder: Encoder) throws { + switch loadedState { + case .notLoaded(let identifiers): + let metadata = DataStoreModelDecoder.Metadata(identifiers: identifiers ?? []) + try metadata.encode(to: encoder) + case .loaded(let element): + try element.encode(to: encoder) + } + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/DataStoreSyncExpression.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/DataStoreSyncExpression.swift new file mode 100644 index 0000000000..acaabd594d --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/DataStoreSyncExpression.swift @@ -0,0 +1,20 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public typealias QueryPredicateResolver = () -> QueryPredicate + +public struct DataStoreSyncExpression { + let modelSchema: ModelSchema + let modelPredicate: QueryPredicateResolver + + static public func syncExpression(_ modelSchema: ModelSchema, + where predicate: @escaping QueryPredicateResolver) -> DataStoreSyncExpression { + DataStoreSyncExpression(modelSchema: modelSchema, modelPredicate: predicate) + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Migration/ModelMigrations.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Migration/ModelMigrations.swift new file mode 100644 index 0000000000..2b6d0faf6d --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Migration/ModelMigrations.swift @@ -0,0 +1,26 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +protocol ModelMigration { + func apply() throws +} + +class ModelMigrations { + var modelMigrations: [ModelMigration] + + init(modelMigrations: [ModelMigration]) { + self.modelMigrations = modelMigrations + } + + func apply() throws { + for modelMigrations in modelMigrations { + try modelMigrations.apply() + } + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Migration/ModelSyncMetadataMigration.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Migration/ModelSyncMetadataMigration.swift new file mode 100644 index 0000000000..cc4250e423 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Migration/ModelSyncMetadataMigration.swift @@ -0,0 +1,106 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import SQLite + +class ModelSyncMetadataMigration: ModelMigration { + + weak var storageAdapter: SQLiteStorageEngineAdapter? + + func apply() throws { + try performModelMetadataSyncPredicateUpgrade() + } + + init(storageAdapter: SQLiteStorageEngineAdapter? = nil) { + self.storageAdapter = storageAdapter + } + + /// Add the new syncPredicate column for the ModelSyncMetadata system table. + /// + /// ModelSyncMetadata's syncPredicate column was added in Amplify version 2.22.0 to + /// support a bug fix related to persisting the sync predicate of the sync expression. + /// Apps before upgrading to this version of the plugin will have created the table already. + /// Upgraded apps will not re-create the table with the CreateTableStatement, neither will throw an error + /// (CreateTableStatement is run with 'create table if not exists' doing a no-op). This function + /// checks if the column exists on the table, and if it doesn't, alter the table to add the new column. + /// + /// For more details, see https://github.com/aws-amplify/amplify-swift/pull/2757. + /// - Returns: `true` if upgrade occured, `false` otherwise. + @discardableResult + func performModelMetadataSyncPredicateUpgrade() throws -> Bool { + do { + guard let field = ModelSyncMetadata.schema.field( + withName: ModelSyncMetadata.keys.syncPredicate.stringValue) else { + log.error("Could not find corresponding ModelField from ModelSyncMetadata for syncPredicate") + return false + } + let exists = try columnExists(modelSchema: ModelSyncMetadata.schema, + field: field) + guard !exists else { + log.debug("Detected ModelSyncMetadata table has syncPredicate column. No migration needed") + return false + } + + log.debug("Detected ModelSyncMetadata table exists without syncPredicate column.") + guard let storageAdapter = storageAdapter else { + log.debug("Missing SQLiteStorageEngineAdapter for model migration") + throw DataStoreError.nilStorageAdapter() + } + guard let connection = storageAdapter.connection else { + throw DataStoreError.nilSQLiteConnection() + } + let addColumnStatement = AlterTableAddColumnStatement( + modelSchema: ModelSyncMetadata.schema, + field: field).stringValue + try connection.execute(addColumnStatement) + log.debug("ModelSyncMetadata table altered to add syncPredicate column.") + return true + } catch { + throw DataStoreError.invalidOperation(causedBy: error) + } + } + + func columnExists(modelSchema: ModelSchema, field: ModelField) throws -> Bool { + guard let storageAdapter = storageAdapter else { + log.debug("Missing SQLiteStorageEngineAdapter for model migration") + throw DataStoreError.nilStorageAdapter() + } + guard let connection = storageAdapter.connection else { + throw DataStoreError.nilSQLiteConnection() + } + + let tableInfoStatement = TableInfoStatement(modelSchema: modelSchema) + do { + let existingColumns = try connection.prepare(tableInfoStatement.stringValue).run() + let columnToFind = field.name + var columnExists = false + for column in existingColumns { + // The second element is the column name + if column.count >= 2, + let columnName = column[1], + let columNameString = columnName as? String, + columnToFind == columNameString { + columnExists = true + break + } + } + return columnExists + } catch { + throw DataStoreError.invalidOperation(causedBy: error) + } + } +} + +extension ModelSyncMetadataMigration: DefaultLogger { + public static var log: Logger { + Amplify.Logging.logger(forCategory: CategoryType.dataStore.displayName, forNamespace: String(describing: self)) + } + public var log: Logger { + Self.log + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Migration/MutationSyncMetadataCopy.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Migration/MutationSyncMetadataCopy.swift new file mode 100644 index 0000000000..76b1d91159 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Migration/MutationSyncMetadataCopy.swift @@ -0,0 +1,43 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension MutationSyncMetadataMigration { + public struct MutationSyncMetadataCopy: Model { + public let id: String + public var deleted: Bool + public var lastChangedAt: Int64 + public var version: Int + + // MARK: - CodingKeys + + public enum CodingKeys: String, ModelKey { + case id + case deleted + case lastChangedAt + case version + } + + public static let keys = CodingKeys.self + + // MARK: - ModelSchema + + public static let schema = defineSchema { definition in + let sync = MutationSyncMetadataCopy.keys + + definition.attributes(.isSystem) + + definition.fields( + .id(), + .field(sync.deleted, is: .required, ofType: .bool), + .field(sync.lastChangedAt, is: .required, ofType: .int), + .field(sync.version, is: .required, ofType: .int) + ) + } + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Migration/MutationSyncMetadataMigration.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Migration/MutationSyncMetadataMigration.swift new file mode 100644 index 0000000000..ab6ac7fdb1 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Migration/MutationSyncMetadataMigration.swift @@ -0,0 +1,59 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Migrates `MutationSyncMetadata` to the new format. +/// +/// Format of the `id` in `MutationSyncMetadata` has changed to support unique ids +/// across mutiple model types. Earlier model id is repalced with id of the format `{modelName}|{modelId}` +/// +class MutationSyncMetadataMigration: ModelMigration { + + weak var delegate: MutationSyncMetadataMigrationDelegate? + + init(delegate: MutationSyncMetadataMigrationDelegate) { + self.delegate = delegate + } + + func apply() throws { + guard let delegate = delegate else { + log.debug("Missing MutationSyncMetadataMigrationDelegate delegate") + throw DataStoreError.unknown("Missing MutationSyncMetadataMigrationDelegate delegate", "", nil) + } + try delegate.preconditionCheck() + try delegate.transaction { + if try delegate.mutationSyncMetadataStoreEmptyOrMigrated() { + return + } + + if try delegate.containsDuplicateIdsAcrossModels() { + log.debug("Duplicate IDs found across different model types.") + log.debug("Clearing MutationSyncMetadata and ModelSyncMetadata to force full sync.") + try delegate.applyMigrationStep(.emptyMutationSyncMetadataStore) + try delegate.applyMigrationStep(.emptyModelSyncMetadataStore) + } else { + log.debug("No duplicate IDs found.") + log.debug("Modifying and backfilling MutationSyncMetadata") + try delegate.applyMigrationStep(.removeMutationSyncMetadataCopyStore) + try delegate.applyMigrationStep(.createMutationSyncMetadataCopyStore) + try delegate.applyMigrationStep(.backfillMutationSyncMetadata) + try delegate.applyMigrationStep(.removeMutationSyncMetadataStore) + try delegate.applyMigrationStep(.renameMutationSyncMetadataCopy) + } + } + } +} + +extension MutationSyncMetadataMigration: DefaultLogger { + public static var log: Logger { + Amplify.Logging.logger(forCategory: CategoryType.dataStore.displayName, forNamespace: String(describing: self)) + } + public var log: Logger { + Self.log + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Migration/MutationSyncMetadataMigrationDelegate+SQLite.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Migration/MutationSyncMetadataMigrationDelegate+SQLite.swift new file mode 100644 index 0000000000..1cd9357c34 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Migration/MutationSyncMetadataMigrationDelegate+SQLite.swift @@ -0,0 +1,137 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import SQLite + +final class SQLiteMutationSyncMetadataMigrationDelegate: MutationSyncMetadataMigrationDelegate { + + let modelSchemas: [ModelSchema] + weak var storageAdapter: SQLiteStorageEngineAdapter? + + init(storageAdapter: SQLiteStorageEngineAdapter, modelSchemas: [ModelSchema]) { + self.storageAdapter = storageAdapter + self.modelSchemas = modelSchemas + } + + func transaction(_ basicClosure: BasicThrowableClosure) throws { + try storageAdapter?.transaction(basicClosure) + } + + func applyMigrationStep(_ step: MutationSyncMetadataMigrationStep) throws { + switch step { + case .emptyMutationSyncMetadataStore: + try emptyMutationSyncMetadataStore() + case .emptyModelSyncMetadataStore: + try emptyModelSyncMetadataStore() + case .removeMutationSyncMetadataCopyStore: + try removeMutationSyncMetadataCopyStore() + case .createMutationSyncMetadataCopyStore: + try createMutationSyncMetadataCopyStore() + case .backfillMutationSyncMetadata: + try backfillMutationSyncMetadata() + case .removeMutationSyncMetadataStore: + try removeMutationSyncMetadataStore() + case .renameMutationSyncMetadataCopy: + try renameMutationSyncMetadataCopy() + } + } + + // MARK: - Clear + + @discardableResult func emptyMutationSyncMetadataStore() throws -> String { + guard let storageAdapter = storageAdapter else { + log.debug("Missing SQLiteStorageEngineAdapter for model migration") + throw DataStoreError.nilStorageAdapter() + } + + return try storageAdapter.emptyStore(for: MutationSyncMetadata.schema) + } + + @discardableResult func emptyModelSyncMetadataStore() throws -> String { + guard let storageAdapter = storageAdapter else { + log.debug("Missing SQLiteStorageEngineAdapter for model migration") + throw DataStoreError.nilStorageAdapter() + } + + return try storageAdapter.emptyStore(for: ModelSyncMetadata.schema) + } + + // MARK: - Migration + + @discardableResult func removeMutationSyncMetadataCopyStore() throws -> String { + guard let storageAdapter = storageAdapter else { + log.debug("Missing SQLiteStorageEngineAdapter for model migration") + throw DataStoreError.nilStorageAdapter() + } + + return try storageAdapter.removeStore(for: MutationSyncMetadataMigration.MutationSyncMetadataCopy.schema) + } + + @discardableResult func createMutationSyncMetadataCopyStore() throws -> String { + guard let storageAdapter = storageAdapter else { + log.debug("Missing SQLiteStorageEngineAdapter for model migration") + throw DataStoreError.nilStorageAdapter() + } + + return try storageAdapter.createStore(for: MutationSyncMetadataMigration.MutationSyncMetadataCopy.schema) + } + + @discardableResult func backfillMutationSyncMetadata() throws -> String { + guard let storageAdapter = storageAdapter else { + log.debug("Missing SQLiteStorageEngineAdapter for model migration") + throw DataStoreError.nilStorageAdapter() + } + + guard let connection = storageAdapter.connection else { + throw DataStoreError.nilSQLiteConnection() + } + + var sql = "" + for modelSchema in modelSchemas { + let modelName = modelSchema.name + + if sql != "" { + sql += " UNION ALL " + } + sql += "SELECT id, \'\(modelName)\' as tableName FROM \(modelName)" + } + sql = "INSERT INTO \(MutationSyncMetadataMigration.MutationSyncMetadataCopy.modelName) (id,deleted,lastChangedAt,version) " + + "select models.tableName || '|' || mm.id, mm.deleted, mm.lastChangedAt, mm.version " + + "from MutationSyncMetadata mm INNER JOIN (" + sql + ") as models on mm.id=models.id" + try connection.execute(sql) + return sql + } + + @discardableResult func removeMutationSyncMetadataStore() throws -> String { + guard let storageAdapter = storageAdapter else { + log.debug("Missing SQLiteStorageEngineAdapter for model migration") + throw DataStoreError.nilStorageAdapter() + } + + return try storageAdapter.removeStore(for: MutationSyncMetadata.schema) + } + + @discardableResult func renameMutationSyncMetadataCopy() throws -> String { + guard let storageAdapter = storageAdapter else { + log.debug("Missing SQLiteStorageEngineAdapter for model migration") + throw DataStoreError.nilStorageAdapter() + } + + return try storageAdapter.renameStore(from: MutationSyncMetadataMigration.MutationSyncMetadataCopy.schema, + toModelSchema: MutationSyncMetadata.schema) + } +} + +extension SQLiteMutationSyncMetadataMigrationDelegate: DefaultLogger { + public static var log: Logger { + Amplify.Logging.logger(forCategory: CategoryType.dataStore.displayName, forNamespace: String(describing: self)) + } + public var log: Logger { + Self.log + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Migration/MutationSyncMetadataMigrationDelegate+SQLiteValidation.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Migration/MutationSyncMetadataMigrationDelegate+SQLiteValidation.swift new file mode 100644 index 0000000000..5fb29ee2ad --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Migration/MutationSyncMetadataMigrationDelegate+SQLiteValidation.swift @@ -0,0 +1,138 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import SQLite + +extension SQLiteMutationSyncMetadataMigrationDelegate { + + // MARK: - Precondition + + /// Migration requires some hardcoded SQL statements that may break if the schema changes + /// Ensure that the schemas used in this migration are as expected + func preconditionCheck() throws { + let fieldCount = 4 + let idField = "id" + let deletedField = "deleted" + let lastChangedAtField = "lastChangedAt" + let versionField = "version" + + guard MutationSyncMetadata.schema.fields.count == fieldCount else { + throw DataStoreError.internalOperation("MutationSyncMetadata schema has changed from 4 fields", "", nil) + } + + guard MutationSyncMetadata.schema.fields[idField] != nil, + MutationSyncMetadata.schema.fields[deletedField] != nil, + MutationSyncMetadata.schema.fields[lastChangedAtField] != nil, + MutationSyncMetadata.schema.fields[versionField] != nil else { + throw DataStoreError.internalOperation("MutationSyncMetadata schema missing expected fields", "", nil) + } + + guard MutationSyncMetadataMigration.MutationSyncMetadataCopy.schema.fields.count == fieldCount else { + throw DataStoreError.internalOperation("MutationSyncMetadataCopy schema has changed from 4 fields", "", nil) + } + + guard MutationSyncMetadataMigration.MutationSyncMetadataCopy.schema.fields[idField] != nil, + MutationSyncMetadataMigration.MutationSyncMetadataCopy.schema.fields[deletedField] != nil, + MutationSyncMetadataMigration.MutationSyncMetadataCopy.schema.fields[lastChangedAtField] != nil, + MutationSyncMetadataMigration.MutationSyncMetadataCopy.schema.fields[versionField] != nil else { + throw DataStoreError.internalOperation("MutationSyncMetadataCopy schema missing expected fields", "", nil) + } + } + + // MARK: - Needs Migration + + /// If there are no MutationSyncMetadata records, then it is not necessary to apply the migration since there is no + /// data to migrate. If there is data, and the id's have already been migrated ( > 0 keys), then no migration needed + func mutationSyncMetadataStoreEmptyOrMigrated() throws -> Bool { + let records = try selectMutationSyncMetadataRecords() + if records.metadataCount == 0 || records.metadataIdMatchNewKeyCount > 0 { + log.debug("No MutationSyncMetadata migration needed.") + return true + } + log.debug("Migration is needed. MutationSyncMetadata IDs need to be migrated to new key format.") + return false + } + + /// Retrieve the record count from the MutationSyncMetadata table for + /// 1. the total number of records + /// 2. the total number of records that have the `id` match `|` + func selectMutationSyncMetadataRecords() throws -> (metadataCount: Int64, metadataIdMatchNewKeyCount: Int64) { + guard let storageAdapter = storageAdapter else { + log.debug("Missing SQLiteStorageEngineAdapter for model migration") + throw DataStoreError.nilStorageAdapter() + } + guard let connection = storageAdapter.connection else { + throw DataStoreError.nilSQLiteConnection() + } + let sql = """ + select (select count(1) as count from MutationSyncMetadata) as allRecords, + (select count(1) as count from MutationSyncMetadata where id like '%|%') as newKeys + """ + log.debug("Checking MutationSyncMetadata records, SQL: \(sql)") + let rows = try connection.run(sql) + let iter = rows.makeIterator() + while let row = try iter.failableNext() { + if let metadataCount = row[0] as? Int64, let metadataIdMatchNewKeyCount = row[1] as? Int64 { + return (metadataCount, metadataIdMatchNewKeyCount) + } else { + log.verbose("Failed to iterate over records") + throw DataStoreError.unknown("Failed to iterate over records", "", nil) + } + } + + throw DataStoreError.unknown("Failed to select MutationSyncMetadata records", "", nil) + } + + // MARK: - Cannot Migrate + + func containsDuplicateIdsAcrossModels() throws -> Bool { + guard let storageAdapter = storageAdapter else { + log.debug("Missing SQLiteStorageEngineAdapter for model migration") + throw DataStoreError.nilStorageAdapter() + } + guard let connection = storageAdapter.connection else { + throw DataStoreError.nilSQLiteConnection() + } + let sql = selectDuplicateIdAcrossModels() + log.debug("Checking for duplicate IDs, SQL: \(sql)") + let rows = try connection.run(sql) + let iter = rows.makeIterator() + while let row = try iter.failableNext() { + return !row.isEmpty + } + + return false + } + + /// Retrieve results where `id` is the same across multiple tables. + /// For three models, the SQL statement looks like this: + /// ``` + /// SELECT id, tableName, count(id) as count FROM ( + /// SELECT id, 'Restaurant' as tableName FROM Restaurant UNION ALL + /// SELECT id, 'Menu' as tableName FROM Menu UNION ALL + /// SELECT id, 'Dish' as tableName FROM Dish) GROUP BY id HAVING count > 1 + /// ``` + /// If there are three models in different model tables with the same id "1" + /// the result of this query will have a row like: + /// ``` + /// // [id, tableName, count(id) + /// [Optional("1"), Optional("Restaurant"), Optional(3)] + /// ``` + /// As long as there is one resulting duplicate id, the entire function will return true + func selectDuplicateIdAcrossModels() -> String { + var sql = "" + for modelSchema in modelSchemas { + let modelName = modelSchema.name + if sql != "" { + sql += " UNION ALL " + } + sql += "SELECT id, \'\(modelName)\' as tableName FROM \(modelName)" + } + return "SELECT id, tableName, count(id) as count FROM (" + sql + ") GROUP BY id HAVING count > 1" + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Migration/MutationSyncMetadataMigrationDelegate.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Migration/MutationSyncMetadataMigrationDelegate.swift new file mode 100644 index 0000000000..50c78329cf --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Migration/MutationSyncMetadataMigrationDelegate.swift @@ -0,0 +1,33 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +enum MutationSyncMetadataMigrationStep { + case emptyMutationSyncMetadataStore + case emptyModelSyncMetadataStore + case removeMutationSyncMetadataCopyStore + case createMutationSyncMetadataCopyStore + case backfillMutationSyncMetadata + case removeMutationSyncMetadataStore + case renameMutationSyncMetadataCopy +} + +/// Delegate used by `MutationSyncMetadataMigration` which can be implemented by different +/// storage adapters. +protocol MutationSyncMetadataMigrationDelegate: AnyObject { + + func preconditionCheck() throws + + func transaction(_ basicClosure: BasicThrowableClosure) throws + + func mutationSyncMetadataStoreEmptyOrMigrated() throws -> Bool + + func containsDuplicateIdsAcrossModels() throws -> Bool + + func applyMigrationStep(_ step: MutationSyncMetadataMigrationStep) throws +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Resources/PrivacyInfo.xcprivacy b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Resources/PrivacyInfo.xcprivacy new file mode 100644 index 0000000000..d8de9fff81 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Resources/PrivacyInfo.xcprivacy @@ -0,0 +1,19 @@ + + + + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + NSPrivacyCollectedDataTypes + + + diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/CascadeDeleteOperation.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/CascadeDeleteOperation.swift new file mode 100644 index 0000000000..7fe0fec668 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/CascadeDeleteOperation.swift @@ -0,0 +1,554 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import Combine + +// swiftlint:disable type_body_length file_length +/// CascadeDeleteOperation has the following logic: +/// 1. Query models from local store based on the following use cases: +/// 1a. If the use case is Delete with id, then query by `id` +/// 1b. or Delete with id and condition, then query by `id` and `condition`. If the model given the condition does not exist, +/// check if the model exists. if the model exists, then fail with `DataStoreError.invalidCondition`. +/// 1c. or Delete with filter, then query by `filter`. +/// 2. If there are at least one item to delete, query for all its associated models recursively. +/// 3. Delete the original queried items from local store. This performs a cascade delete by default (See +/// **CreateTableStatement** for more details, `on delete cascade` when creating the SQL table enables this behavior). +/// 4. If sync is enabled, then submit the delete mutations to the sync engine, in the order of children to parent models. +public class CascadeDeleteOperation: AsynchronousOperation { + let storageAdapter: StorageEngineAdapter + var syncEngine: RemoteSyncEngineBehavior? + let modelType: M.Type + let modelSchema: ModelSchema + let deleteInput: DeleteInput + let completionForWithId: ((DataStoreResult) -> Void)? + let completionForWithFilter: ((DataStoreResult<[M]>) -> Void)? + + private let serialQueueSyncDeletions = DispatchQueue(label: "com.amazoncom.Storage.CascadeDeleteOperation.concurrency") + private let isDeveloperDefinedModel: Bool + + convenience init( + storageAdapter: StorageEngineAdapter, + syncEngine: RemoteSyncEngineBehavior?, + modelType: M.Type, + modelSchema: ModelSchema, + withIdentifier identifier: ModelIdentifierProtocol, + condition: QueryPredicate? = nil, + completion: @escaping (DataStoreResult) -> Void) { + + var deleteInput = DeleteInput.withIdentifier(id: identifier) + if let condition = condition { + deleteInput = .withIdentifierAndCondition(id: identifier, condition: condition) + } + self.init( + storageAdapter: storageAdapter, + syncEngine: syncEngine, + modelType: modelType, + modelSchema: modelSchema, + deleteInput: deleteInput, + completionForWithId: completion, + completionForWithFilter: nil) + } + + convenience init( + storageAdapter: StorageEngineAdapter, + syncEngine: RemoteSyncEngineBehavior?, + modelType: M.Type, + modelSchema: ModelSchema, + filter: QueryPredicate, + completion: @escaping (DataStoreResult<[M]>) -> Void) { + self.init( + storageAdapter: storageAdapter, + syncEngine: syncEngine, + modelType: modelType, + modelSchema: modelSchema, + deleteInput: .withFilter(filter), + completionForWithId: nil, + completionForWithFilter: completion + ) + } + + private init(storageAdapter: StorageEngineAdapter, + syncEngine: RemoteSyncEngineBehavior?, + modelType: M.Type, + modelSchema: ModelSchema, + deleteInput: DeleteInput, + completionForWithId: ((DataStoreResult) -> Void)?, + completionForWithFilter: ((DataStoreResult<[M]>) -> Void)? + ) { + self.storageAdapter = storageAdapter + self.syncEngine = syncEngine + self.modelType = modelType + self.modelSchema = modelSchema + self.deleteInput = deleteInput + self.completionForWithId = completionForWithId + self.completionForWithFilter = completionForWithFilter + self.isDeveloperDefinedModel = StorageEngine + .systemModelSchemas + .contains { $0.name != modelSchema.name } + super.init() + } + + override public func main() { + queryAndDelete() + } + + func queryAndDelete() { + do { + try storageAdapter.transaction { + Task { + let transactionResult = await queryAndDeleteTransaction() + syncIfNeededAndFinish(transactionResult) + } + } + } catch { + syncIfNeededAndFinish(.failure(causedBy: error)) + } + } + + func queryAndDeleteTransaction() async -> DataStoreResult> { + var queriedResult: DataStoreResult<[M]>? + var deletedResult: DataStoreResult<[M]>? + var associatedModels: [(ModelName, Model)] = [] + + queriedResult = await withCheckedContinuation { continuation in + self.storageAdapter.query(self.modelType, + modelSchema: self.modelSchema, + predicate: self.deleteInput.predicate, + sort: nil, + paginationInput: nil, + eagerLoad: true) { result in + continuation.resume(returning: result) + } + } + guard case .success(let queriedModels) = queriedResult else { + return collapseResults(queryResult: queriedResult, + deleteResult: deletedResult, + associatedModels: associatedModels) + } + guard !queriedModels.isEmpty else { + guard case .withIdentifierAndCondition(let identifier, _) = self.deleteInput else { + // Query did not return any results, treat this as a successful no-op delete. + deletedResult = .success([M]()) + return collapseResults(queryResult: queriedResult, + deleteResult: deletedResult, + associatedModels: associatedModels) + } + + // Query using the computed predicate did not return any results, check if model actually exists. + do { + if try self.storageAdapter.exists(self.modelSchema, withIdentifier: identifier, predicate: nil) { + queriedResult = .failure( + DataStoreError.invalidCondition( + "Delete failed due to condition did not match existing model instance.", + "Subsequent deletes will continue to fail until the model instance is updated.")) + } else { + deletedResult = .success([M]()) + } + } catch { + queriedResult = .failure(DataStoreError.invalidOperation(causedBy: error)) + } + + return collapseResults(queryResult: queriedResult, + deleteResult: deletedResult, + associatedModels: associatedModels) + } + + let modelIds = queriedModels.map { $0.identifier(schema: self.modelSchema).stringValue } + logMessage("[CascadeDelete.1] Deleting \(self.modelSchema.name) with identifiers: \(modelIds)") + + associatedModels = await self.recurseQueryAssociatedModels(modelSchema: self.modelSchema, ids: modelIds) + + deletedResult = await withCheckedContinuation { continuation in + self.storageAdapter.delete(self.modelType, + modelSchema: self.modelSchema, + filter: self.deleteInput.predicate) { result in + continuation.resume(returning: result) + } + } + return collapseResults(queryResult: queriedResult, + deleteResult: deletedResult, + associatedModels: associatedModels) + } + + func recurseQueryAssociatedModels(modelSchema: ModelSchema, ids: [String]) async -> [(ModelName, Model)] { + var associatedModels: [(ModelName, Model)] = [] + for (_, modelField) in modelSchema.fields { + guard + modelField.hasAssociation, + modelField.isOneToOne || modelField.isOneToMany, + let associatedModelName = modelField.associatedModelName, + let associatedField = modelField.associatedField, + let associatedModelSchema = ModelRegistry.modelSchema(from: associatedModelName) + else { + continue + } + logMessage( + "[CascadeDelete.2] Querying for \(modelSchema.name)'s associated model \(associatedModelSchema.name)." + ) + let queriedModels = await queryAssociatedModels( + associatedModelSchema: associatedModelSchema, + associatedField: associatedField, + ids: ids) + + let associatedModelIds = queriedModels.map { + $0.1.identifier(schema: associatedModelSchema).stringValue + } + + logMessage("[CascadeDelete.2] Queried for \(associatedModelSchema.name), retrieved ids for deletion: \(associatedModelIds)") + + associatedModels.append(contentsOf: queriedModels) + associatedModels.append(contentsOf: await recurseQueryAssociatedModels( + modelSchema: associatedModelSchema, + ids: associatedModelIds) + ) + } + + return associatedModels + } + + func queryAssociatedModels(associatedModelSchema modelSchema: ModelSchema, + associatedField: ModelField, + ids: [String]) async -> [(ModelName, Model)] { + var queriedModels: [(ModelName, Model)] = [] + let chunkedArrays = ids.chunked(into: SQLiteStorageEngineAdapter.maxNumberOfPredicates) + for chunkedArray in chunkedArrays { + // TODO: Add conveinence to queryPredicate where we have a list of items, to be all or'ed + var queryPredicates: [QueryPredicateOperation] = [] + for id in chunkedArray { + queryPredicates.append(QueryPredicateOperation(field: associatedField.name, operator: .equals(id))) + } + let groupedQueryPredicates = QueryPredicateGroup(type: .or, predicates: queryPredicates) + + do { + let models = try await withCheckedThrowingContinuation { continuation in + storageAdapter.query(modelSchema: modelSchema, predicate: groupedQueryPredicates, eagerLoad: true) { result in + continuation.resume(with: result) + } + } + queriedModels.append(contentsOf: models.map { model in + (modelSchema.name, model) + }) + } catch { + log.error("Failed to query \(modelSchema) on mutation event generation: \(error)") + } + } + return queriedModels + } + + private func collapseResults( + queryResult: DataStoreResult<[M]>?, + deleteResult: DataStoreResult<[M]>?, + associatedModels: [(ModelName, Model)] + ) -> DataStoreResult> { + + guard let queryResult = queryResult else { + return .failure(.unknown("queryResult not set during transaction", "coding error", nil)) + } + + switch queryResult { + case .success(let models): + guard let deleteResult = deleteResult else { + return .failure(.unknown("deleteResult not set during transaction", "coding error", nil)) + } + + switch deleteResult { + case .success: + logMessage("[CascadeDelete.3] Local cascade delete of \(self.modelSchema.name) successful!") + return .success(QueryAndDeleteResult(deletedModels: models, + associatedModels: associatedModels)) + case .failure(let error): + return .failure(error) + } + + case .failure(let error): + return .failure(error) + } + } + + func syncIfNeededAndFinish(_ transactionResult: DataStoreResult>) { + switch transactionResult { + case .success(let queryAndDeleteResult): + logMessage( + """ + [CascadeDelete.4] sending a total of + \(queryAndDeleteResult.associatedModels.count + queryAndDeleteResult.deletedModels.count) delete mutations + """ + ) + switch deleteInput { + case .withIdentifier, .withIdentifierAndCondition: + guard queryAndDeleteResult.deletedModels.count <= 1 else { + completionForWithId?(.failure(.unknown("delete with id returned more than one result", "", nil))) + finish() + return + } + + guard queryAndDeleteResult.deletedModels.first != nil else { + completionForWithId?(.success(nil)) + finish() + return + } + case .withFilter: + guard !queryAndDeleteResult.deletedModels.isEmpty else { + completionForWithFilter?(.success(queryAndDeleteResult.deletedModels)) + finish() + return + } + } + + guard modelSchema.isSyncable, let syncEngine = self.syncEngine else { + if !modelSchema.isSystem { + log.error("Unable to sync model (\(modelSchema.name)) where isSyncable is false") + } + if self.syncEngine == nil { + log.error("Unable to sync because syncEngine is nil") + } + completionForWithId?(.success(queryAndDeleteResult.deletedModels.first)) + completionForWithFilter?(.success(queryAndDeleteResult.deletedModels)) + finish() + return + } + + guard #available(iOS 13.0, *) else { + completionForWithId?(.success(queryAndDeleteResult.deletedModels.first)) + completionForWithFilter?(.success(queryAndDeleteResult.deletedModels)) + finish() + return + } + + // TODO: This requires follow up. + // In the current code, when deleting a single model instance conditionally, the `condition` predicate is + // first applied locally to determine whether the item should be deleted or not. If met, the local item is + // deleted. When syncing this deleted model with the delete mutation event, the `condition` is not passed + // to the delete mutation. Should it be passed to the delete mutation as well? + // + // When deleting all models that match the `filter` predicate, the `filter` is passed to the + // delete mutation event. Since the item was originally retrieved using the filter as a way to narrow + // down which items should be deleted, then does it still need to be passed as the "condition" for the + // delete mutation if it will always be met? (Perhaps, this is needed as a way to guard against updates + // that move the model out of the filtered results). Should we stop passing the `filter` to the delete + // mutation? + switch deleteInput { + case .withIdentifier, .withIdentifierAndCondition: + syncDeletions(withModels: queryAndDeleteResult.deletedModels, + associatedModels: queryAndDeleteResult.associatedModels, + syncEngine: syncEngine) { + switch $0 { + case .success: + self.completionForWithId?(.success(queryAndDeleteResult.deletedModels.first)) + case .failure(let error): + self.completionForWithId?(.failure(error)) + } + self.finish() + } + case .withFilter(let filter): + syncDeletions(withModels: queryAndDeleteResult.deletedModels, + predicate: filter, + associatedModels: queryAndDeleteResult.associatedModels, + syncEngine: syncEngine) { + switch $0 { + case .success: + self.completionForWithFilter?(.success(queryAndDeleteResult.deletedModels)) + case .failure(let error): + self.completionForWithFilter?(.failure(error)) + } + self.finish() + } + } + + case .failure(let error): + completionForWithId?(.failure(error)) + completionForWithFilter?(.failure(error)) + finish() + } + } + + // `syncDeletions` will first sync all associated models in reversed order so the lowest level of children models + // are synced first, before its parent models. See `recurseQueryAssociatedModels()` for more details on the + // ordering of the results in `associatedModels`. Once all the associated models are synced, sync the `models`, + // finishing the sequence of deletions from children to parent. + // + // For example, A has-many B and C, B has-many D, D has-many E. The query will result in associatedModels with + // the order [B, D, E, C]. Sync deletions will be performed the back to the front from C, E, D, B, then finally the + // parent models A. + // + // `.reversed()` will not allocate new space for its elements (what we want) by wrapping the underlying + // collection and provide access in reverse order. + // For more details: https://developer.apple.com/documentation/swift/array/1690025-reversed + @available(iOS 13.0, *) + private func syncDeletions(withModels models: [M], + predicate: QueryPredicate? = nil, + associatedModels: [(ModelName, Model)], + syncEngine: RemoteSyncEngineBehavior, + completion: @escaping DataStoreCallback) { + var savedDataStoreError: DataStoreError? + + guard !associatedModels.isEmpty else { + syncDeletions(withModels: models, + predicate: predicate, + syncEngine: syncEngine, + dataStoreError: savedDataStoreError, + completion: completion) + return + } + self.log.debug("[CascadeDelete.4] Begin syncing \(associatedModels.count) associated models for deletion. ") + + var mutationEventsSubmitCompleted = 0 + for (modelName, associatedModel) in associatedModels.reversed() { + let mutationEvent: MutationEvent + do { + mutationEvent = try MutationEvent(untypedModel: associatedModel, + modelName: modelName, + mutationType: .delete) + } catch { + let dataStoreError = DataStoreError(error: error) + completion(.failure(dataStoreError)) + return + } + + let mutationEventCallback: DataStoreCallback = { result in + self.serialQueueSyncDeletions.async { + mutationEventsSubmitCompleted += 1 + switch result { + case .failure(let dataStoreError): + self.log.error("\(#function) failed to submit to sync engine \(mutationEvent)") + if savedDataStoreError == nil { + savedDataStoreError = dataStoreError + } + case .success(let mutationEvent): + self.log.verbose("\(#function) successfully submitted \(mutationEvent.modelName) to sync engine \(mutationEvent)") + } + + if mutationEventsSubmitCompleted == associatedModels.count { + self.syncDeletions(withModels: models, + predicate: predicate, + syncEngine: syncEngine, + dataStoreError: savedDataStoreError, + completion: completion) + } + } + } + submitToSyncEngine(mutationEvent: mutationEvent, + syncEngine: syncEngine, + completion: mutationEventCallback) + + } + } + + @available(iOS 13.0, *) + private func syncDeletions(withModels models: [M], + predicate: QueryPredicate? = nil, + syncEngine: RemoteSyncEngineBehavior, + dataStoreError: DataStoreError?, + completion: @escaping DataStoreCallback) { + logMessage("[CascadeDelete.4] Begin syncing \(models.count) \(self.modelSchema.name) model for deletion") + var graphQLFilterJSON: String? + if let predicate = predicate { + do { + graphQLFilterJSON = try GraphQLFilterConverter.toJSON(predicate, + modelSchema: modelSchema) + } catch { + let dataStoreError = DataStoreError(error: error) + completion(.failure(dataStoreError)) + return + } + } + var mutationEventsSubmitCompleted = 0 + var savedDataStoreError = dataStoreError + for model in models { + let mutationEvent: MutationEvent + do { + mutationEvent = try MutationEvent(model: model, + modelSchema: modelSchema, + mutationType: .delete, + graphQLFilterJSON: graphQLFilterJSON) + } catch { + let dataStoreError = DataStoreError(error: error) + completion(.failure(dataStoreError)) + return + } + + let mutationEventCallback: DataStoreCallback = { result in + self.serialQueueSyncDeletions.async { + mutationEventsSubmitCompleted += 1 + switch result { + case .failure(let dataStoreError): + self.log.error("\(#function) failed to submit to sync engine \(mutationEvent)") + if savedDataStoreError == nil { + savedDataStoreError = dataStoreError + } + case .success: + self.log.verbose("\(#function) successfully submitted to sync engine \(mutationEvent)") + } + if mutationEventsSubmitCompleted == models.count { + if let lastEmittedDataStoreError = savedDataStoreError { + completion(.failure(lastEmittedDataStoreError)) + } else { + completion(.successfulVoid) + } + } + } + } + + submitToSyncEngine(mutationEvent: mutationEvent, + syncEngine: syncEngine, + completion: mutationEventCallback) + } + } + + private func submitToSyncEngine(mutationEvent: MutationEvent, + syncEngine: RemoteSyncEngineBehavior, + completion: @escaping DataStoreCallback) { + syncEngine.submit(mutationEvent, completion: completion) + } + + private func logMessage( + _ message: @escaping @autoclosure () -> String, + level log: (() -> String) -> Void = log.debug) { + guard isDeveloperDefinedModel else { return } + log(message) + } +} + +// MARK: - Supporting types +extension CascadeDeleteOperation { + + struct QueryAndDeleteResult { + let deletedModels: [M] + let associatedModels: [(ModelName, Model)] + } + + enum DeleteInput { + case withIdentifier(id: ModelIdentifierProtocol) + case withIdentifierAndCondition(id: ModelIdentifierProtocol, condition: QueryPredicate) + case withFilter(_ filter: QueryPredicate) + + /// Returns a computed predicate based on the type of delete scenario it is. + var predicate: QueryPredicate { + switch self { + case .withIdentifier(let identifier): + return identifier.predicate + case .withIdentifierAndCondition(let identifier, let predicate): + return QueryPredicateGroup(type: .and, + predicates: [identifier.predicate, + predicate]) + case .withFilter(let predicate): + return predicate + } + } + } +} + +extension CascadeDeleteOperation: DefaultLogger { + public static var log: Logger { + Amplify.Logging.logger(forCategory: CategoryType.dataStore.displayName, forNamespace: String(describing: self)) + } + public var log: Logger { + Self.log + } +} +// swiftlint:enable type_body_length file_length diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/ModelStorageBehavior.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/ModelStorageBehavior.swift new file mode 100644 index 0000000000..23d1c35e1c --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/ModelStorageBehavior.swift @@ -0,0 +1,71 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + + +protocol ModelStorageBehavior { + + /// Setup the model store with the given schema + func setUp(modelSchemas: [ModelSchema]) throws + + /// Apply any data migration logic for the given schemas in the underlying data store. + func applyModelMigrations(modelSchemas: [ModelSchema]) throws + + func save(_ model: M, + modelSchema: ModelSchema, + condition: QueryPredicate?, + eagerLoad: Bool, + completion: @escaping DataStoreCallback) + + func save(_ model: M, + condition: QueryPredicate?, + eagerLoad: Bool, + completion: @escaping DataStoreCallback) + + @available(*, deprecated, message: "Use delete(:modelSchema:withIdentifier:predicate:completion") + func delete(_ modelType: M.Type, + modelSchema: ModelSchema, + withId id: Model.Identifier, + condition: QueryPredicate?, + completion: @escaping DataStoreCallback) + + func delete(_ modelType: M.Type, + modelSchema: ModelSchema, + withIdentifier identifier: ModelIdentifierProtocol, + condition: QueryPredicate?, + completion: @escaping DataStoreCallback) + + func delete(_ modelType: M.Type, + modelSchema: ModelSchema, + filter: QueryPredicate, + completion: @escaping DataStoreCallback<[M]>) + + func query(_ modelType: M.Type, + predicate: QueryPredicate?, + sort: [QuerySortDescriptor]?, + paginationInput: QueryPaginationInput?, + eagerLoad: Bool, + completion: DataStoreCallback<[M]>) + + func query(_ modelType: M.Type, + modelSchema: ModelSchema, + predicate: QueryPredicate?, + sort: [QuerySortDescriptor]?, + paginationInput: QueryPaginationInput?, + eagerLoad: Bool, + completion: DataStoreCallback<[M]>) + +} + +protocol ModelStorageErrorBehavior { + func shouldIgnoreError(error: DataStoreError) -> Bool +} + +extension ModelStorageErrorBehavior { + func shouldIgnoreError(error: DataStoreError) -> Bool { + return false + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/Model+SQLite.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/Model+SQLite.swift new file mode 100644 index 0000000000..4667035cd6 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/Model+SQLite.swift @@ -0,0 +1,234 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import SQLite + +/// Extended types that conform to `Persistable` in order to provide conversion to SQLite's `Binding` +/// types. This is necessary so `Model` properties' map values to a SQLite compatible types. +extension Persistable { + + /// Convert the internal `Persistable` type to a `Binding` compatible value. + /// + /// Most often the types will be interchangeable. However, in some cases a custom + /// conversion might be necessary. + /// + /// - Note: a `preconditionFailure` might happen in case the value cannot be converted. + /// + /// - Returns: the value as `Binding` + internal func asBinding() -> Binding { + let value = self + let valueType = type(of: value) + do { + let binding = try SQLiteModelValueConverter.convertToTarget(from: value, + fieldType: .from(type: valueType)) + guard let validBinding = binding else { + return Fatal.preconditionFailure(""" + Converting \(String(describing: value)) of type \(String(describing: valueType)) + to a SQLite Binding returned a nil value. This is likely a bug in the + SQLiteModelValueConverter logic. + """) + } + return validBinding + } catch { + return Fatal.preconditionFailure(""" + Value \(String(describing: value)) of type \(String(describing: valueType)) + is not a SQLite Binding compatible type. Error: \(error.localizedDescription) + + \(AmplifyErrorMessages.shouldNotHappenReportBugToAWS()) + """) + } + } +} + +private let logger = Amplify.Logging.logger(forCategory: .dataStore) + +extension Model { + + /// Get the values of a `Model` for the fields relevant to a SQL query. The order of the + /// values follow the same order of the model's columns. + /// + /// Use the `fields` parameter to convert just a subset of fields. + /// + /// - Parameter fields: an optional subset of fields + /// - Returns: an array of SQLite's `Binding` compatible type + internal func sqlValues(for fields: [ModelField]? = nil, modelSchema: ModelSchema) -> [Binding?] { + let modelFields = fields ?? modelSchema.sortedFields + let values: [Binding?] = modelFields.map { field in + + var existingFieldOptionalValue: Any?? + + // self[field.name] subscript accessor or jsonValue() returns an Any??, we need to do a few things: + // - `guard` to make sure the field name exists on the model + // - `guard` to ensure the returned value isn't nil + // - Attempt to cast to Persistable to ensure the model value isn't incorrectly assigned to a type we + // can't handle + if field.name == ModelIdentifierFormat.Custom.sqlColumnName { + existingFieldOptionalValue = self.identifier(schema: modelSchema).stringValue + } else if let jsonModel = self as? JSONValueHolder { + existingFieldOptionalValue = jsonModel.jsonValue(for: field.name, modelSchema: modelSchema) + } else { + existingFieldOptionalValue = self[field.name] + // Additional attempt to get the internal data, like "_post" + // TODO: alternatively, check if association or not + if existingFieldOptionalValue == nil { + let internalFieldName = "_\(field.name)" + existingFieldOptionalValue = self[internalFieldName] + } + } + + guard let existingFieldValue = existingFieldOptionalValue else { + return nil + } + + guard let anyValue = existingFieldValue else { + return nil + } + + // At this point, we have a value: Any. However, remember that Any could itself be an optional, so we're + // not quite done yet. + let value: Any + // swiftlint:disable:next syntactic_sugar + if case Optional.some(let unwrappedValue) = anyValue { + value = unwrappedValue + } else { + return nil + } + + // Now `value` is still an Any, but we've assured ourselves that it's not an Optional, which means we can + // safely attempt a cast to Persistable below. + + // if value is an associated model, get its id + if field.isForeignKey, + case let .model(associatedModelName) = field.type, + let associatedModelSchema = ModelRegistry.modelSchema(from: associatedModelName) { + + // Check if it is a Model or json object. + if let associatedModelValue = value as? Model { + return associatedModelValue.identifier + } else if let associatedLazyModel = value as? (any _LazyReferenceValue) { + // The identifier (sometimes the FK), comes from the loaded model's identifier or + // from the not loaded identifier's stringValue (the value, or the formatted value for CPK) + switch associatedLazyModel._state { + case .notLoaded(let identifiers): + guard let identifiers = identifiers else { + return nil + } + return identifiers.stringValue + case .loaded(let model): + return model?.identifier + } + } else if let associatedModelJSON = value as? [String: JSONValue] { + return associatedPrimaryKeyValue(fromJSON: associatedModelJSON, + associatedModelSchema: associatedModelSchema) + } + } + + // otherwise, delegate to the value converter + do { + let binding = try SQLiteModelValueConverter.convertToTarget(from: value, fieldType: field.type) + return binding + } catch { + logger.warn(""" + Error converting \(modelSchema.name).\(field.name) to the proper SQLite Binding. + Root cause is: \(String(describing: error)) + """) + return nil + } + + } + + return values + } + + /// Given a serialized JSON model, returns the serialized value of its primary key. + /// The returned value is either the value of the field or the serialization of multiple values in case of a composite PK. + /// - Parameters: + /// - associatedModelJSON: model as JSON value + /// - associatedModelSchema: model's schema + /// - Returns: serialized value of the primary key + private func associatedPrimaryKeyValue(fromJSON associatedModelJSON: [String: JSONValue], + associatedModelSchema: ModelSchema) -> String { + let associatedModelPKFields: ModelIdentifierProtocol.Fields + + // get the associated model primary key fields + associatedModelPKFields = associatedModelSchema.primaryKey.fields.compactMap { + if case .string(let value) = associatedModelJSON[$0.name] { + return (name: $0.name, value: value) + } + return nil + } + // Since Flutter models internally use a general serialized model structure with but + // different model schemas, we always use an identifier with a ModelIdentifierFormat.Custom format + return ModelIdentifier + .make(fields: associatedModelPKFields).stringValue + } + +} + +extension Array where Element == ModelSchema { + + /// Sort the [ModelSchema] array based on the associations between them. + /// + /// The order the tables are created for each model depends on their relationships. + /// The tables for the models that own the `foreign key` of the relationship can only + /// be created *after* the other edge of the relationship is created. + /// + /// For example: + /// + /// ``` + /// Blog (1) - (n) Post (1) - (n) Comment + /// ``` + /// The `Comment` table can only be created after the `Post`, which can only be + /// created after `Blog`. Therefore: + /// + /// ``` + /// let modelSchemas = [Comment.schema, Post.schema, Blog.schema] + /// modelSchemas.sortedByDependencyOrder() == [Blog.schema, Post.schema, Comment.schema] + /// ``` + func sortByDependencyOrder() -> Self { + var sortedKeys: [String] = [] + var sortMap: [String: ModelSchema] = [:] + + func walkAssociatedModels(of schema: ModelSchema) { + if !sortedKeys.contains(schema.name) { + let associatedModelSchemas = schema.sortedFields + .filter { $0.isForeignKey } + .map { (schema) -> ModelSchema in + guard let associatedSchema = ModelRegistry.modelSchema(from: schema.requiredAssociatedModelName) + else { + return Fatal.preconditionFailure(""" + Could not retrieve schema for the model \(schema.requiredAssociatedModelName), verify that + datastore is initialized. + """) + } + return associatedSchema + } + associatedModelSchemas.forEach(walkAssociatedModels(of:)) + + let key = schema.name + sortedKeys.append(key) + sortMap[key] = schema + } + } + + let sortedStartList = sorted { $0.name < $1.name } + sortedStartList.forEach(walkAssociatedModels(of:)) + return sortedKeys.map { sortMap[$0]! } + } + + func hasAssociations() -> Bool { + contains { modelSchema in + modelSchema.hasAssociations + } + } +} + +extension ModelIdentifierFormat.Custom { + /// Name for composite identifier (multiple fields) + public static let sqlColumnName = "@@primaryKey" +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/ModelSchema+SQLite.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/ModelSchema+SQLite.swift new file mode 100644 index 0000000000..2c1d18d932 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/ModelSchema+SQLite.swift @@ -0,0 +1,204 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import SQLite + +extension String { + + /// Utility for wrapping the string in double quotes. + func quoted() -> String { + return "\"\(self)\"" + } +} + +/// SQLite supported data types. +enum SQLDataType: String { + case text + case integer + case real +} + +/// Protocol that adds SQL-specific behavior to `ModelKey` types. +protocol SQLColumn { + + /// The name of the field as a SQL column. + var sqlName: String { get } + + /// The underlying SQLite data type. + var sqlType: SQLDataType { get } + + /// Computed property that indicates if the field is a foreign key or not. + var isForeignKey: Bool { get } +} + +extension ModelPrimaryKey: SQLColumn { + /// Convenience method to convert a ModelPrimaryKey to an + /// ModelField to be used in a SQL query + var asField: ModelField { + ModelField(name: name, + type: .string, + isRequired: true, + attributes: [.primaryKey]) + } + + var sqlType: SQLDataType { + .text + } + + var isForeignKey: Bool { + false + } + + public var sqlName: String { + fields.count == 1 ? fields[0].name : ModelIdentifierFormat.Custom.sqlColumnName + } + + public var name: String { + sqlName + } + + public func columnName(forNamespace namespace: String? = nil) -> String { + if fields.count == 1, let field = fields.first { + return field.columnName(forNamespace: namespace) + } + + let columnName = ModelIdentifierFormat.Custom.sqlColumnName.quoted() + if let namespace = namespace { + return "\(namespace.quoted()).\(columnName)" + } + + return columnName + } +} + +extension ModelField: SQLColumn { + + var sqlName: String { + if case let .belongsTo(_, targetNames) = association { + return foreignKeySqlName(withAssociationTargets: targetNames) + } else if case let .hasOne(_, targetNames) = association { + return foreignKeySqlName(withAssociationTargets: targetNames) + } + return name + } + + var sqlType: SQLDataType { + switch type { + case .string, .enum, .date, .dateTime, .time, .model: + return .text + case .int, .bool, .timestamp: + return .integer + case .double: + return .real + default: + return .text + } + } + + var isForeignKey: Bool { + isAssociationOwner + } + + /// Default foreign value used to reference a model with a composite primary key. + /// It's only used for the local storage, the individual values will be sent to the cloud. + func foreignKeySqlName(withAssociationTargets targetNames: [String]) -> String { + // default name for legacy models without a target name + if targetNames.isEmpty { + return name + "Id" + + // association with a model with a single-field PK + } else if targetNames.count == 1, + let keyName = targetNames.first { + return keyName + } + // composite PK + return "@@\(name)ForeignKey" + } + + /// Get the name of the `ModelField` as a SQL column name. Columns can be optionally namespaced + /// and are always wrapped in quotes so reserved words are escaped. + /// + /// For instance, `columnName(forNamespace: "root")` on a field named "id" returns `"root"."id"` + /// + /// - Parameter namespace: the optional column namespace + /// - Returns: a valid (i.e. escaped) SQL column name + func columnName(forNamespace namespace: String? = nil) -> String { + var column = sqlName.quoted() + if let namespace = namespace { + column = namespace.quoted() + "." + column + } + return column + } + + /// Get the column alias of a `ModelField`. Aliases are useful for serialization of SQL query + /// results to `Model` properties. A column alias might also have an optional `namespace` that + /// allows nested properties to be represented by their full path. + /// + /// For instance, if a model named `Comment` has a reference to model named `Post` through the + /// `post` property, the nested `id` field could be aliased as `post.id`. + /// + /// - Parameter namespace: the optional alias namespace + /// - Returns: the column alias prefixed by `as`. Example: if the `Model` field is named "id" + /// the call `field.columnAlias(forNamespace: "post")` would return `as "post.id"`. + func columnAlias(forNamespace namespace: String? = nil) -> String { + var column = sqlName + if let namespace = namespace { + column = "\(namespace).\(column)" + } + return column.quoted() + } + +} + +extension ModelSchema { + + /// Filter the fields that represent actual columns on the `Model` SQL table. The definition of + /// a column is a field that either represents a scalar value (e.g. string, number, etc) or + /// the owner of a foreign key to another `Model`. Fields that reference the inverse side of + /// the relationship (i.e. the "one" side of a "one-to-many" relationship) are excluded. + var columns: [ModelField] { + let fields = sortedFields.filter { !$0.hasAssociation || $0.isForeignKey } + if primaryKey.isCompositeKey { + return [primaryKey.asField] + fields + } + return fields + } + + /// Filter the fields that represent foreign keys. + var foreignKeys: [ModelField] { + sortedFields.filter { $0.isForeignKey } + } + + /// Create SQLite indexes corresponding to secondary indexes in the model schema + func createIndexStatements() -> String { + // Store field names used to represent associations for a fast lookup + var associationsFields = Set() + for (_, field) in self.fields { + if field.isAssociationOwner, + let association = field.association, + case let .belongsTo(_, targetNames: targetNames) = association { + associationsFields.formUnion(targetNames) + } + } + + var statement = "" + for case let .index(fields, name) in indexes { + // don't create an index on fields used to represent associations + if !associationsFields.isDisjoint(with: fields) { + continue + } + statement += CreateIndexStatement( + modelSchema: self, + fields: fields, + indexName: name + ) + .stringValue + } + return statement + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/ModelValueConverter+SQLite.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/ModelValueConverter+SQLite.swift new file mode 100644 index 0000000000..f7ff503374 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/ModelValueConverter+SQLite.swift @@ -0,0 +1,103 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import SQLite + +internal extension Bool { + + var intValue: Int { + return self ? Int(1) : Int(0) + } + +} + +public struct SQLiteModelValueConverter: ModelValueConverter { + + public typealias SourceType = Any? + public typealias TargetType = Binding? + + // swiftlint:disable:next cyclomatic_complexity + public static func convertToTarget(from source: Any?, fieldType: ModelFieldType) throws -> Binding? { + guard let value = source else { + return nil + } + switch fieldType { + case .string: + return value as? String + case .int: + if let intValue = value as? Int { + return intValue + } + if let int64Value = value as? Int64 { + return int64Value + } + return nil + case .double: + return value as? Double + case .date, .dateTime, .time: + return (value as? TemporalSpec)?.iso8601String + case .timestamp: + return value as? Int + case .bool: + return (value as? Bool)?.intValue + case .enum: + return (value as? EnumPersistable)?.rawValue + case .model: + if let modelInstance = (value as? Model), + let modelSchema = ModelRegistry.modelSchema(from: modelInstance.modelName) { + return modelInstance.identifier(schema: modelSchema).stringValue + } + return nil + case .collection: + // collections are not converted to SQL Binding since they represent a model association + // and the foreign key lives on the other side of the association + return nil + case .embedded, .embeddedCollection: + if let encodable = value as? Encodable { + return try SQLiteModelValueConverter.toJSON(encodable) + } + return nil + } + } + + // swiftlint:disable:next cyclomatic_complexity + public static func convertToSource(from target: Binding?, fieldType: ModelFieldType) throws -> Any? { + guard let value = target else { + return nil + } + switch fieldType { + case .string, .date, .dateTime, .time: + return value as? String + case .int: + return value as? Int64 + case .double: + return value as? Double + case .timestamp: + return value as? Int64 + case .bool: + if let intValue = value as? Int64 { + return Bool.fromDatatypeValue(intValue) + } + return nil + case .enum: + return value as? String + case .embedded, .embeddedCollection: + if let stringValue = value as? String { + return try SQLiteModelValueConverter.fromJSON(stringValue) + } + return nil + // models and collections are handled at the SQL statement layer since they need custom logic + // from the SQL result. See Statement+Model.swift for details + case .model: + return nil + case .collection: + return nil + } + } + +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/QueryPaginationInput+SQLite.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/QueryPaginationInput+SQLite.swift new file mode 100644 index 0000000000..16bced37bf --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/QueryPaginationInput+SQLite.swift @@ -0,0 +1,16 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + + +extension QueryPaginationInput { + + var sqlStatement: String { + let offset = page * limit + return "limit \(limit) offset \(offset)" + } + +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/QueryPredicate+SQLite.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/QueryPredicate+SQLite.swift new file mode 100644 index 0000000000..13be639922 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/QueryPredicate+SQLite.swift @@ -0,0 +1,65 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import SQLite + +extension QueryOperator { + + func sqlOperation(column: String) -> String { + switch self { + case .notEqual(let value): + return value == nil ? "\(column) is not null" : "\(column) <> ?" + case .equals(let value): + return value == nil ? "\(column) is null" : "\(column) = ?" + case .lessOrEqual: + return "\(column) <= ?" + case .lessThan: + return "\(column) < ?" + case .greaterOrEqual: + return "\(column) >= ?" + case .greaterThan: + return "\(column) > ?" + case .between: + return "\(column) between ? and ?" + case .beginsWith: + return "instr(\(column), ?) = 1" + case .contains: + return "instr(\(column), ?) > 0" + case .notContains: + return "instr(\(column), ?) = 0" + } + } + + var bindings: [Binding?] { + switch self { + case let .between(start, end): + return [start.asBinding(), end.asBinding()] + case .notEqual(let value), .equals(let value): + return value == nil ? [] : [value?.asBinding()] + case .lessOrEqual(let value), + .lessThan(let value), + .greaterOrEqual(let value), + .greaterThan(let value): + return [value.asBinding()] + case .contains(let value), + .beginsWith(let value), + .notContains(let value): + return [value.asBinding()] + } + } +} + +extension QueryPredicate { + var isAll: Bool { + if let allPredicate = self as? QueryPredicateConstant, allPredicate == .all { + return true + } else { + return false + } + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/QuerySort+SQLite.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/QuerySort+SQLite.swift new file mode 100644 index 0000000000..7ff06c9c10 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/QuerySort+SQLite.swift @@ -0,0 +1,44 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import SQLite + +extension QuerySortBy { + + var fieldName: String { + switch self { + case .ascending(let key), .descending(let key): + return key.stringValue + } + } + + var fieldOrder: QuerySortOrder { + switch self { + case .ascending: + return QuerySortOrder.ascending + case .descending: + return QuerySortOrder.descending + } + } + + var sortDescriptor: QuerySortDescriptor { + switch self { + case .ascending(let key): + return .init(fieldName: key.stringValue, order: .ascending) + case .descending(let key): + return .init(fieldName: key.stringValue, order: .descending) + } + } +} + +extension QuerySortInput { + + func asSortDescriptors() -> [QuerySortDescriptor]? { + return inputs.map { QuerySortDescriptor(fieldName: $0.fieldName, order: $0.fieldOrder) } + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/QuerySortDescriptor.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/QuerySortDescriptor.swift new file mode 100644 index 0000000000..913e63545a --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/QuerySortDescriptor.swift @@ -0,0 +1,53 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Struct used by the Datastore plugin to decide on how to order a query. +public struct QuerySortDescriptor { + + /// String representation of the CodingKey value of the field to be sorted. + /// + /// fieldName for a field createdBy inside Post model can be retreived by `Post.keys.createdBy.stringValue`. + let fieldName: String + + /// Sorting order for the field + let order: QuerySortOrder + + public init(fieldName: String, order: QuerySortOrder) { + self.fieldName = fieldName + self.order = order + } +} + +public enum QuerySortOrder: String { + + case ascending = "asc" + + case descending = "desc" +} + +extension Array where Element == QuerySortDescriptor { + + /// Generates the sorting part of the sql statement. + /// + /// For a sort description of ascending on `Post.createdAt` will return the string `"\"root\".\"createdAt\" asc"` + /// where `root` is the namespace. + func sortStatement(namespace: String) -> String { + let sqlResult = map { Array.columnFor(field: $0.fieldName, + order: $0.order.rawValue, + namespace: namespace) } + return sqlResult.joined(separator: ", ") + } + + static func columnFor(field: String, + order: String, + namespace: String) -> String { + return namespace.quoted() + "." + field.quoted() + " " + order + + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/SQLStatement+AlterTable.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/SQLStatement+AlterTable.swift new file mode 100644 index 0000000000..dfd153515b --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/SQLStatement+AlterTable.swift @@ -0,0 +1,32 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import SQLite + +struct AlterTableStatement: SQLStatement { + var fromModelSchema: ModelSchema + var modelSchema: ModelSchema + + var stringValue: String { + return "ALTER TABLE \"\(fromModelSchema.name)\" RENAME TO \"\(modelSchema.name)\"" + } + + init(from fromModelSchema: ModelSchema, toModelSchema: ModelSchema) { + self.fromModelSchema = fromModelSchema + self.modelSchema = toModelSchema + } +} + +struct AlterTableAddColumnStatement: SQLStatement { + var modelSchema: ModelSchema + var field: ModelField + + var stringValue: String { + "ALTER TABLE \"\(modelSchema.name)\" ADD COLUMN \"\(field.sqlName)\" \"\(field.sqlType)\";" + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/SQLStatement+Condition.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/SQLStatement+Condition.swift new file mode 100644 index 0000000000..3cce4160ae --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/SQLStatement+Condition.swift @@ -0,0 +1,102 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import SQLite + +typealias SQLPredicate = (String, [Binding?]) + +/// Utility function that translates a `QueryPredicate` to well-formatted SQL conditions. +/// It walks all nodes of a predicate tree and output the appropriate SQL statement. +/// +/// - Parameters: +/// - modelSchema: the model schema of the `Model` +/// - predicate: the query predicate +/// - Returns: a tuple containing the SQL string and the associated values +private func translateQueryPredicate(from modelSchema: ModelSchema, + predicate: QueryPredicate, + namespace: Substring? = nil) -> SQLPredicate { + var sql: [String] = [] + var bindings: [Binding?] = [] + let indentPrefix = " " + var indentSize = 1 + + func translate(_ pred: QueryPredicate, predicateIndex: Int, groupType: QueryPredicateGroupType) { + let indent = String(repeating: indentPrefix, count: indentSize) + if let operation = pred as? QueryPredicateOperation { + let column = resolveColumn(operation) + if predicateIndex == 0 { + sql.append("\(indent)\(operation.operator.sqlOperation(column: column))") + } else { + sql.append("\(indent)\(groupType.rawValue) \(operation.operator.sqlOperation(column: column))") + } + + bindings.append(contentsOf: operation.operator.bindings) + } else if let group = pred as? QueryPredicateGroup { + var shouldClose = false + + if predicateIndex == 0 { + sql.append("\(indent)(") + } else { + sql.append("\(indent)\(groupType.rawValue) (") + } + + indentSize += 1 + shouldClose = true + + for index in 0 ..< group.predicates.count { + translate(group.predicates[index], predicateIndex: index, groupType: group.type) + } + + if shouldClose { + indentSize -= 1 + sql.append("\(indent))") + } + } else if let constant = pred as? QueryPredicateConstant { + if case .all = constant { + sql.append("or 1 = 1") + } + } + } + + func resolveColumn(_ operation: QueryPredicateOperation) -> String { + let modelField = modelSchema.field(withName: operation.field) + if let namespace = namespace, let modelField = modelField { + return modelField.columnName(forNamespace: String(namespace)) + } else if let modelField = modelField { + return modelField.columnName() + } else if let namespace = namespace { + return String(namespace).quoted() + "." + operation.field.quoted() + } + return operation.field.quoted() + } + + // the very first `and` is always prepended, using -1 for if statement checking + // the very first `and` is to connect `where` clause with translated QueryPredicate + translate(predicate, predicateIndex: -1, groupType: .and) + return (sql.joined(separator: "\n"), bindings) +} + +/// Represents a partial SQL statement with query conditions. This type can be used to +/// compose `insert`, `update`, `delete` and `select` statements with conditions. +struct ConditionStatement: SQLStatement { + + let modelSchema: ModelSchema + let stringValue: String + let variables: [Binding?] + + init(modelSchema: ModelSchema, predicate: QueryPredicate, namespace: Substring? = nil) { + self.modelSchema = modelSchema + + let (sql, variables) = translateQueryPredicate(from: modelSchema, + predicate: predicate, + namespace: namespace) + self.stringValue = sql + self.variables = variables + } + +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/SQLStatement+CreateIndex.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/SQLStatement+CreateIndex.swift new file mode 100644 index 0000000000..4f2dccf237 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/SQLStatement+CreateIndex.swift @@ -0,0 +1,32 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Represents a `create index` SQL statement. The index is created based on the +/// secondary index present in the `ModelSchema` +struct CreateIndexStatement: SQLStatement { + var modelSchema: ModelSchema + + // fields/column names which are used to create the index in SQLite table + var fields: [ModelFieldName] + + // name of the secondary index + var indexName: String + + init(modelSchema: ModelSchema, fields: [ModelFieldName], indexName: String?) { + self.modelSchema = modelSchema + self.fields = fields + self.indexName = indexName ?? fields.joined(separator: "_") + "_pk" + } + + var stringValue: String { + return """ + create index if not exists \"\(indexName)\" on \"\(modelSchema.name)\" (\(fields.map { "\"\($0)\"" }.joined(separator: ", "))); + """ + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/SQLStatement+CreateTable.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/SQLStatement+CreateTable.swift new file mode 100644 index 0000000000..39a07aac83 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/SQLStatement+CreateTable.swift @@ -0,0 +1,72 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Represents a `create table` SQL statement. The table is created based on the `ModelSchema` +struct CreateTableStatement: SQLStatement { + + let modelSchema: ModelSchema + + init(modelSchema: ModelSchema) { + self.modelSchema = modelSchema + } + + var stringValue: String { + let name = modelSchema.name + var statement = #"create table if not exists "\#(name)" (\#n"# + + let primaryKey = modelSchema.primaryKey + let columns = modelSchema.columns + let foreignKeys = modelSchema.foreignKeys + + for (index, column) in columns.enumerated() { + statement += " \"\(column.sqlName)\" \(column.sqlType.rawValue)" + if column.name == primaryKey.name { + statement += " primary key" + } + + if column.isRequired { + statement += " not null" + } + if column.isOneToOne && column.isForeignKey { + statement += " unique" + } + + let isNotLastColumn = index < columns.endIndex - 1 + if isNotLastColumn { + statement += ",\n" + } + } + + let hasForeignKeys = !foreignKeys.isEmpty + if hasForeignKeys { + statement += ",\n" + } + + for (index, foreignKey) in foreignKeys.enumerated() { + statement += " foreign key(\"\(foreignKey.sqlName)\") " + let associatedModel = foreignKey.requiredAssociatedModelName + guard let schema = ModelRegistry.modelSchema(from: associatedModel) else { + return Fatal.preconditionFailure(""" + Could not retrieve schema for the model \(associatedModel), verify that datastore is initialized. + """) + } + let associatedId = schema.primaryKey + let associatedModelName = schema.name + statement += "references \"\(associatedModelName)\"(\"\(associatedId.sqlName)\")\n" + statement += " on delete cascade" + let isNotLastKey = index < foreignKeys.endIndex - 1 + if isNotLastKey { + statement += "\n" + } + } + + statement += "\n);" + return statement + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/SQLStatement+Delete.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/SQLStatement+Delete.swift new file mode 100644 index 0000000000..0000d356a2 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/SQLStatement+Delete.swift @@ -0,0 +1,76 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import SQLite + +/// Represents a `delete` SQL statement that is optionally composed by a `ConditionStatement`. +struct DeleteStatement: SQLStatement { + + let modelSchema: ModelSchema + let conditionStatement: ConditionStatement? + let namespace = "root" + + init(modelSchema: ModelSchema, predicate: QueryPredicate? = nil) { + self.modelSchema = modelSchema + + var conditionStatement: ConditionStatement? + if let predicate = predicate { + let statement = ConditionStatement(modelSchema: modelSchema, + predicate: predicate, + namespace: namespace[...]) + conditionStatement = statement + } + self.conditionStatement = conditionStatement + } + + init(_: M.Type, + modelSchema: ModelSchema, + withId id: String, + predicate: QueryPredicate? = nil) { + let identifier = DefaultModelIdentifier.makeDefault(id: id) + self.init(modelSchema: modelSchema, + withIdentifier: identifier, + predicate: predicate) + } + + init(modelSchema: ModelSchema, + withIdentifier id: ModelIdentifierProtocol, + predicate: QueryPredicate? = nil) { + var queryPredicate: QueryPredicate = field(modelSchema.primaryKey.sqlName) + .eq(id.stringValue) + if let predicate = predicate { + queryPredicate = field(modelSchema.primaryKey.sqlName) + .eq(id.stringValue).and(predicate) + } + self.init(modelSchema: modelSchema, predicate: queryPredicate) + } + + init(model: Model) { + self.init(modelSchema: model.schema) + } + + var stringValue: String { + let sql = """ + delete from "\(modelSchema.name)" as \(namespace) + """ + + if let conditionStatement = conditionStatement { + return """ + \(sql) + where 1 = 1 + \(conditionStatement.stringValue) + """ + } + return sql + } + + var variables: [Binding?] { + return conditionStatement?.variables ?? [] + } + +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/SQLStatement+DropTable.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/SQLStatement+DropTable.swift new file mode 100644 index 0000000000..1d82dedc7f --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/SQLStatement+DropTable.swift @@ -0,0 +1,22 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import SQLite + +/// Represents a `drop table` SQL statement +struct DropTableStatement: SQLStatement { + var modelSchema: ModelSchema + + var stringValue: String { + return "DROP TABLE IF EXISTS \"\(modelSchema.name)\"" + } + + init(modelSchema: ModelSchema) { + self.modelSchema = modelSchema + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/SQLStatement+Insert.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/SQLStatement+Insert.swift new file mode 100644 index 0000000000..8d830b3707 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/SQLStatement+Insert.swift @@ -0,0 +1,32 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import SQLite + +/// Represents a `insert` SQL statement associated with a `Model` instance. +struct InsertStatement: SQLStatement { + let modelSchema: ModelSchema + let variables: [Binding?] + + init(model: Model, modelSchema: ModelSchema) { + self.modelSchema = modelSchema + self.variables = model.sqlValues(for: modelSchema.columns, modelSchema: modelSchema) + } + + var stringValue: String { + let fields = modelSchema.columns + let columns = fields.map { $0.columnName() } + var statement = "insert into \"\(modelSchema.name)\" " + statement += "(\(columns.joined(separator: ", ")))\n" + + let variablePlaceholders = Array(repeating: "?", count: columns.count).joined(separator: ", ") + statement += "values (\(variablePlaceholders))" + + return statement + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/SQLStatement+Select.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/SQLStatement+Select.swift new file mode 100644 index 0000000000..c498f93d46 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/SQLStatement+Select.swift @@ -0,0 +1,184 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import SQLite + +/// Support data structure used to hold information about `SelectStatement` than +/// can be used later to parse the results. +struct SelectStatementMetadata { + + typealias ColumnMapping = [String: (ModelSchema, ModelField)] + + let statement: String + let columnMapping: ColumnMapping + let bindings: [Binding?] + + static func metadata(from modelSchema: ModelSchema, + predicate: QueryPredicate? = nil, + sort: [QuerySortDescriptor]? = nil, + paginationInput: QueryPaginationInput? = nil, + eagerLoad: Bool = true) -> SelectStatementMetadata { + let rootNamespace = "root" + let fields = modelSchema.columns + let tableName = modelSchema.name + var columnMapping: ColumnMapping = [:] + var columns = fields.map { field -> String in + columnMapping.updateValue((modelSchema, field), forKey: field.name) + return field.columnName(forNamespace: rootNamespace) + " as " + field.columnAlias() + } + + // eager load many-to-one/one-to-one relationships + let joinStatements = joins(from: modelSchema, eagerLoad: eagerLoad) + columns += joinStatements.columns + columnMapping.merge(joinStatements.columnMapping) { _, new in new } + + var sql = """ + select + \(joinedAsSelectedColumns(columns)) + from "\(tableName)" as "\(rootNamespace)" + \(joinStatements.statements.joined(separator: "\n")) + """.trimmingCharacters(in: .whitespacesAndNewlines) + + var bindings: [Binding?] = [] + if let predicate = predicate { + let conditionStatement = ConditionStatement(modelSchema: modelSchema, + predicate: predicate, + namespace: rootNamespace[...]) + bindings.append(contentsOf: conditionStatement.variables) + sql = """ + \(sql) + where 1 = 1 + \(conditionStatement.stringValue) + """ + } + + if let sort = sort, !sort.isEmpty { + sql = """ + \(sql) + order by \(sort.sortStatement(namespace: rootNamespace)) + """ + } + + if let paginationInput = paginationInput { + sql = """ + \(sql) + \(paginationInput.sqlStatement) + """ + } + + return SelectStatementMetadata(statement: sql, + columnMapping: columnMapping, + bindings: bindings) + } + + struct JoinStatement { + let columns: [String] + let statements: [String] + let columnMapping: ColumnMapping + } + + /// Walk through the associations recursively to generate join statements. + /// + /// Implementation note: this should be revisited once we define support + /// for explicit `eager` vs `lazy` associations. + private static func joins(from schema: ModelSchema, eagerLoad: Bool = true) -> JoinStatement { + var columns: [String] = [] + var joinStatements: [String] = [] + var columnMapping: ColumnMapping = [:] + guard eagerLoad == true else { + return JoinStatement(columns: columns, + statements: joinStatements, + columnMapping: columnMapping) + } + + func visitAssociations(node: ModelSchema, namespace: String = "root") { + for foreignKey in node.foreignKeys { + let associatedModelName = foreignKey.requiredAssociatedModelName + + guard let associatedSchema = ModelRegistry.modelSchema(from: associatedModelName) else { + return Fatal.preconditionFailure(""" + Could not retrieve schema for the model \(associatedModelName), verify that datastore is + initialized. + """) + } + let associatedTableName = associatedSchema.name + + // columns + let alias = namespace == "root" ? foreignKey.name : "\(namespace).\(foreignKey.name)" + let associatedColumn = associatedSchema.primaryKey.columnName(forNamespace: alias) + let foreignKeyName = foreignKey.columnName(forNamespace: namespace) + + // append columns from relationships + columns += associatedSchema.columns.map { field -> String in + columnMapping.updateValue((associatedSchema, field), forKey: "\(alias).\(field.name)") + return field.columnName(forNamespace: alias) + " as " + field.columnAlias(forNamespace: alias) + } + + let joinType = foreignKey.isRequired ? "inner" : "left outer" + + joinStatements.append(""" + \(joinType) join \"\(associatedTableName)\" as "\(alias)" + on \(associatedColumn) = \(foreignKeyName) + """) + visitAssociations(node: associatedSchema, namespace: alias) + } + } + visitAssociations(node: schema) + + return JoinStatement(columns: columns, + statements: joinStatements, + columnMapping: columnMapping) + } + +} + +/// Represents a `select` SQL statement associated with a `Model` instance and +/// optionally composed by a `ConditionStatement`. +struct SelectStatement: SQLStatement { + + let modelSchema: ModelSchema + let metadata: SelectStatementMetadata + + init(from modelSchema: ModelSchema, + predicate: QueryPredicate? = nil, + sort: [QuerySortDescriptor]? = nil, + paginationInput: QueryPaginationInput? = nil, + eagerLoad: Bool = true) { + self.modelSchema = modelSchema + self.metadata = .metadata(from: modelSchema, + predicate: predicate, + sort: sort, + paginationInput: paginationInput, + eagerLoad: eagerLoad) + } + + var stringValue: String { + metadata.statement + } + + var variables: [Binding?] { + metadata.bindings + } + +} + +// MARK: - Helpers + +/// Join a list of table columns joined and formatted for readability. +/// +/// - Parameter columns the list of column names +/// - Parameter perLine max numbers of columns per line +/// - Returns: a list of columns that can be used in `select` SQL statements +internal func joinedAsSelectedColumns(_ columns: [String], perLine: Int = 3) -> String { + return columns.enumerated().reduce("") { partial, entry in + let spacer = entry.offset == 0 || entry.offset % perLine == 0 ? "\n " : " " + let isFirstOrLast = entry.offset == 0 || entry.offset >= columns.count + let separator = isFirstOrLast ? "" : ",\(spacer)" + return partial + separator + entry.element + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/SQLStatement+TableInfoStatement.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/SQLStatement+TableInfoStatement.swift new file mode 100644 index 0000000000..9fd0eec40a --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/SQLStatement+TableInfoStatement.swift @@ -0,0 +1,17 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import SQLite + +struct TableInfoStatement: SQLStatement { + let modelSchema: ModelSchema + + var stringValue: String { + return "PRAGMA table_info(\"\(modelSchema.name)\");" + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/SQLStatement+Update.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/SQLStatement+Update.swift new file mode 100644 index 0000000000..ee2cac48c1 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/SQLStatement+Update.swift @@ -0,0 +1,69 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import SQLite + +/// Represents a `update` SQL statement. +struct UpdateStatement: SQLStatement { + + let modelSchema: ModelSchema + let conditionStatement: ConditionStatement? + + private let model: Model + + init(model: Model, modelSchema: ModelSchema, condition: QueryPredicate? = nil) { + self.model = model + self.modelSchema = modelSchema + + var conditionStatement: ConditionStatement? + if let condition = condition { + let statement = ConditionStatement(modelSchema: modelSchema, + predicate: condition) + conditionStatement = statement + } + + self.conditionStatement = conditionStatement + } + + var stringValue: String { + let columns = updateColumns.map { $0.columnName() } + + let columnsStatement = columns.map { column in + " \(column) = ?" + } + + var sql = """ + update "\(modelSchema.name)" + set + \(columnsStatement.joined(separator: ",\n")) + where \(modelSchema.primaryKey.columnName()) = ? + """ + + if let conditionStatement = conditionStatement { + sql = """ + \(sql) + \(conditionStatement.stringValue) + """ + } + + return sql + } + + var variables: [Binding?] { + var bindings = model.sqlValues(for: updateColumns, modelSchema: modelSchema) + bindings.append(model.identifier(schema: modelSchema).stringValue) + if let conditionStatement = conditionStatement { + bindings.append(contentsOf: conditionStatement.variables) + } + return bindings + } + + private var updateColumns: [ModelField] { + modelSchema.columns.filter { !$0.isPrimaryKey } + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/SQLStatement.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/SQLStatement.swift new file mode 100644 index 0000000000..0181334ca0 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/SQLStatement.swift @@ -0,0 +1,35 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import SQLite + +/// A sub-protocol of `DataStoreStatement` that represents a SQL statement. +/// +/// SQL statements include: `create table`, `insert`, `update`, `delete` and `select`. +protocol SQLStatement: DataStoreStatement where Variables == [Binding?] {} + +/// An useful extension to add a default empty array to `SQLStatement.variables` to +/// concrete types conforming to `SQLStatement`. +extension SQLStatement { + + var variables: [Binding?] { + return [] + } +} + +extension SQLStatement { + + // `modelType` is deprecated, instead modelSchema should be used. + var modelType: Model.Type { + fatalError(""" + DataStoreStatement.modelType is deprecated. SQLStatement is an internal type and there should be \ + no references to modelType. If you encounter this error, please open a GitHub issue. \ + https://github.com/aws-amplify/amplify-ios/issues/new/choose + """) + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/Statement+AnyModel.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/Statement+AnyModel.swift new file mode 100644 index 0000000000..96d170f3d9 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/Statement+AnyModel.swift @@ -0,0 +1,52 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import SQLite +import Foundation + +extension Statement { + func convertToUntypedModel(using modelSchema: ModelSchema, + statement: SelectStatement, + eagerLoad: Bool) throws -> [Model] { + var models = [Model]() + + for row in self { + let modelValues = try convert(row: row, + withSchema: modelSchema, + using: statement, + eagerLoad: eagerLoad) + + let untypedModel = try modelValues.map { + try convertToAnyModel(using: modelSchema, modelDictionary: $0) + } + + if let untypedModel = untypedModel { + models.append(untypedModel) + } + } + + return models + } + + private func convertToAnyModel(using modelSchema: ModelSchema, + modelDictionary: ModelValues) throws -> Model { + let data = try JSONSerialization.data(withJSONObject: modelDictionary) + guard let jsonString = String(data: data, encoding: .utf8) else { + let error = DataStoreError.decodingError( + "Could not create JSON string from model data", + """ + The values below could not be serialized into a String. Ensure the data below contains no invalid UTF8: + \(modelDictionary) + """ + ) + throw error + } + + let instance = try ModelRegistry.decode(modelName: modelSchema.name, from: jsonString) + return instance + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/Statement+Model.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/Statement+Model.swift new file mode 100644 index 0000000000..c89e770f5e --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/Statement+Model.swift @@ -0,0 +1,330 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import SQLite + +typealias ModelValues = [String: Any?] + +/// Struct used to hold the values extracted from a executed `Statement`. +/// +/// This type allows the results to be decoded into the actual models with a single call +/// instead of decoding each row individually. This keeps serialization of +/// large result sets efficient. +struct StatementResult: Decodable { + + let elements: [M] + + static func from(dictionary: ModelValues) throws -> Self { + let data = try JSONSerialization.data(withJSONObject: dictionary) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = ModelDateFormatting.decodingStrategy + return try decoder.decode(Self.self, from: data) + } +} + +/// Conforming to this protocol means that the `Statement` can be converted to an array of `Model`. +protocol StatementModelConvertible { + + /// Converts all the rows in the current executed `Statement` to the given `Model` type. + /// + /// - Parameters: + /// - modelType - the target `Model` type + /// - modelSchema - the schema for `Model` + /// - statement - the query executed that generated this result + /// - Returns: an array of `Model` of the specified type + func convert(to modelType: M.Type, + withSchema modelSchema: ModelSchema, + using statement: SelectStatement, + eagerLoad: Bool) throws -> [M] + +} + +/// Extend `Statement` with the model conversion capabilities defined by `StatementModelConvertible`. +extension Statement: StatementModelConvertible { + + var logger: Logger { + Amplify.Logging.logger(forCategory: .dataStore) + } + + func convert(to modelType: M.Type, + withSchema modelSchema: ModelSchema, + using statement: SelectStatement, + eagerLoad: Bool = true) throws -> [M] { + let elements: [ModelValues] = try self.convertToModelValues(to: modelType, + withSchema: modelSchema, + using: statement, + eagerLoad: eagerLoad) + let values: ModelValues = ["elements": elements] + let result: StatementResult = try StatementResult.from(dictionary: values) + return result.elements + } + + func convertToModelValues(to modelType: M.Type, + withSchema modelSchema: ModelSchema, + using statement: SelectStatement, + eagerLoad: Bool = true) throws -> [ModelValues] { + var elements: [ModelValues] = [] + + // parse each row of the result + let iter = makeIterator() + while let row = try iter.failableNext() { + if let modelDictionary = try convert(row: row, withSchema: modelSchema, using: statement, eagerLoad: eagerLoad) { + elements.append(modelDictionary) + } + } + return elements + } + + func convert( + row: Element, + withSchema modelSchema: ModelSchema, + using statement: SelectStatement, + eagerLoad: Bool = true, + path: [String] = [] + ) throws -> ModelValues? { + guard let maxDepth = columnNames.map({ $0.split(separator: ".").count }).max(), + path.count < maxDepth + else { + return nil + } + + let modelValues = try modelSchema.fields.mapValues { + try convert(field: $0, schema: modelSchema, using: statement, from: row, eagerLoad: eagerLoad, path: path) + } + + if modelValues.values.contains(where: { $0 != nil }) { + return modelValues.merging(schemeMetadata(schema: modelSchema, element: row, path: path)) { $1 } + } else { + return nil + } + } + + private func convert( + field: ModelField, + schema: ModelSchema, + using statement: SelectStatement, + from element: Element, + eagerLoad: Bool, + path: [String] + ) throws -> Any? { + + switch (field.type, eagerLoad) { + case (.collection, _): + return convertCollection(field: field, schema: schema, from: element, path: path) + + case (.model, false): + let targetNames = getTargetNames(field: field) + var associatedFieldValues = targetNames.map { + getValue(from: element, by: path + [$0]) + }.compactMap { $0 } + .map { String(describing: $0) } + + if associatedFieldValues.isEmpty { + associatedFieldValues = associatedValues( + from: path + [field.foreignKeySqlName(withAssociationTargets: targetNames)], + element: element + ) + } + guard associatedFieldValues.count == field.associatedFieldNames.count else { + return nil + } + return DataStoreModelDecoder.lazyInit(identifiers: zip(field.associatedFieldNames, associatedFieldValues).map { + LazyReferenceIdentifier(name: $0.0, value: $0.1) + })?.toJsonObject() + + case let (.model(modelName), true): + guard let modelSchema = getModelSchema(for: modelName, with: statement) + else { + return nil + } + + return try convert( + row: element, + withSchema: modelSchema, + using: statement, + eagerLoad: eagerLoad, + path: path + [field.name]) + + default: + let value = getValue(from: element, by: path + [field.name]) + return try SQLiteModelValueConverter.convertToSource(from: value, fieldType: field.type) + } + } + + private func getModelSchema(for modelName: ModelName, with statement: SelectStatement) -> ModelSchema? { + return statement.metadata.columnMapping.values.first { $0.0.name == modelName }.map { $0.0 } + } + + private func associatedValues(from foreignKeyPath: [String], element: Element) -> [String] { + return [getValue(from: element, by: foreignKeyPath)] + .compactMap({ $0 }) + .map({ String(describing: $0) }) + .flatMap({ $0.split(separator: ModelIdentifierFormat.Custom.separator.first!) }) + .map({ String($0).trimmingCharacters(in: .init(charactersIn: "\"")) }) + } + + private func convertCollection(field: ModelField, schema: ModelSchema, from element: Element, path: [String]) -> Any? { + if field.isArray && field.hasAssociation, + case let .some(.hasMany(associatedFieldName: associatedFieldName, associatedFieldNames: associatedFieldNames)) = field.association { + // Construct the lazy list based on the field reference name and `@@primarykey` or primary key field of the parent + if associatedFieldNames.count <= 1, let associatedFieldName = associatedFieldName { + let primaryKeyName = schema.primaryKey.isCompositeKey + ? ModelIdentifierFormat.Custom.sqlColumnName + : schema.primaryKey.fields.first.flatMap { $0.name } + let primaryKeyValue = primaryKeyName.flatMap { getValue(from: element, by: path + [$0]) } + + return primaryKeyValue.map { + DataStoreListDecoder.lazyInit(associatedIds: [String(describing: $0)], + associatedWith: [associatedFieldName]) + } + } else { + // If `targetNames` is > 1, then this is a uni-directional has-many, thus no reference field on the child + // Construct the lazy list based on the primary key values and the corresponding target names + let primaryKeyNames = schema.primaryKey.fields.map { $0.name } + let primaryKeyValues = primaryKeyNames + .map { getValue(from: element, by: path + [$0]) } + .compactMap { $0 } + .map { String(describing: $0) } + return DataStoreListDecoder.lazyInit(associatedIds: primaryKeyValues, + associatedWith: associatedFieldNames) + } + + } + + return nil + } + + private func getTargetNames(field: ModelField) -> [String] { + switch field.association { + case let .some(.hasOne(_, targetNames)): + return targetNames + case let .some(.belongsTo(_, targetNames)): + return targetNames + default: + return [] + } + } + + private func getValue(from element: Element, by path: [String]) -> Binding? { + return columnNames.firstIndex { $0 == path.fieldPath } + .flatMap { element[$0] } + } + + private func schemeMetadata(schema: ModelSchema, element: Element, path: [String]) -> ModelValues { + var metadata = [ + "__typename": schema.name + ] + + if schema.primaryKey.isCompositeKey, + let compositeKey = getValue(from: element, by: path + [ModelIdentifierFormat.Custom.sqlColumnName]) { + metadata.updateValue(String(describing: compositeKey), forKey: ModelIdentifierFormat.Custom.sqlColumnName) + } + + return metadata + } +} + +private extension Dictionary where Key == String, Value == Any? { + + /// Utility to create a `NSMutableDictionary` from a Swift `Dictionary`. + func mutableCopy() -> NSMutableDictionary { + // swiftlint:disable:next force_cast + return (self as NSDictionary).mutableCopy() as! NSMutableDictionary + } +} + +/// Extension that adds utilities to created nested values in a dictionary +/// from a `keyPath` notation (e.g. `root.with.some.nested.prop`. +private extension NSMutableDictionary { + + /// Utility to allows Swift standard types to be used in `setObject` + /// of the `NSMutableDictionary`. + /// + /// - Parameters: + /// - value: the value to be set + /// - key: the key as a `String` + func updateValue(_ value: Value?, forKey key: String) { + let object = value == nil ? NSNull() : value as Any + setObject(object, forKey: key as NSString) + } + + /// Utility that enables the automatic creation of nested dictionaries when + /// a `keyPath` is passed, even if no existing value is set in that `keyPath`. + /// + /// This function will auto-create nested structures and set the value accordingly. + /// + /// - Example + /// + /// ```swift + /// let dict = [:].mutableCopy() + /// dict.updateValue(1, "some.nested.value") + /// + /// // dict now is + /// [ + /// "some": [ + /// "nested": [ + /// "value": 1 + /// ] + /// ] + /// ] + /// ``` + /// + /// - Parameters: + /// - value: the value to be set + /// - keyPath: the key path as a `String` (e.g. "nested.key") + func updateValue(_ value: Value?, forKeyPath keyPath: String) { + if keyPath.firstIndex(of: ".") == nil { + updateValue(value, forKey: keyPath) + } + let keyComponents = keyPath.components(separatedBy: ".") + var current = self + for (index, key) in keyComponents.enumerated() { + let isLast = index == keyComponents.endIndex - 1 + if isLast { + current.updateValue(value, forKey: key) + } else if let nested = current[key] as? NSMutableDictionary { + current = nested + } else { + let nested: NSMutableDictionary = [:] + current.updateValue(nested, forKey: key) + current = nested + } + } + } + +} + +extension String { + /// Utility to return the second last keypath (if available) given a keypath. For example, + /// + /// - "post" returns the key path root "post" + /// - "post.id" returns "post", dropping the path "id" + /// - "post.blog.id" returns "post.blog", dropping the path "id" + /// + /// - Parameter keyPath: the key path as a `String` (e.g. "nested.key") + func dropLastPath() -> String { + if firstIndex(of: ".") == nil { + return self + } + + let keyComponents = components(separatedBy: ".") + let index = keyComponents.count - 2 + if index == 0 { + return keyComponents[index] + } else { + let subKeyComponents = keyComponents.dropLast() + return subKeyComponents.joined(separator: ".") + } + } +} + +extension Array where Element == String { + var fieldPath: String { + self.filter { !$0.isEmpty }.joined(separator: ".") + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/StorageEngineAdapter+SQLite.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/StorageEngineAdapter+SQLite.swift new file mode 100644 index 0000000000..47e7ea292c --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/StorageEngineAdapter+SQLite.swift @@ -0,0 +1,586 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import SQLite + +// swiftlint:disable type_body_length file_length +/// [SQLite](https://sqlite.org) `StorageEngineAdapter` implementation. This class provides +/// an integration layer between the AppSyncLocal `StorageEngine` and SQLite for local storage. +final class SQLiteStorageEngineAdapter: StorageEngineAdapter { + var connection: Connection? + var dbFilePath: URL? + static let dbVersionKey = "com.amazonaws.DataStore.dbVersion" + + // TODO benchmark whether a SELECT FROM FOO WHERE ID IN (1, 2, 3...) performs measurably + // better than SELECT FROM FOO WHERE ID = 1 OR ID=2 OR ID=3 + // + // SQLite supports up to 1000 expressions per SQLStatement. We have chosen to use 50 expressions + // less (equaling 950) than the maximum because it is possible that our SQLStatement already has + // some expressions. If we encounter performance problems in the future, we will want to profile + // our system and find an optimal value. + static var maxNumberOfPredicates: Int = 950 + + convenience init(version: String, + databaseName: String = "database", + userDefaults: UserDefaults = UserDefaults.standard) throws { + var dbFilePath = SQLiteStorageEngineAdapter.getDbFilePath(databaseName: databaseName) + _ = try SQLiteStorageEngineAdapter.clearIfNewVersion(version: version, dbFilePath: dbFilePath) + + let path = dbFilePath.absoluteString + + let connection: Connection + do { + connection = try Connection(path) + + var urlResourceValues = URLResourceValues() + urlResourceValues.isExcludedFromBackup = true + try dbFilePath.setResourceValues(urlResourceValues) + } catch { + throw DataStoreError.invalidDatabase(path: path, error) + } + + try self.init(connection: connection, + dbFilePath: dbFilePath, + userDefaults: userDefaults, + version: version) + + } + + internal init(connection: Connection, + dbFilePath: URL? = nil, + userDefaults: UserDefaults = UserDefaults.standard, + version: String = "version") throws { + self.connection = connection + self.dbFilePath = dbFilePath + try SQLiteStorageEngineAdapter.initializeDatabase(connection: connection) + log.verbose("Initialized \(connection)") + userDefaults.set(version, forKey: SQLiteStorageEngineAdapter.dbVersionKey) + } + + static func initializeDatabase(connection: Connection) throws { + log.debug("Initializing database connection: \(String(describing: connection))") + + if log.logLevel >= .verbose { + connection.trace { self.log.verbose($0) } + } + + let databaseInitializationStatement = """ + pragma auto_vacuum = full; + pragma encoding = "utf-8"; + pragma foreign_keys = on; + pragma case_sensitive_like = off; + """ + + try connection.execute(databaseInitializationStatement) + } + + static func getDbFilePath(databaseName: String) -> URL { + guard let documentsPath = getDocumentPath() else { + return Fatal.preconditionFailure("Could not create the database. The `.documentDirectory` is invalid") + } + return documentsPath.appendingPathComponent("\(databaseName).db") + } + + func setUp(modelSchemas: [ModelSchema]) throws { + guard let connection = connection else { + throw DataStoreError.invalidOperation(causedBy: nil) + } + + log.debug("Setting up \(modelSchemas.count) models") + + let createTableStatements = modelSchemas + .sortByDependencyOrder() + .map { CreateTableStatement(modelSchema: $0).stringValue } + .joined(separator: "\n") + + let createIndexStatements = modelSchemas + .sortByDependencyOrder() + .map { $0.createIndexStatements() } + .joined(separator: "\n") + + do { + try connection.execute(createTableStatements) + try connection.execute(createIndexStatements) + } catch { + throw DataStoreError.invalidOperation(causedBy: error) + } + + } + + func applyModelMigrations(modelSchemas: [ModelSchema]) throws { + let delegate = SQLiteMutationSyncMetadataMigrationDelegate( + storageAdapter: self, + modelSchemas: modelSchemas) + let mutationSyncMetadataMigration = MutationSyncMetadataMigration(delegate: delegate) + + let modelSyncMetadataMigration = ModelSyncMetadataMigration(storageAdapter: self) + + let modelMigrations = ModelMigrations(modelMigrations: [mutationSyncMetadataMigration, + modelSyncMetadataMigration]) + do { + try modelMigrations.apply() + } catch { + throw DataStoreError.invalidOperation(causedBy: error) + } + } + + // MARK: - Save + func save(_ model: M, + condition: QueryPredicate? = nil, + eagerLoad: Bool = true, + completion: @escaping DataStoreCallback) { + save(model, + modelSchema: model.schema, + condition: condition, + eagerLoad: eagerLoad, + completion: completion) + } + + func save(_ model: M, + modelSchema: ModelSchema, + condition: QueryPredicate? = nil, + eagerLoad: Bool = true, + completion: DataStoreCallback) { + completion(save(model, + modelSchema: modelSchema, + condition: condition, + eagerLoad: eagerLoad)) + } + + func save(_ model: M, + modelSchema: ModelSchema, + condition: QueryPredicate? = nil, + eagerLoad: Bool = true) -> DataStoreResult { + guard let connection = connection else { + return .failure(DataStoreError.nilSQLiteConnection()) + } + do { + let modelType = type(of: model) + let modelIdentifier = model.identifier(schema: modelSchema) + let modelExists = try exists(modelSchema, withIdentifier: modelIdentifier) + + if !modelExists { + if let condition = condition, !condition.isAll { + let dataStoreError = DataStoreError.invalidCondition( + "Cannot apply a condition on model which does not exist.", + "Save the model instance without a condition first.") + return .failure(causedBy: dataStoreError) + } + + let statement = InsertStatement(model: model, modelSchema: modelSchema) + _ = try connection.prepare(statement.stringValue).run(statement.variables) + } + + if modelExists { + if let condition = condition, !condition.isAll { + let modelExistsWithCondition = try exists(modelSchema, + withIdentifier: modelIdentifier, + predicate: condition) + if !modelExistsWithCondition { + let dataStoreError = DataStoreError.invalidCondition( + "Save failed due to condition did not match existing model instance.", + "The save will continue to fail until the model instance is updated.") + return .failure(causedBy: dataStoreError) + } + } + + let statement = UpdateStatement(model: model, + modelSchema: modelSchema, + condition: condition) + _ = try connection.prepare(statement.stringValue).run(statement.variables) + } + + // load the recent saved instance and pass it back to the callback + let queryResult = query(modelType, modelSchema: modelSchema, + predicate: model.identifier(schema: modelSchema).predicate, + eagerLoad: eagerLoad) + switch queryResult { + case .success(let result): + if let saved = result.first { + return .success(saved) + } else { + return .failure(.nonUniqueResult(model: modelType.modelName, + count: result.count)) + } + case .failure(let error): + return .failure(error) + } + } catch { + return .failure(causedBy: error) + } + } + + // MARK: - Delete + func delete(_ modelType: M.Type, + modelSchema: ModelSchema, + filter: QueryPredicate, + completion: (DataStoreResult<[M]>) -> Void) { + guard let connection = connection else { + completion(.failure(DataStoreError.nilSQLiteConnection())) + return + } + do { + let statement = DeleteStatement(modelSchema: modelSchema, predicate: filter) + _ = try connection.prepare(statement.stringValue).run(statement.variables) + completion(.success([])) + } catch { + completion(.failure(causedBy: error)) + } + } + + func delete(_ modelType: M.Type, + modelSchema: ModelSchema, + withId id: Model.Identifier, + condition: QueryPredicate? = nil, + completion: (DataStoreResult) -> Void) { + delete(untypedModelType: modelType, + modelSchema: modelSchema, + withId: id, + condition: condition) { result in + switch result { + case .success: + completion(.success(nil)) + case .failure(let error): + completion(.failure(error)) + } + } + } + + func delete(_ modelType: M.Type, + modelSchema: ModelSchema, + withIdentifier identifier: ModelIdentifierProtocol, + condition: QueryPredicate?, + completion: @escaping DataStoreCallback) where M: Model { + delete(untypedModelType: modelType, + modelSchema: modelSchema, + withIdentifier: identifier, + condition: condition) { result in + switch result { + case .success: + completion(.success(nil)) + case .failure(let error): + completion(.failure(error)) + } + } + } + + func delete(untypedModelType modelType: Model.Type, + modelSchema: ModelSchema, + withId id: Model.Identifier, + condition: QueryPredicate? = nil, + completion: DataStoreCallback) { + delete(untypedModelType: modelType, + modelSchema: modelSchema, + withIdentifier: DefaultModelIdentifier.makeDefault(id: id), + condition: condition, + completion: completion) + } + + func delete(untypedModelType modelType: Model.Type, + modelSchema: ModelSchema, + withIdentifier id: ModelIdentifierProtocol, + condition: QueryPredicate? = nil, + completion: DataStoreCallback) { + guard let connection = connection else { + completion(.failure(DataStoreError.nilSQLiteConnection())) + return + } + do { + let statement = DeleteStatement(modelSchema: modelSchema, + withIdentifier: id, + predicate: condition) + _ = try connection.prepare(statement.stringValue).run(statement.variables) + completion(.emptyResult) + } catch { + completion(.failure(causedBy: error)) + } + } + + // MARK: - query + func query(_ modelType: M.Type, + predicate: QueryPredicate? = nil, + sort: [QuerySortDescriptor]? = nil, + paginationInput: QueryPaginationInput? = nil, + eagerLoad: Bool = true, + completion: DataStoreCallback<[M]>) { + query(modelType, + modelSchema: modelType.schema, + predicate: predicate, + sort: sort, + paginationInput: paginationInput, + eagerLoad: eagerLoad, + completion: completion) + } + + func query(_ modelType: M.Type, + modelSchema: ModelSchema, + predicate: QueryPredicate? = nil, + sort: [QuerySortDescriptor]? = nil, + paginationInput: QueryPaginationInput? = nil, + eagerLoad: Bool = true, + completion: DataStoreCallback<[M]>) { + completion(query(modelType, + modelSchema: modelSchema, + predicate: predicate, + sort: sort, + paginationInput: paginationInput, + eagerLoad: eagerLoad)) + } + + private func query(_ modelType: M.Type, + modelSchema: ModelSchema, + predicate: QueryPredicate? = nil, + sort: [QuerySortDescriptor]? = nil, + paginationInput: QueryPaginationInput? = nil, + eagerLoad: Bool = true) -> DataStoreResult<[M]> { + guard let connection = connection else { + return .failure(DataStoreError.nilSQLiteConnection()) + } + do { + let statement = SelectStatement(from: modelSchema, + predicate: predicate, + sort: sort, + paginationInput: paginationInput, + eagerLoad: eagerLoad) + let rows = try connection.prepare(statement.stringValue).run(statement.variables) + let result: [M] = try rows.convert(to: modelType, + withSchema: modelSchema, + using: statement, + eagerLoad: eagerLoad) + return .success(result) + } catch { + return .failure(causedBy: error) + } + } + + // MARK: - Exists + func exists(_ modelSchema: ModelSchema, + withId id: String, + predicate: QueryPredicate? = nil) throws -> Bool { + try exists(modelSchema, + withIdentifier: DefaultModelIdentifier.makeDefault(id: id), + predicate: predicate) + } + + func exists(_ modelSchema: ModelSchema, + withIdentifier id: ModelIdentifierProtocol, + predicate: QueryPredicate? = nil) throws -> Bool { + guard let connection = connection else { + throw DataStoreError.nilSQLiteConnection() + } + let primaryKey = modelSchema.primaryKey.sqlName.quoted() + var sql = "select count(\(primaryKey)) from \"\(modelSchema.name)\" where \(primaryKey) = ?" + var variables: [Binding?] = [id.stringValue] + if let predicate = predicate { + let conditionStatement = ConditionStatement(modelSchema: modelSchema, predicate: predicate) + sql = """ + \(sql) + \(conditionStatement.stringValue) + """ + + variables.append(contentsOf: conditionStatement.variables) + } + + let result = try connection.scalar(sql, variables) + if let count = result as? Int64 { + if count > 1 { + throw DataStoreError.nonUniqueResult(model: modelSchema.name, count: Int(count)) + } + return count == 1 + } + return false + } + + func queryMutationSync(forAnyModel anyModel: AnyModel) throws -> MutationSync? { + let model = anyModel.instance + let results = try queryMutationSync(for: [model], modelName: anyModel.modelName) + return results.first + } + + func queryMutationSync(for models: [Model], modelName: String) throws -> [MutationSync] { + guard let connection = connection else { + throw DataStoreError.nilSQLiteConnection() + } + + guard let modelSchema = ModelRegistry.modelSchema(from: modelName) else { + throw DataStoreError.invalidModelName(modelName) + } + + let statement = SelectStatement(from: MutationSyncMetadata.schema) + let primaryKey = MutationSyncMetadata.schema.primaryKey.sqlName + // This is a temp workaround since we don't currently support the "in" operator + // in query predicates (this avoids the 1 + n query problem). Consider adding "in" support + let placeholders = Array(repeating: "?", count: models.count).joined(separator: ", ") + let sql = statement.stringValue + "\nwhere \(primaryKey) in (\(placeholders))" + + // group models by id for fast access when creating the tuple + let modelById = Dictionary(grouping: models, by: { + return MutationSyncMetadata.identifier(modelName: modelName, + modelId: $0.identifier(schema: modelSchema).stringValue) + }).mapValues { $0.first! } + let ids = [String](modelById.keys) + let rows = try connection.prepare(sql).bind(ids) + + let syncMetadataList = try rows.convert(to: MutationSyncMetadata.self, + withSchema: MutationSyncMetadata.schema, + using: statement) + let mutationSyncList = try syncMetadataList.map { syncMetadata -> MutationSync in + guard let model = modelById[syncMetadata.id] else { + throw DataStoreError.invalidOperation(causedBy: nil) + } + let anyModel = try model.eraseToAnyModel() + return MutationSync(model: anyModel, syncMetadata: syncMetadata) + } + return mutationSyncList + } + + func queryMutationSyncMetadata(for modelId: String, modelName: String) throws -> MutationSyncMetadata? { + let results = try queryMutationSyncMetadata(for: [modelId], modelName: modelName) + return try results.unique() + } + + func queryMutationSyncMetadata(for modelIds: [String], + modelName: String) throws -> [MutationSyncMetadata] { + guard let connection = connection else { + throw DataStoreError.nilSQLiteConnection() + } + let modelType = MutationSyncMetadata.self + let modelSchema = MutationSyncMetadata.schema + let fields = MutationSyncMetadata.keys + var results = [MutationSyncMetadata]() + let chunkedModelIdsArr = modelIds.chunked(into: SQLiteStorageEngineAdapter.maxNumberOfPredicates) + for chunkedModelIds in chunkedModelIdsArr { + var queryPredicates: [QueryPredicateOperation] = [] + for id in chunkedModelIds { + let mutationSyncMetadataId = MutationSyncMetadata.identifier(modelName: modelName, + modelId: id) + queryPredicates.append(QueryPredicateOperation(field: fields.id.stringValue, + operator: .equals(mutationSyncMetadataId))) + } + let groupedQueryPredicates = QueryPredicateGroup(type: .or, predicates: queryPredicates) + let statement = SelectStatement(from: modelSchema, predicate: groupedQueryPredicates) + let rows = try connection.prepare(statement.stringValue).run(statement.variables) + let result = try rows.convert(to: modelType, + withSchema: modelSchema, + using: statement) + results.append(contentsOf: result) + } + return results + } + + func queryModelSyncMetadata(for modelSchema: ModelSchema) throws -> ModelSyncMetadata? { + guard let connection = connection else { + throw DataStoreError.nilSQLiteConnection() + } + let statement = SelectStatement(from: ModelSyncMetadata.schema, + predicate: field("id").eq(modelSchema.name)) + let rows = try connection.prepare(statement.stringValue).run(statement.variables) + let result = try rows.convert(to: ModelSyncMetadata.self, + withSchema: ModelSyncMetadata.schema, + using: statement) + return try result.unique() + } + + func transaction(_ transactionBlock: BasicThrowableClosure) throws { + guard let connection = connection else { + throw DataStoreError.nilSQLiteConnection() + } + try connection.transaction { + try transactionBlock() + } + } + + func clear(completion: @escaping DataStoreCallback) { + guard let dbFilePath = dbFilePath else { + log.error("Attempt to clear DB, but file path was empty") + completion(.failure(causedBy: DataStoreError.invalidDatabase(path: "Database path not set", nil))) + return + } + connection = nil + let fileManager = FileManager.default + do { + try fileManager.removeItem(at: dbFilePath) + } catch { + log.error("Failed to delete database file located at: \(dbFilePath), error: \(error)") + completion(.failure(causedBy: DataStoreError.invalidDatabase(path: dbFilePath.absoluteString, error))) + } + completion(.successfulVoid) + } + + func shouldIgnoreError(error: DataStoreError) -> Bool { + if let sqliteError = SQLiteResultError(from: error), + case .constraintViolation = sqliteError { + return true + } + + return false + } + + static func clearIfNewVersion(version: String, + dbFilePath: URL, + userDefaults: UserDefaults = UserDefaults.standard, + fileManager: FileManager = FileManager.default) throws -> Bool { + + guard let previousVersion = userDefaults.string(forKey: dbVersionKey) else { + return false + } + + if previousVersion == version { + return false + } + + guard fileManager.fileExists(atPath: dbFilePath.path) else { + return false + } + + log.verbose("\(#function) Warning: Schema change detected, removing your previous database") + do { + try fileManager.removeItem(at: dbFilePath) + } catch { + log.error("\(#function) Failed to delete database file located at: \(dbFilePath), error: \(error)") + throw DataStoreError.invalidDatabase(path: dbFilePath.path, error) + } + return true + } +} + +// MARK: - Private Helpers + +/// Helper function that can be used as a shortcut to access the user's document +/// directory on the underlying OS. This is used to create the SQLite database file. +/// +/// - Returns: the path to the user document directory. +private func getDocumentPath() -> URL? { + return try? FileManager.default.url( + for: .documentDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) +} + +extension DataStoreError { + + static func nilSQLiteConnection() -> DataStoreError { + .internalOperation("SQLite connection is `nil`", + """ + This is expected if DataStore.clear is called while syncing as the SQLite connection is closed. + Call DataStore.start to restart the sync process. + """, nil) + } +} + +extension SQLiteStorageEngineAdapter: DefaultLogger { + public static var log: Logger { + Amplify.Logging.logger(forCategory: CategoryType.dataStore.displayName, forNamespace: String(describing: self)) + } + public var log: Logger { + Self.log + } +} +// swiftlint:enable type_body_length file_length diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/StorageEngineAdapter+UntypedModel.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/StorageEngineAdapter+UntypedModel.swift new file mode 100644 index 0000000000..7ea820d016 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/StorageEngineAdapter+UntypedModel.swift @@ -0,0 +1,88 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import SQLite + +extension SQLiteStorageEngineAdapter { + + func save(untypedModel: Model, + eagerLoad: Bool = true, + completion: DataStoreCallback) { + guard let connection = connection else { + completion(.failure(.nilSQLiteConnection())) + return + } + + do { + let modelName: ModelName + if let jsonModel = untypedModel as? JSONValueHolder, + let modelNameFromJson = jsonModel.jsonValue(for: "__typename") as? String { + modelName = modelNameFromJson + } else { + modelName = untypedModel.modelName + } + + guard let modelSchema = ModelRegistry.modelSchema(from: modelName) else { + let error = DataStoreError.invalidModelName(modelName) + throw error + } + + let shouldUpdate = try exists(modelSchema, + withIdentifier: untypedModel.identifier(schema: modelSchema)) + + if shouldUpdate { + let statement = UpdateStatement(model: untypedModel, modelSchema: modelSchema) + _ = try connection.prepare(statement.stringValue).run(statement.variables) + } else { + let statement = InsertStatement(model: untypedModel, modelSchema: modelSchema) + _ = try connection.prepare(statement.stringValue).run(statement.variables) + } + + query(modelSchema: modelSchema, + predicate: untypedModel.identifier(schema: modelSchema).predicate, + eagerLoad: eagerLoad) { + switch $0 { + case .success(let result): + if let saved = result.first { + completion(.success(saved)) + } else { + completion(.failure(.nonUniqueResult(model: modelSchema.name, + count: result.count))) + } + case .failure(let error): + completion(.failure(error)) + } + } + + } catch { + completion(.failure(causedBy: error)) + } + } + + func query(modelSchema: ModelSchema, + predicate: QueryPredicate? = nil, + eagerLoad: Bool = true, + completion: DataStoreCallback<[Model]>) { + guard let connection = connection else { + completion(.failure(.nilSQLiteConnection())) + return + } + do { + let statement = SelectStatement(from: modelSchema, + predicate: predicate, + eagerLoad: eagerLoad) + let rows = try connection.prepare(statement.stringValue).run(statement.variables) + let result: [Model] = try rows.convertToUntypedModel(using: modelSchema, + statement: statement, + eagerLoad: eagerLoad) + completion(.success(result)) + } catch { + completion(.failure(causedBy: error)) + } + } + +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/StorageEngineMigrationAdapter+SQLite.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/StorageEngineMigrationAdapter+SQLite.swift new file mode 100644 index 0000000000..08be435439 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/SQLite/StorageEngineMigrationAdapter+SQLite.swift @@ -0,0 +1,50 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import SQLite + +extension SQLiteStorageEngineAdapter { + + @discardableResult func createStore(for modelSchema: ModelSchema) throws -> String { + guard let connection = connection else { + throw DataStoreError.nilSQLiteConnection() + } + let createTableStatement = CreateTableStatement(modelSchema: modelSchema).stringValue + let createIndexStatement = modelSchema.createIndexStatements() + try connection.execute(createTableStatement) + try connection.execute(createIndexStatement) + return createTableStatement + } + + @discardableResult func removeStore(for modelSchema: ModelSchema) throws -> String { + guard let connection = connection else { + throw DataStoreError.nilSQLiteConnection() + } + let dropStatement = DropTableStatement(modelSchema: modelSchema).stringValue + try connection.execute(dropStatement) + return dropStatement + } + + @discardableResult func emptyStore(for modelSchema: ModelSchema) throws -> String { + guard let connection = connection else { + throw DataStoreError.nilSQLiteConnection() + } + let deleteStatement = DeleteStatement(modelSchema: modelSchema).stringValue + try connection.execute(deleteStatement) + return deleteStatement + } + + @discardableResult func renameStore(from: ModelSchema, toModelSchema: ModelSchema) throws -> String { + guard let connection = connection else { + throw DataStoreError.nilSQLiteConnection() + } + let alterTableStatement = AlterTableStatement(from: from, toModelSchema: toModelSchema).stringValue + try connection.execute(alterTableStatement) + return alterTableStatement + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/StorageEngine+SyncRequirement.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/StorageEngine+SyncRequirement.swift new file mode 100644 index 0000000000..516475b206 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/StorageEngine+SyncRequirement.swift @@ -0,0 +1,207 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Combine +import Foundation + +extension StorageEngine { + + func startSync() -> Result { + let (result, syncEngine) = initalizeSyncEngine() + + if let syncEngine = syncEngine, !syncEngine.isSyncing() { + guard let api = tryGetAPIPlugin() else { + log.info("Unable to find suitable API plugin for syncEngine. syncEngine will not be started") + return .failure(.configuration( + "Unable to find suitable API plugin for syncEngine. syncEngine will not be started", + "Ensure the API category has been setup and configured for your project", + nil + )) + } + + guard let apiGraphQL = api as? APICategoryGraphQLBehavior else { + log.info("Unable to find GraphQL API plugin for syncEngine. syncEngine will not be started") + return .failure(.configuration( + "Unable to find suitable GraphQL API plugin for syncEngine. syncEngine will not be started", + "Ensure the API category has been setup and configured for your project", + nil + )) + } + + let authPluginRequired = StorageEngine.requiresAuthPlugin( + api, + authModeStrategy: dataStoreConfiguration.authModeStrategyType + ) + guard authPluginRequired else { + syncEngine.start(api: apiGraphQL, auth: nil) + return .success(.successfullyInitialized) + } + + guard let auth = tryGetAuthPlugin() else { + log.warn("Unable to find suitable Auth plugin for syncEngine. Models require auth") + return .failure(.configuration( + "Unable to find suitable Auth plugin for syncEngine. Models require auth", + "Ensure the Auth category has been setup and configured for your project", + nil + )) + } + + syncEngine.start(api: apiGraphQL, auth: auth) + } + + return .success(result) + } + + private func initalizeSyncEngine() -> (SyncEngineInitResult, RemoteSyncEngineBehavior?) { + if let syncEngine = syncEngine { + return (.alreadyInitialized, syncEngine) + } else { + if isSyncEnabled, syncEngine == nil { + self.syncEngine = try? RemoteSyncEngine( + storageAdapter: storageAdapter, + dataStoreConfiguration: dataStoreConfiguration + ) + + self.syncEngineSink = syncEngine?.publisher.sink( + receiveCompletion: onReceiveCompletion(receiveCompletion:), + receiveValue: onReceive(receiveValue:) + ) + } + return (.successfullyInitialized, syncEngine) + } + } + + /// Expresses whether the `StorageEngine` syncs from a remote source + /// based on whether the `AWSAPIPlugin` is present. + var syncsFromRemote: Bool { + tryGetAPIPlugin() != nil + } + + private func tryGetAPIPlugin() -> APICategoryPlugin? { + do { + return try Amplify.API.getPlugin(for: validAPIPluginKey) + } catch { + return nil + } + } + + private func tryGetAuthPlugin() -> AuthCategoryBehavior? { + do { + return try Amplify.Auth.getPlugin(for: validAuthPluginKey) + } catch { + return nil + } + } + + static func requiresAuthPlugin( + _ apiPlugin: APICategoryPlugin, + authModeStrategy: AuthModeStrategyType + ) -> Bool { + let modelsRequireAuthPlugin = ModelRegistry.modelSchemas.contains { schema in + guard schema.isSyncable else { + return false + } + return requiresAuthPlugin(apiPlugin, + authRules: schema.authRules, + authModeStrategy: authModeStrategy) + } + + return modelsRequireAuthPlugin + } + + static func requiresAuthPlugin( + _ apiPlugin: APICategoryPlugin, + authRules: [AuthRule], + authModeStrategy: AuthModeStrategyType + ) -> Bool { + switch authModeStrategy { + case .default: + if authRules.isEmpty { + return false + } + // Only use the auth rule as determination for auth plugin requirement when there is + // exactly one. If there is more than one auth rule AND multi-auth is not enabled, + // then immediately fall back to using the default auth type configured on the APIPlugin because + // we do not have enough information to know which provider to use to make the determination. + if authRules.count == 1, + let singleAuthRule = authRules.first, + let ruleRequireAuthPlugin = singleAuthRule.requiresAuthPlugin { + return ruleRequireAuthPlugin + } + case .multiAuth: + if let rulesRequireAuthPlugin = authRules.requireAuthPlugin { + return rulesRequireAuthPlugin + } + } + + // Fall back to the endpoint's auth type if a determination cannot be made from the auth rules. This can + // occur for older generation of the auth rules which do not have provider information such as the initial + // single auth rule use cases. The auth type from the API is used to determine whether or not the auth + // plugin is required. + if let awsAPIAuthInfo = apiPlugin as? AWSAPIAuthInformation { + do { + return try awsAPIAuthInfo.defaultAuthType().requiresAuthPlugin + } catch { + log.error(error: error) + } + } + + log.warn(""" + Could not determine whether the auth plugin is required or not. The auth rules present + may be missing provider information. When this happens, the API Plugin is used to determine + whether the default auth type requires the auth plugin. The default auth type could not be determined. + """) + + // If both checks above cannot determine if auth plugin is required, fallback to previous logic + let apiAuthProvider = (apiPlugin as APICategoryAuthProviderFactoryBehavior).apiAuthProviderFactory() + if apiAuthProvider.oidcAuthProvider() != nil { + log.verbose("Found OIDC Auth Provider from the API Plugin.") + return false + } + + if apiAuthProvider.functionAuthProvider() != nil { + log.verbose("Found Function Auth Provider from the API Plugin.") + return false + } + + // There are auth rules and no ODIC/Function providers on the API plugin, then return true. + return true + } +} + +internal extension AuthRules { + /// Convenience method to check whether we need Auth plugin + /// - Returns: true If **any** of the rules uses a provider that requires the Auth plugin, `nil` otherwise + var requireAuthPlugin: Bool? { + for rule in self { + guard let requiresAuthPlugin = rule.requiresAuthPlugin else { + return nil + } + if requiresAuthPlugin { + return true + } + } + return false + } +} + +internal extension AuthRule { + var requiresAuthPlugin: Bool? { + guard let provider = self.provider else { + return nil + } + + switch provider { + // OIDC, Function and API key providers don't need + // Auth plugin + case .oidc, .function, .apiKey: + return false + case .userPools, .iam: + return true + } + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/StorageEngine.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/StorageEngine.swift new file mode 100644 index 0000000000..bd579c0489 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/StorageEngine.swift @@ -0,0 +1,420 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Combine +import Foundation + +typealias StorageEngineBehaviorFactory = + (Bool, + DataStoreConfiguration, + String, + String, + String, + UserDefaults) throws -> StorageEngineBehavior + +// swiftlint:disable type_body_length +final class StorageEngine: StorageEngineBehavior { + // TODO: Make this private once we get a mutation flow that passes the type of mutation as needed + let storageAdapter: StorageEngineAdapter + var syncEngine: RemoteSyncEngineBehavior? + let validAPIPluginKey: String + let validAuthPluginKey: String + var signInListener: UnsubscribeToken? + let isSyncEnabled: Bool + + let dataStoreConfiguration: DataStoreConfiguration + private let operationQueue: OperationQueue + + var iSyncEngineSink: Any? + var syncEngineSink: AnyCancellable? { + get { + if let iSyncEngineSink = iSyncEngineSink as? AnyCancellable { + return iSyncEngineSink + } + return nil + } + set { + iSyncEngineSink = newValue + } + } + + var iStorageEnginePublisher: Any? + var storageEnginePublisher: PassthroughSubject { + get { + if iStorageEnginePublisher == nil { + iStorageEnginePublisher = PassthroughSubject() + } + // swiftlint:disable:next force_cast + return iStorageEnginePublisher as! PassthroughSubject + } + set { + iStorageEnginePublisher = newValue + } + } + + var publisher: AnyPublisher { + return storageEnginePublisher.eraseToAnyPublisher() + } + + static var systemModelSchemas: [ModelSchema] { + return [ + ModelSyncMetadata.schema, + MutationEvent.schema, + MutationSyncMetadata.schema + ] + } + + // Internal initializer used for testing, to allow lazy initialization of the SyncEngine. Note that the provided + // storageAdapter must have already been set up with system models + init(storageAdapter: StorageEngineAdapter, + dataStoreConfiguration: DataStoreConfiguration, + syncEngine: RemoteSyncEngineBehavior?, + validAPIPluginKey: String, + validAuthPluginKey: String, + isSyncEnabled: Bool = false + ) { + self.storageAdapter = storageAdapter + self.dataStoreConfiguration = dataStoreConfiguration + self.syncEngine = syncEngine + self.validAPIPluginKey = validAPIPluginKey + self.validAuthPluginKey = validAuthPluginKey + self.isSyncEnabled = isSyncEnabled + + let operationQueue = OperationQueue() + operationQueue.name = "com.amazonaws.StorageEngine" + self.operationQueue = operationQueue + } + + convenience init(isSyncEnabled: Bool, + dataStoreConfiguration: DataStoreConfiguration, + validAPIPluginKey: String = "awsAPIPlugin", + validAuthPluginKey: String = "awsCognitoAuthPlugin", + modelRegistryVersion: String, + userDefault: UserDefaults = UserDefaults.standard) throws { + + let key = kCFBundleNameKey as String + let databaseName = Bundle.main.object(forInfoDictionaryKey: key) as? String ?? "app" + + let storageAdapter = try SQLiteStorageEngineAdapter(version: modelRegistryVersion, databaseName: databaseName) + + try storageAdapter.setUp(modelSchemas: StorageEngine.systemModelSchemas) + + self.init( + storageAdapter: storageAdapter, + dataStoreConfiguration: dataStoreConfiguration, + syncEngine: nil, + validAPIPluginKey: validAPIPluginKey, + validAuthPluginKey: validAuthPluginKey, + isSyncEnabled: isSyncEnabled + ) + self.storageEnginePublisher = PassthroughSubject() + } + + func onReceiveCompletion(receiveCompletion: Subscribers.Completion) { + switch receiveCompletion { + case .failure(let dataStoreError): + log.debug("RemoteSyncEngine publisher completed with error \(dataStoreError)") + case .finished: + log.debug("RemoteSyncEngine publisher completed successfully") + } + + stopSync { result in + switch result { + case .success: + self.log.info("Stopping DataStore successful.") + case .failure(let error): + self.log.error("Failed to stop StorageEngine with error: \(error)") + } + } + } + + func onReceive(receiveValue: RemoteSyncEngineEvent) { + switch receiveValue { + case .storageAdapterAvailable: + break + case .subscriptionsPaused: + break + case .mutationsPaused: + break + case .clearedStateOutgoingMutations: + break + case .subscriptionsInitialized: + break + case .performedInitialSync: + break + case .subscriptionsActivated: + break + case .mutationQueueStarted: + break + case .syncStarted: + break + case .cleanedUp: + break + case .cleanedUpForTermination: + break + case .mutationEvent(let mutationEvent): + storageEnginePublisher.send(.mutationEvent(mutationEvent)) + case .modelSyncedEvent(let modelSyncedEvent): + storageEnginePublisher.send(.modelSyncedEvent(modelSyncedEvent)) + case .syncQueriesReadyEvent: + storageEnginePublisher.send(.syncQueriesReadyEvent) + case .readyEvent: + storageEnginePublisher.send(.readyEvent) + case .schedulingRestart: + break + } + } + + func setUp(modelSchemas: [ModelSchema]) throws { + try storageAdapter.setUp(modelSchemas: modelSchemas) + } + + func applyModelMigrations(modelSchemas: [ModelSchema]) throws { + try storageAdapter.applyModelMigrations(modelSchemas: modelSchemas) + } + + public func save(_ model: M, + modelSchema: ModelSchema, + condition: QueryPredicate? = nil, + eagerLoad: Bool = true, + completion: @escaping DataStoreCallback) { + + // TODO: Refactor this into a proper request/result where the result includes metadata like the derived + // mutation type + let modelExists: Bool + do { + modelExists = try storageAdapter.exists(modelSchema, + withIdentifier: model.identifier(schema: modelSchema), + predicate: nil) + } catch { + let dataStoreError = DataStoreError.invalidOperation(causedBy: error) + completion(.failure(dataStoreError)) + return + } + + let mutationType = modelExists ? MutationEvent.MutationType.update : .create + + // If it is `create`, and there is a condition, and that condition is not `.all`, fail the request + if mutationType == .create, let condition = condition, !condition.isAll { + let dataStoreError = DataStoreError.invalidCondition( + "Cannot apply a condition on model which does not exist.", + "Save the model instance without a condition first.") + completion(.failure(causedBy: dataStoreError)) + return + } + + do { + try storageAdapter.transaction { + let result = self.storageAdapter.save(model, + modelSchema: modelSchema, + condition: condition, + eagerLoad: eagerLoad) + guard modelSchema.isSyncable else { + completion(result) + return + } + + guard case .success(let savedModel) = result else { + completion(result) + return + } + + guard let syncEngine else { + let message = "No SyncEngine available to sync mutation event, rollback save." + self.log.verbose("\(#function) \(message) : \(savedModel)") + throw DataStoreError.internalOperation( + message, + "`DataStore.save()` was interrupted. `DataStore.stop()` may have been called.", + nil) + } + self.log.verbose("\(#function) syncing mutation for \(savedModel)") + self.syncMutation(of: savedModel, + modelSchema: modelSchema, + mutationType: mutationType, + predicate: condition, + syncEngine: syncEngine, + completion: completion) + } + } catch { + completion(.failure(causedBy: error)) + } + } + + func save(_ model: M, + condition: QueryPredicate? = nil, + eagerLoad: Bool = true, + completion: @escaping DataStoreCallback) { + save(model, + modelSchema: model.schema, + condition: condition, + eagerLoad: eagerLoad, + completion: completion) + } + + @available(*, deprecated, message: "Use delete(:modelSchema:withIdentifier:predicate:completion") + func delete(_ modelType: M.Type, + modelSchema: ModelSchema, + withId id: Model.Identifier, + condition: QueryPredicate? = nil, + completion: @escaping (DataStoreResult) -> Void) { + let cascadeDeleteOperation = CascadeDeleteOperation(storageAdapter: storageAdapter, + syncEngine: syncEngine, + modelType: modelType, modelSchema: modelSchema, + withIdentifier: DefaultModelIdentifier.makeDefault(id: id), + condition: condition) { completion($0) } + operationQueue.addOperation(cascadeDeleteOperation) + } + + func delete(_ modelType: M.Type, + modelSchema: ModelSchema, + withIdentifier identifier: ModelIdentifierProtocol, + condition: QueryPredicate?, + completion: @escaping DataStoreCallback) { + let cascadeDeleteOperation = CascadeDeleteOperation(storageAdapter: storageAdapter, + syncEngine: syncEngine, + modelType: modelType, modelSchema: modelSchema, + withIdentifier: identifier, + condition: condition) { completion($0) } + operationQueue.addOperation(cascadeDeleteOperation) + + } + + func delete(_ modelType: M.Type, + modelSchema: ModelSchema, + filter: QueryPredicate, + completion: @escaping DataStoreCallback<[M]>) { + let cascadeDeleteOperation = CascadeDeleteOperation(storageAdapter: storageAdapter, + syncEngine: syncEngine, + modelType: modelType, + modelSchema: modelSchema, + filter: filter) { completion($0) } + operationQueue.addOperation(cascadeDeleteOperation) + } + + func query(_ modelType: M.Type, + modelSchema: ModelSchema, + predicate: QueryPredicate?, + sort: [QuerySortDescriptor]?, + paginationInput: QueryPaginationInput?, + eagerLoad: Bool = true, + completion: (DataStoreResult<[M]>) -> Void) { + return storageAdapter.query(modelType, + modelSchema: modelSchema, + predicate: predicate, + sort: sort, + paginationInput: paginationInput, + eagerLoad: eagerLoad, + completion: completion) + } + + func query(_ modelType: M.Type, + predicate: QueryPredicate? = nil, + sort: [QuerySortDescriptor]? = nil, + paginationInput: QueryPaginationInput? = nil, + eagerLoad: Bool = true, + completion: DataStoreCallback<[M]>) { + query(modelType, + modelSchema: modelType.schema, + predicate: predicate, + sort: sort, + paginationInput: paginationInput, + eagerLoad: eagerLoad, + completion: completion) + } + + func clear(completion: @escaping DataStoreCallback) { + if let syncEngine = syncEngine { + syncEngine.stop(completion: { _ in + self.syncEngine = nil + self.storageAdapter.clear(completion: completion) + }) + } else { + storageAdapter.clear(completion: completion) + } + } + + func stopSync(completion: @escaping DataStoreCallback) { + if let syncEngine = syncEngine { + syncEngine.stop { _ in + self.syncEngine = nil + completion(.successfulVoid) + } + } else { + completion(.successfulVoid) + } + } + + @available(iOS 13.0, *) + private func syncMutation(of savedModel: M, + modelSchema: ModelSchema, + mutationType: MutationEvent.MutationType, + predicate: QueryPredicate? = nil, + syncEngine: RemoteSyncEngineBehavior, + completion: @escaping DataStoreCallback) { + let mutationEvent: MutationEvent + do { + var graphQLFilterJSON: String? + if let predicate = predicate { + graphQLFilterJSON = try GraphQLFilterConverter.toJSON(predicate, + modelSchema: modelSchema) + } + + mutationEvent = try MutationEvent(model: savedModel, + modelSchema: modelSchema, + mutationType: mutationType, + graphQLFilterJSON: graphQLFilterJSON) + + } catch { + let dataStoreError = DataStoreError(error: error) + completion(.failure(dataStoreError)) + return + } + + let mutationEventCallback: DataStoreCallback = { result in + switch result { + case .failure(let dataStoreError): + completion(.failure(dataStoreError)) + case .success(let mutationEvent): + self.log.verbose("\(#function) successfully submitted \(mutationEvent.modelName) to sync engine \(mutationEvent)") + completion(.success(savedModel)) + } + } + + submitToSyncEngine(mutationEvent: mutationEvent, + syncEngine: syncEngine, + completion: mutationEventCallback) + } + + private func submitToSyncEngine(mutationEvent: MutationEvent, + syncEngine: RemoteSyncEngineBehavior, + completion: @escaping DataStoreCallback) { + Task { + syncEngine.submit(mutationEvent, completion: completion) + } + } + +} + +extension StorageEngine: Resettable { + func reset() async { + // TOOD: Perform cleanup on StorageAdapter, including releasing its `Connection` if needed + if let resettable = syncEngine as? Resettable { + log.verbose("Resetting syncEngine") + await resettable.reset() + self.log.verbose("Resetting syncEngine: finished") + } + } +} + +extension StorageEngine: DefaultLogger { + public static var log: Logger { + Amplify.Logging.logger(forCategory: CategoryType.dataStore.displayName, forNamespace: String(describing: self)) + } + public var log: Logger { + Self.log + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/StorageEngineAdapter.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/StorageEngineAdapter.swift new file mode 100644 index 0000000000..82b1cda766 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/StorageEngineAdapter.swift @@ -0,0 +1,88 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +protocol StorageEngineAdapter: AnyObject, ModelStorageBehavior, ModelStorageErrorBehavior, StorageEngineMigrationAdapter { + + static var maxNumberOfPredicates: Int { get } + + // MARK: - Async APIs + func save(untypedModel: Model, eagerLoad: Bool, completion: @escaping DataStoreCallback) + + func delete(untypedModelType modelType: Model.Type, + modelSchema: ModelSchema, + withIdentifier identifier: ModelIdentifierProtocol, + condition: QueryPredicate?, + completion: DataStoreCallback) + + func delete(_ modelType: M.Type, + modelSchema: ModelSchema, + filter: QueryPredicate, + completion: @escaping DataStoreCallback<[M]>) + + func query(modelSchema: ModelSchema, + predicate: QueryPredicate?, + eagerLoad: Bool, + completion: DataStoreCallback<[Model]>) + + // MARK: - Synchronous APIs + + func save(_ model: M, + modelSchema: ModelSchema, + condition: QueryPredicate?, + eagerLoad: Bool) -> DataStoreResult + + func exists(_ modelSchema: ModelSchema, + withIdentifier id: ModelIdentifierProtocol, + predicate: QueryPredicate?) throws -> Bool + + func queryMutationSync(for models: [Model], modelName: String) throws -> [MutationSync] + + func queryMutationSync(forAnyModel anyModel: AnyModel) throws -> MutationSync? + + func queryMutationSyncMetadata(for modelId: String, modelName: String) throws -> MutationSyncMetadata? + + func queryMutationSyncMetadata(for modelIds: [String], modelName: String) throws -> [MutationSyncMetadata] + + func queryModelSyncMetadata(for modelSchema: ModelSchema) throws -> ModelSyncMetadata? + + func transaction(_ basicClosure: BasicThrowableClosure) throws + + func clear(completion: @escaping DataStoreCallback) +} + +protocol StorageEngineMigrationAdapter { + + @discardableResult func removeStore(for modelSchema: ModelSchema) throws -> String + + @discardableResult func createStore(for modelSchema: ModelSchema) throws -> String + + @discardableResult func emptyStore(for modelSchema: ModelSchema) throws -> String + + @discardableResult func renameStore(from: ModelSchema, toModelSchema: ModelSchema) throws -> String +} + +extension StorageEngineAdapter { + + func delete(_ modelType: M.Type, + filter predicate: QueryPredicate, + completion: @escaping DataStoreCallback<[M]>) { + delete(modelType, modelSchema: modelType.schema, filter: predicate, completion: completion) + } + + func delete(untypedModelType modelType: Model.Type, + withIdentifier identifier: ModelIdentifierProtocol, + condition: QueryPredicate? = nil, + completion: DataStoreCallback) { + delete(untypedModelType: modelType, + modelSchema: modelType.schema, + withIdentifier: identifier, + condition: condition, + completion: completion) + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/StorageEngineBehavior.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/StorageEngineBehavior.swift new file mode 100644 index 0000000000..1797d18ef0 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/StorageEngineBehavior.swift @@ -0,0 +1,36 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import Combine + +enum StorageEngineEvent { + case started + case mutationEvent(MutationEvent) + case modelSyncedEvent(ModelSyncedEvent) + case syncQueriesReadyEvent + case readyEvent +} + +enum SyncEngineInitResult { + case alreadyInitialized + case successfullyInitialized + case failure(DataStoreError) +} + +protocol StorageEngineBehavior: AnyObject, ModelStorageBehavior { + + var publisher: AnyPublisher { get } + + /// start remote sync, based on if sync is enabled and/or authentication is required + func startSync() -> Result + func stopSync(completion: @escaping DataStoreCallback) + func clear(completion: @escaping DataStoreCallback) + + /// expresses whether the conforming type is syncing from a remote source. + var syncsFromRemote: Bool { get } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Subscribe/DataStoreObserveQueryOperation.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Subscribe/DataStoreObserveQueryOperation.swift new file mode 100644 index 0000000000..62b959ae27 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Subscribe/DataStoreObserveQueryOperation.swift @@ -0,0 +1,412 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Combine +import Foundation + +protocol DataStoreObserveQueryOperation { + func resetState() + func startObserveQuery(with storageEngine: StorageEngineBehavior) +} + +class ObserveQueryRequest: AmplifyOperationRequest { + var options: Any + + typealias Options = Any + + init(options: Any) { + self.options = options + } +} + +/// Publishes a stream of `DataStoreQuerySnapshot` events. +/// +/// Flow: When the operation starts executing +/// - Subscribe to DataStore hub events +/// - Subscribe to Item changes +/// - Perform initial query to set up the internal state of the items +/// - Generate first snapshot based on the internal state +/// When the operation receives item changes +/// - Batch them into batches of up to 1000 items or when 2 seconds have elapsed (`.collect(2s,1000)`)` +/// - Update internal state of items based on the changed items +/// - Generate new snapshot based on latest state of the items. +/// +/// This operation should perform its methods under the serial DispatchQueue `serialQueue` to ensure all its properties +/// remain thread-safe. +class ObserveQueryTaskRunner: InternalTaskRunner, InternalTaskAsyncThrowingSequence, InternalTaskThrowingChannel, DataStoreObserveQueryOperation { + typealias Request = ObserveQueryRequest + typealias InProcess = DataStoreQuerySnapshot + var request: ObserveQueryRequest + var context = InternalTaskAsyncThrowingSequenceContext>() + + private let serialQueue = DispatchQueue(label: "com.amazonaws.AWSDataStoreObseverQueryOperation.serialQueue", + target: DispatchQueue.global()) + private let itemsChangedPeriodicPublishTimeInSeconds: DispatchQueue.SchedulerTimeType.Stride = 2 + + let modelType: M.Type + let modelSchema: ModelSchema + let predicate: QueryPredicate? + let sortInput: [QuerySortDescriptor]? + var storageEngine: StorageEngineBehavior + var dataStorePublisher: ModelSubcriptionBehavior + let dispatchedModelSyncedEvent: AtomicValue + let itemsChangedMaxSize: Int + + let stopwatch: Stopwatch + var observeQueryStarted: Bool + var currentItems: SortedList + var batchItemsChangedSink: AnyCancellable? + var itemsChangedSink: AnyCancellable? + var modelSyncedEventSink: AnyCancellable? + + var currentSnapshot: DataStoreQuerySnapshot { + DataStoreQuerySnapshot(items: currentItems.sortedModels, isSynced: dispatchedModelSyncedEvent.get()) + } + + private var running = false + + var dataStoreStatePublisher: AnyPublisher + var dataStoreStateSink: AnyCancellable? + + init(request: ObserveQueryRequest = .init(options: []), + context: InternalTaskAsyncThrowingSequenceContext> = InternalTaskAsyncThrowingSequenceContext>(), + modelType: M.Type, + modelSchema: ModelSchema, + predicate: QueryPredicate?, + sortInput: [QuerySortDescriptor]?, + storageEngine: StorageEngineBehavior, + dataStorePublisher: ModelSubcriptionBehavior, + dataStoreConfiguration: DataStoreConfiguration, + dispatchedModelSyncedEvent: AtomicValue, + dataStoreStatePublisher: AnyPublisher) { + self.request = request + self.context = context + + self.modelType = modelType + self.modelSchema = modelSchema + self.predicate = predicate + self.sortInput = sortInput + self.storageEngine = storageEngine + self.dataStorePublisher = dataStorePublisher + self.dispatchedModelSyncedEvent = dispatchedModelSyncedEvent + self.itemsChangedMaxSize = Int(dataStoreConfiguration.syncPageSize) + self.stopwatch = Stopwatch() + self.observeQueryStarted = false + self.currentItems = SortedList(sortInput: sortInput, modelSchema: modelSchema) + + self.dataStoreStatePublisher = dataStoreStatePublisher + } + + func run() async throws { + guard !running else { return } + running = true + + subscribeToDataStoreState() + startObserveQuery() + } + + func subscribeToDataStoreState() { + serialQueue.async { [weak self] in + guard let self = self else { return } + + self.dataStoreStateSink = self.dataStoreStatePublisher.sink { completion in + switch completion { + case .finished: + self.finish() + case .failure(let error): + self.fail(error) + } + } receiveValue: { state in + switch state { + case .start(let storageEngine): + self.startObserveQuery(storageEngine) + case .stop, .clear: + self.resetState() + } + } + + } + } + + public func cancel() { + serialQueue.sync { + if let itemsChangedSink = itemsChangedSink { + itemsChangedSink.cancel() + } + + if let batchItemsChangedSink = batchItemsChangedSink { + batchItemsChangedSink.cancel() + } + + if let modelSyncedEventSink = modelSyncedEventSink { + modelSyncedEventSink.cancel() + } + } + } + + func resetState() { + serialQueue.async { + if !self.observeQueryStarted { + return + } else { + self.observeQueryStarted = false + } + self.log.verbose("Resetting state") + self.currentItems.reset() + self.itemsChangedSink = nil + self.batchItemsChangedSink = nil + self.modelSyncedEventSink = nil + } + } + + func startObserveQuery(with storageEngine: StorageEngineBehavior) { + startObserveQuery(storageEngine) + } + + private func startObserveQuery(_ storageEngine: StorageEngineBehavior? = nil) { + serialQueue.async { + + if self.observeQueryStarted { + return + } else { + self.observeQueryStarted = true + } + + if let storageEngine = storageEngine { + self.storageEngine = storageEngine + } + self.log.verbose("Start ObserveQuery") + self.subscribeToItemChanges() + self.initialQuery() + } + } + + // MARK: - Query + + func initialQuery() { + startSnapshotStopWatch() + storageEngine.query( + modelType, + modelSchema: modelSchema, + predicate: predicate, + sort: sortInput, + paginationInput: nil, + eagerLoad: true, + completion: { queryResult in + switch queryResult { + case .success(let queriedModels): + currentItems.set(sortedModels: queriedModels) + subscribeToModelSyncedEvent() + sendSnapshot() + case .failure(let error): + fail(error) + return + } + }) + } + + // MARK: Observe item changes + + /// Subscribe to item changes with two subscribers (During Sync and After Sync). During Sync, the items are filtered + /// by name and predicate, then collected by the timeOrCount grouping, before sent for processing the snapshot. + /// After Sync, the item is only filtered by name, and not the predicate filter because updates to the item may + /// make it so that the item no longer matches the predicate and requires to be removed from `currentItems`. + /// This check is defered until `onItemChangedAfterSync` where the predicate is then used, and `currentItems` is + /// accessed under the serial queue. + func subscribeToItemChanges() { + serialQueue.async { [weak self] in + guard let self = self else { return } + + self.batchItemsChangedSink = self.dataStorePublisher.publisher + .filter { _ in !self.dispatchedModelSyncedEvent.get() } + .filter(self.filterByModelName(mutationEvent:)) + .filter(self.filterByPredicateMatch(mutationEvent:)) + .handleEvents(receiveOutput: self.onItemChangeDuringSync(mutationEvent:) ) + .collect( + .byTimeOrCount( + // on queue + self.serialQueue, + // collect over this timeframe + self.itemsChangedPeriodicPublishTimeInSeconds, + // If the `storageEngine` does sync from remote, the initial batch should + // collect snapshots based on time / snapshots received. + // If it doesn't, it should publish each snapshot without waiting. + self.storageEngine.syncsFromRemote + ? self.itemsChangedMaxSize + : 1 + ) + ) + .sink(receiveCompletion: self.onReceiveCompletion(completed:), + receiveValue: self.onItemsChangeDuringSync(mutationEvents:)) + + self.itemsChangedSink = self.dataStorePublisher.publisher + .filter { _ in self.dispatchedModelSyncedEvent.get() } + .filter(self.filterByModelName(mutationEvent:)) + .receive(on: self.serialQueue) + .sink(receiveCompletion: self.onReceiveCompletion(completed:), + receiveValue: self.onItemChangeAfterSync(mutationEvent:)) + } + } + + func subscribeToModelSyncedEvent() { + modelSyncedEventSink = Amplify.Hub.publisher(for: .dataStore).sink { event in + if event.eventName == HubPayload.EventName.DataStore.modelSynced, + let modelSyncedEvent = event.data as? ModelSyncedEvent, + modelSyncedEvent.modelName == self.modelSchema.name { + self.serialQueue.async { + self.sendSnapshot() + } + } + } + } + + func filterByModelName(mutationEvent: MutationEvent) -> Bool { + // Filter in the model when it matches the model name for this operation + mutationEvent.modelName == modelSchema.name + } + + func filterByPredicateMatch(mutationEvent: MutationEvent) -> Bool { + // Filter in the model when there is no predicate to check against. + guard let predicate = self.predicate else { + return true + } + do { + let model = try mutationEvent.decodeModel(as: modelType) + // Filter in the model when the predicate matches, otherwise filter out + return predicate.evaluate(target: model) + } catch { + log.error(error: error) + return false + } + } + + func onItemChangeDuringSync(mutationEvent: MutationEvent) { + serialQueue.async { [weak self] in + guard let self = self, self.observeQueryStarted else { + return + } + + self.apply(itemsChanged: [mutationEvent]) + } + } + + func onItemsChangeDuringSync(mutationEvents: [MutationEvent]) { + serialQueue.async { [weak self] in + guard let self = self, + self.observeQueryStarted, + !mutationEvents.isEmpty, + !self.dispatchedModelSyncedEvent.get() + else { return } + + self.startSnapshotStopWatch() + self.sendSnapshot() + } + } + + // Item changes after sync is more elaborate than item changes during sync because the item was never filtered out + // by the predicate (unlike during sync). An item that no longer matches the predicate may already exist in the + // snapshot and now needs to be removed. The evaluation is done here under the serial queue since checking to + // remove the item requires that check on `currentItems` and is required to be performed under the serial queue. + func onItemChangeAfterSync(mutationEvent: MutationEvent) { + serialQueue.async { + guard self.observeQueryStarted else { + return + } + self.startSnapshotStopWatch() + + do { + let model = try mutationEvent.decodeModel(as: self.modelType) + guard let mutationType = MutationEvent.MutationType(rawValue: mutationEvent.mutationType) else { + return + } + + guard let predicate = self.predicate else { + // 1. If there is no predicate, this item should be applied to the snapshot + if self.currentItems.apply(model: model, mutationType: mutationType) { + self.sendSnapshot() + } + return + } + + // 2. When there is a predicate, evaluate further + let modelMatchesPredicate = predicate.evaluate(target: model) + + guard !modelMatchesPredicate else { + // 3. When the item matchs the predicate, the item should be applied to the snapshot + if self.currentItems.apply(model: model, mutationType: mutationType) { + self.sendSnapshot() + } + return + } + + // 4. When the item does not match the predicate, and is an update/delete, then the item needs to be + // removed from `currentItems` because it no longer should be in the snapshot. If removing it was + // was successfully, then send a new snapshot + if mutationType == .update || mutationType == .delete, self.currentItems.remove(model) { + self.sendSnapshot() + } + } catch { + self.log.error(error: error) + return + } + + } + } + + /// Update `curentItems` list with the changed items. + /// This method is not thread safe unless executed under the serial DispatchQueue `serialQueue`. + private func apply(itemsChanged: [MutationEvent]) { + for item in itemsChanged { + do { + let model = try item.decodeModel(as: modelType) + guard let mutationType = MutationEvent.MutationType(rawValue: item.mutationType) else { + return + } + + currentItems.apply(model: model, mutationType: mutationType) + } catch { + log.error(error: error) + continue + } + } + } + + private func startSnapshotStopWatch() { + if log.logLevel >= .debug { + stopwatch.start() + } + } + + private func sendSnapshot() { + send(currentSnapshot) + if log.logLevel >= .debug { + let time = stopwatch.stop() + log.debug("Time to generate snapshot: \(time) seconds. isSynced: \(dispatchedModelSyncedEvent.get()), count: \(currentSnapshot.items.count)") + } + } + + private func onReceiveCompletion(completed: Subscribers.Completion) { + serialQueue.async { [weak self] in + guard let self = self else { return } + switch completed { + case .finished: + self.finish() + case .failure(let error): + self.fail(error) + } + } + } +} + +extension ObserveQueryTaskRunner: DefaultLogger { + public static var log: Logger { + Amplify.Logging.logger(forCategory: CategoryType.dataStore.displayName, forNamespace: String(describing: self)) + } + public var log: Logger { + Self.log + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Subscribe/DataStorePublisher.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Subscribe/DataStorePublisher.swift new file mode 100644 index 0000000000..ee34bc831d --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Subscribe/DataStorePublisher.swift @@ -0,0 +1,40 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Combine + +struct DataStorePublisher: ModelSubcriptionBehavior { + + private let subject = PassthroughSubject() + + var publisher: AnyPublisher { + return subject.eraseToAnyPublisher() + } + + func send(input: MutationEvent) { + subject.send(input) + } + + func send(dataStoreError: DataStoreError) { + subject.send(completion: .failure(dataStoreError)) + } + + func sendFinished() { + subject.send(completion: .finished) + } +} + +protocol ModelSubcriptionBehavior { + + var publisher: AnyPublisher { get } + + func send(input: MutationEvent) + + func send(dataStoreError: DataStoreError) + + func sendFinished() +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Subscribe/ObserveTaskRunner.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Subscribe/ObserveTaskRunner.swift new file mode 100644 index 0000000000..99475ff3eb --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Subscribe/ObserveTaskRunner.swift @@ -0,0 +1,50 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Combine + +struct ObserveRequest: AmplifyOperationRequest { + typealias Options = Any + var options: Any + init(options: Any = []) { + self.options = options + } +} + +class ObserveTaskRunner: InternalTaskRunner, InternalTaskAsyncThrowingSequence, InternalTaskThrowingChannel { + var request: ObserveRequest + + typealias Request = ObserveRequest + typealias InProcess = MutationEvent + + var publisher: AnyPublisher + var sink: AnyCancellable? + var context = InternalTaskAsyncThrowingSequenceContext() + + private var running = false + + public init(request: ObserveRequest = .init(), publisher: AnyPublisher) { + self.request = request + self.publisher = publisher + } + + func run() async throws { + guard !running else { return } + running = true + + self.sink = publisher.sink { completion in + switch completion { + case .finished: + self.finish() + case .failure(let error): + self.fail(error) + } + } receiveValue: { mutationEvent in + self.send(mutationEvent) + } + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Subscribe/Support/Model+Sort.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Subscribe/Support/Model+Sort.swift new file mode 100644 index 0000000000..d2f17947f2 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Subscribe/Support/Model+Sort.swift @@ -0,0 +1,188 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + + +extension Array where Element: Model { + + /// If the models are equal in terms of their sort order (`compare` is `nil`), return the order based on the sort + /// order, such as `true` for ascending and `false` for descending. + mutating func sortModels(by sortBy: QuerySortDescriptor, modelSchema: ModelSchema) { + sort { + modelSchema.comparator(model1: $0, model2: $1, sortBy: sortBy) ?? (sortBy.order == .ascending) + } + } +} + +enum ModelValueCompare { + case bothNil + case leftNil(value2: T) + case rightNil(value1: T) + case values(value1: T, value2: T) + + init(value1Optional: T?, value2Optional: T?) { + switch (value1Optional, value2Optional) { + case (nil, nil): + self = .bothNil + case (nil, .some(let val2)): + self = .leftNil(value2: val2) + case (.some(let val1), nil): + self = .rightNil(value1: val1) + case (.some(let val1), .some(let val2)): + self = .values(value1: val1, value2: val2) + } + } + + /// Return `true` when they are in the correct order. For example, if ascending and the left value is less than + /// right value, then return `true`. If descending and the left value is larger than the right + /// value, return `true`. Treat `nil` values as less than non `nil` values. If the values are both nil or equal, + /// then return `nil` + func sortComparator(sortOrder: QuerySortOrder) -> Bool? { + switch self { + case .bothNil: + return nil + case .leftNil: + return sortOrder == .ascending + case .rightNil: + return sortOrder == .descending + case .values(let value1, let value2): + if value1 == value2 { + return nil + } else { + return sortOrder == .ascending ? value1 < value2 : value1 > value2 + } + } + } +} + +extension ModelSchema { + + /// Compares two model's specified by the field and returns the sort direction based on the `sortBy` sort direction. + /// + /// Models are compared with basic operators on their field values when the field type corresponds to a `Comparable` + /// type such as String, Int, Double, Date. When the field cannot be compared using the basic operators, it will be + /// turned to its String or Int representation for the comparison. For example, EnumPersistable's String + /// value and bool's Int value are used instead. For Bool, this means `nil` is less than `false`, and `false` + /// is less than `true`. + /// + /// `nil` or non-existent values (values which do not exist on the model instance) are treated as less than the + /// values which do exist/non-nil. This returns true when sort is ascending (false if descending) when the + /// `model1`'s field value is `nil` and `model2`'s field value is some value. + /// + /// Sorting on field types such as `.embedded`, `.embeddedCollection`, `.model`, `.collection` + /// is undetermined behavior and is currently not supported. + /// + /// - Note: Maintainers need to keep this utility updated when new field types are added. + /// + /// - Parameters: + /// - model1: model instance to be compared + /// - model2: model instance to be compared + /// - sortBy: The field and direction used to compare the two models + /// - Returns: The resulting comparison between the two models based on `sortBy` + // swiftlint:disable:next cyclomatic_complexity + func comparator(model1: Model, + model2: Model, + sortBy: QuerySortDescriptor) -> Bool? { + let fieldName = sortBy.fieldName + let sortOrder = sortBy.order + guard let modelField = field(withName: fieldName) else { + return false + } + let value1 = model1[fieldName] ?? nil + let value2 = model2[fieldName] ?? nil + switch modelField.type { + case .string: + guard let value1Optional = value1 as? String?, let value2Optional = value2 as? String? else { + return false + } + return ModelValueCompare(value1Optional: value1Optional, + value2Optional: value2Optional) + .sortComparator(sortOrder: sortOrder) + case .int, .timestamp: + if let value1Optional = value1 as? Int?, let value2Optional = value2 as? Int? { + return ModelValueCompare(value1Optional: value1Optional, + value2Optional: value2Optional) + .sortComparator(sortOrder: sortOrder) + } + + if let value1Optional = value1 as? Int64?, let value2Optional = value2 as? Int64? { + return ModelValueCompare(value1Optional: value1Optional, + value2Optional: value2Optional) + .sortComparator(sortOrder: sortOrder) + } + + return false + case .double: + guard let value1Optional = value1 as? Double?, let value2Optional = value2 as? Double? else { + return false + } + return ModelValueCompare(value1Optional: value1Optional, + value2Optional: value2Optional) + .sortComparator(sortOrder: sortOrder) + + case .date: + guard let value1Optional = value1 as? Temporal.Date?, let value2Optional = value2 as? Temporal.Date? else { + return false + } + return ModelValueCompare(value1Optional: value1Optional, + value2Optional: value2Optional) + .sortComparator(sortOrder: sortOrder) + case .dateTime: + guard let value1Optional = value1 as? Temporal.DateTime?, + let value2Optional = value2 as? Temporal.DateTime? else { + return false + } + return ModelValueCompare(value1Optional: value1Optional, + value2Optional: value2Optional) + .sortComparator(sortOrder: sortOrder) + + case .time: + guard let value1Optional = value1 as? Temporal.Time?, let value2Optional = value2 as? Temporal.Time? else { + return false + } + return ModelValueCompare(value1Optional: value1Optional, + value2Optional: value2Optional) + .sortComparator(sortOrder: sortOrder) + case .bool: + guard let value1Optional = value1 as? Bool?, let value2Optional = value2 as? Bool? else { + return false + } + return ModelValueCompare(value1Optional: value1Optional?.intValue, + value2Optional: value2Optional?.intValue) + .sortComparator(sortOrder: sortOrder) + case .enum: + // swiftlint:disable syntactic_sugar + guard case .some(Optional.some(let value1Optional)) = value1, + case .some(Optional.some(let value2Optional)) = value2 else { + if value1 == nil && value2 != nil { + return sortOrder == .ascending + } else if value1 != nil && value2 == nil { + return sortOrder == .descending + } + return false + } + // swiftlint:enable syntactic_sugar + let enumValue1Optional = (value1Optional as? EnumPersistable)?.rawValue + let enumValue2Optional = (value2Optional as? EnumPersistable)?.rawValue + return ModelValueCompare(value1Optional: enumValue1Optional, + value2Optional: enumValue2Optional) + .sortComparator(sortOrder: sortOrder) + case .embedded, .embeddedCollection, .model, .collection: + // Behavior is undetermined + log.warn("Sorting on field type \(modelField.type) is unsupported") + return false + } + } +} + +extension ModelSchema: DefaultLogger { + public static var log: Logger { + Amplify.Logging.logger(forCategory: CategoryType.dataStore.displayName, forNamespace: String(describing: self)) + } + public var log: Logger { + Self.log + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Subscribe/Support/SortedList.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Subscribe/Support/SortedList.swift new file mode 100644 index 0000000000..37bc462e83 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Subscribe/Support/SortedList.swift @@ -0,0 +1,138 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + + +class SortedList { + private(set) var sortedModels: [ModelType] + private(set) var modelIds: Set + private let sortInput: [QuerySortDescriptor]? + private let modelSchema: ModelSchema + + init(sortInput: [QuerySortDescriptor]?, modelSchema: ModelSchema) { + self.sortedModels = [] + self.modelIds = [] + self.sortInput = sortInput + self.modelSchema = modelSchema + } + + /// Sets the internal data structures to the list of models. `sortedModels` should already be sorted to efficiently + /// update the internal instructures of `SortedList`. If `sortedModels` is not sorted, then the subsequent + /// methods used on the data structure will have adverse consequences, for example adding a model assumes the list + /// is already sorted (since it performs a binary search insertion). + func set(sortedModels: [ModelType]) { + self.sortedModels = sortedModels + modelIds = Set(sortedModels.map { $0.identifier(schema: modelSchema).stringValue }) + } + + /// Apply the incoming `model` to the sorted array based on the mutation type. This logic accounts for duplicate + /// events since identical events may have different sources (local and remote). When the mutation type is delete, + /// remove it if it exists in the array. When create/update and it has an sort order, then remove and add it back in + /// the correct sort order. if there is no sort order, replace it. + /// Return `true` if something occured (added, replaced, deleted), otherwise `false` + @discardableResult func apply(model: ModelType, mutationType: MutationEvent.MutationType) -> Bool { + if mutationType == MutationEvent.MutationType.delete { + return remove(model) + } + + guard let sortInputs = sortInput else { + // If there is no sort order, check if it exists to replace, otherwise add it to the end + appendOrReplace(model) + return true + } + + // When there is a sort input, always attempt to remove it before adding to the correct position. + // If we had simply replaced it, and the update if applied to a field on the model that is the same as the + // sort field, then it may no longer be in the correct position. + _ = remove(model) + add(model: model, sortInputs: sortInputs) + return true + } + + /// Add the incoming `model` to the sorted array based on the sort input, or at the end if none is provided. + /// Search for the index by comparing the incoming model with the current model in the binary search traversal. + /// If the models are equal in terms of their sort order (comparator returns `nil`), then move onto the next sort + /// input. If all sort comparators return `nil`, then the incoming model is equal to the current model on all + /// sort inputs, and inserting at the index will maintain the overall sort order. + func add(model: ModelType, sortInputs: [QuerySortDescriptor]) { + let index = sortedModels.binarySearch { existingModel in + var sortOrder: Bool? + var sortIndex: Int = 0 + while sortOrder == nil && sortIndex < sortInputs.endIndex { + let sortInput = sortInputs[sortIndex] + // `existingModel` is passed as left argument so the binarySearch's `predicate` criteria is met, ie. + // if `existingModel` should come before the `model`, keep searching the right half of the array + sortOrder = modelSchema.comparator(model1: existingModel, model2: model, sortBy: sortInput) + sortIndex += 1 + } + return sortOrder + } + + sortedModels.insert(model, at: index) + modelIds.insert(model.identifier(schema: modelSchema).stringValue) + } + + /// Tries to remove the `model`, if removed then return `true`, otherwise `false` + func remove(_ model: ModelType) -> Bool { + let identifier = model.identifier(schema: modelSchema) + if modelIds.contains(identifier.stringValue), + let index = sortedModels.firstIndex(where: { + $0.identifier(schema: $0.schema).stringValue == identifier.stringValue + }) { + sortedModels.remove(at: index) + modelIds.remove(identifier.stringValue) + return true + } else { + return false + } + } + + /// Tries to replace the model with `model` if it already exists, otherwise append it to at the end + func appendOrReplace(_ model: ModelType) { + let identifier = model.identifier(schema: modelSchema) + if modelIds.contains(identifier.stringValue), + let index = sortedModels.firstIndex(where: { $0.identifier(schema: $0.schema).stringValue == identifier.stringValue }) { + sortedModels[index] = model + } else { + sortedModels.append(model) + modelIds.insert(identifier.stringValue) + } + } + + /// Removes the models in the list + func reset() { + sortedModels.removeAll() + modelIds.removeAll() + } +} + +extension Array where Element: Model { + + /// Binary search an array that is expected to be sorted based on the `predicate`. The predicate should return + /// `true` to continue searching on the right side by moving left index after the current middle index. Return + /// `false` to continue searching on the left side by moving right index to the middle. If the `predicate` returns + /// `nil` then the search is complete and return the index. There may be multiple models in the array that resolves + /// to `nil`, however the index is immediately returned when found. The binary search is only possible on + /// pre-sorted arrays to provide a O(log n) runtime and can be used to maintain the sorted array by inserting new + /// models into the correct position. + func binarySearch(predicate: (Element) -> Bool?) -> Index { + var left = startIndex + var right = endIndex + while left != right { + let middle = index(left, offsetBy: distance(from: left, to: right) / 2) + guard let result = predicate(self[middle]) else { + return middle + } + + if result { + left = index(after: middle) + } else { + right = middle + } + } + return left + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/Events/ModelSyncedEvent.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/Events/ModelSyncedEvent.swift new file mode 100644 index 0000000000..ae5a414019 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/Events/ModelSyncedEvent.swift @@ -0,0 +1,68 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + + +/// Hub payload for the `ModelSynced` event. +public struct ModelSyncedEvent { + /// Name of the model that was synced + public let modelName: String + /// True when a full sync query was performed for this event. + public let isFullSync: Bool + /// True when a delta sync query was performed for this event. + public let isDeltaSync: Bool + /// Number of model instances added to the local store + public let added: Int + /// Number of existing model instances updated in the local store + public let updated: Int + /// Number of existing model instances deleted from the local store + public let deleted: Int + + public init(modelName: String, + isFullSync: Bool, + isDeltaSync: Bool, + added: Int, + updated: Int, + deleted: Int) { + self.modelName = modelName + self.isFullSync = isFullSync + self.isDeltaSync = isDeltaSync + self.added = added + self.updated = updated + self.deleted = deleted + } +} + +extension ModelSyncedEvent { + struct Builder { + var modelName: String + var isFullSync: Bool + var isDeltaSync: Bool + var added: Int + var updated: Int + var deleted: Int + + init() { + self.modelName = "" + self.isFullSync = false + self.isDeltaSync = false + self.added = 0 + self.updated = 0 + self.deleted = 0 + } + + func build() -> ModelSyncedEvent { + ModelSyncedEvent( + modelName: modelName, + isFullSync: isFullSync, + isDeltaSync: isDeltaSync, + added: added, + updated: updated, + deleted: deleted + ) + } + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/Events/NetworkStatusEvent.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/Events/NetworkStatusEvent.swift new file mode 100644 index 0000000000..04cfcc9a3f --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/Events/NetworkStatusEvent.swift @@ -0,0 +1,16 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// Used as HubPayload for the `NetworkStatus` +public struct NetworkStatusEvent { + /// status of network: true if network is active + public let active: Bool + + public init(active: Bool) { + self.active = active + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/Events/OutboxMutationEvent.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/Events/OutboxMutationEvent.swift new file mode 100644 index 0000000000..c83abdcf28 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/Events/OutboxMutationEvent.swift @@ -0,0 +1,40 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + + +/// Used as HubPayload for `OutboxMutationEnqueued` and `OutboxMutationProcessed` +public struct OutboxMutationEvent { + public let modelName: String + public let element: OutboxMutationEventElement + + public init(modelName: String, element: OutboxMutationEventElement) { + self.modelName = modelName + self.element = element + } + public static func fromModelWithMetadata(modelName: String, + model: Model, + mutationSync: MutationSync) -> OutboxMutationEvent { + let element = OutboxMutationEventElement(model: model, + version: mutationSync.syncMetadata.version, + lastChangedAt: mutationSync.syncMetadata.lastChangedAt, + deleted: mutationSync.syncMetadata.deleted) + return OutboxMutationEvent(modelName: modelName, element: element) + } + + public static func fromModelWithoutMetadata(modelName: String, + model: Model) -> OutboxMutationEvent { + let element = OutboxMutationEventElement(model: model) + return OutboxMutationEvent(modelName: modelName, element: element) + } + + public struct OutboxMutationEventElement { + public let model: Model + public var version: Int? + public var lastChangedAt: Int64? + public var deleted: Bool? + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/Events/OutboxStatusEvent.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/Events/OutboxStatusEvent.swift new file mode 100644 index 0000000000..8181a742f1 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/Events/OutboxStatusEvent.swift @@ -0,0 +1,16 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// Used as HubPayload for the `OutboxStatus` +public struct OutboxStatusEvent { + /// status of outbox: true if there are no events in the outbox at the time the event was dispatched + public let isEmpty: Bool + + public init(isEmpty: Bool) { + self.isEmpty = isEmpty + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/Events/SyncQueriesStartedEvent.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/Events/SyncQueriesStartedEvent.swift new file mode 100644 index 0000000000..a9e1887fc4 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/Events/SyncQueriesStartedEvent.swift @@ -0,0 +1,16 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// Used as HubPayload for the `SyncQueriesStarted` +public struct SyncQueriesStartedEvent { + /// A list of all model names for which DataStore has started establishing subscriptions + public let models: [String] + + public init(models: [String]) { + self.models = models + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/InitialSync/InitialSyncOperation.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/InitialSync/InitialSyncOperation.swift new file mode 100644 index 0000000000..780524075e --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/InitialSync/InitialSyncOperation.swift @@ -0,0 +1,306 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Combine +import Foundation + +final class InitialSyncOperation: AsynchronousOperation { + typealias SyncQueryResult = PaginatedList + + private weak var api: APICategoryGraphQLBehavior? + private weak var reconciliationQueue: IncomingEventReconciliationQueue? + private weak var storageAdapter: StorageEngineAdapter? + private let dataStoreConfiguration: DataStoreConfiguration + private let authModeStrategy: AuthModeStrategy + + private let modelSchema: ModelSchema + + private var recordsReceived: UInt + private var queryTask: Task? + + private var syncMaxRecords: UInt { + return dataStoreConfiguration.syncMaxRecords + } + private var syncPageSize: UInt { + return dataStoreConfiguration.syncPageSize + } + + private var syncPredicate: QueryPredicate? { + return dataStoreConfiguration.syncExpressions.first { + $0.modelSchema.name == self.modelSchema.name + }?.modelPredicate() + } + + private var syncPredicateString: String? { + guard let syncPredicate = syncPredicate, + let data = try? syncPredicateEncoder.encode(syncPredicate) else { + return nil + } + return String(data: data, encoding: .utf8) + } + + private lazy var _syncPredicateEncoder: JSONEncoder = { + var encoder = JSONEncoder() + encoder.dateEncodingStrategy = ModelDateFormatting.encodingStrategy + encoder.outputFormatting = [.sortedKeys] + return encoder + }() + + var syncPredicateEncoder: JSONEncoder { + _syncPredicateEncoder + } + + private let initialSyncOperationTopic: PassthroughSubject + var publisher: AnyPublisher { + return initialSyncOperationTopic.eraseToAnyPublisher() + } + + init(modelSchema: ModelSchema, + api: APICategoryGraphQLBehavior?, + reconciliationQueue: IncomingEventReconciliationQueue?, + storageAdapter: StorageEngineAdapter?, + dataStoreConfiguration: DataStoreConfiguration, + authModeStrategy: AuthModeStrategy) { + self.modelSchema = modelSchema + self.api = api + self.reconciliationQueue = reconciliationQueue + self.storageAdapter = storageAdapter + self.dataStoreConfiguration = dataStoreConfiguration + self.authModeStrategy = authModeStrategy + + self.recordsReceived = 0 + self.initialSyncOperationTopic = PassthroughSubject() + } + + override func main() { + guard !isCancelled else { + finish(result: .successfulVoid) + return + } + + log.info("Beginning sync for \(modelSchema.name)") + let lastSyncMetadata = getLastSyncMetadata() + let lastSyncTime = getLastSyncTime(lastSyncMetadata) + self.queryTask = Task { + await query(lastSyncTime: lastSyncTime) + } + } + + private func getLastSyncMetadata() -> ModelSyncMetadata? { + guard !isCancelled else { + finish(result: .successfulVoid) + return nil + } + + guard let storageAdapter = storageAdapter else { + log.error(error: DataStoreError.nilStorageAdapter()) + return nil + } + + do { + let modelSyncMetadata = try storageAdapter.queryModelSyncMetadata(for: modelSchema) + return modelSyncMetadata + } catch { + log.error(error: error) + return nil + } + } + + /// Retrieve the lastSync time for the request before performing the query operation. + /// + /// - Parameter lastSyncMetadata: Retrieved persisted sync metadata for this model + /// - Returns: A `lastSync` time for the query request. + func getLastSyncTime(_ lastSyncMetadata: ModelSyncMetadata?) -> Int64? { + let syncType: SyncType + let lastSyncTime: Int64? + if syncPredicateChanged(self.syncPredicateString, lastSyncMetadata?.syncPredicate) { + log.info("SyncPredicate for \(modelSchema.name) changed, performing full sync.") + lastSyncTime = nil + syncType = .fullSync + } else { + lastSyncTime = getLastSyncTime(lastSync: lastSyncMetadata?.lastSync) + syncType = lastSyncTime == nil ? .fullSync : .deltaSync + } + initialSyncOperationTopic.send(.started(modelName: modelSchema.name, syncType: syncType)) + return lastSyncTime + } + + private func syncPredicateChanged(_ lastSyncPredicate: String?, _ currentSyncPredicate: String?) -> Bool { + switch (lastSyncPredicate, currentSyncPredicate) { + case (.some, .some): + return lastSyncPredicate != currentSyncPredicate + case (.some, .none), (.none, .some): + return true + case (.none, .none): + return false + } + } + + private func getLastSyncTime(lastSync: Int64?) -> Int64? { + guard let lastSync = lastSync else { + return nil + } + let lastSyncDate = Date(timeIntervalSince1970: TimeInterval.milliseconds(Double(lastSync))) + let secondsSinceLastSync = (lastSyncDate.timeIntervalSinceNow * -1) + if secondsSinceLastSync < 0 { + log.info("lastSyncTime was in the future, assuming base query") + return nil + } + + let shouldDoDeltaQuery = secondsSinceLastSync < dataStoreConfiguration.syncInterval + return shouldDoDeltaQuery ? lastSync : nil + } + + private func query(lastSyncTime: Int64?, nextToken: String? = nil) async { + guard !isCancelled else { + finish(result: .successfulVoid) + return + } + + guard let api = api else { + finish(result: .failure(DataStoreError.nilAPIHandle())) + return + } + let minSyncPageSize = Int(min(syncMaxRecords - recordsReceived, syncPageSize)) + let limit = minSyncPageSize < 0 ? Int(syncPageSize) : minSyncPageSize + let authTypes = await authModeStrategy.authTypesFor(schema: modelSchema, operation: .read) + .publisher() + .map { Optional.some($0) } // map to optional to have nil as element + .replaceEmpty(with: nil) // use a nil element to trigger default auth if no auth provided + .map { authType in { [weak self] in + guard let self, let api = self.api else { + throw APIError.operationError("Operation cancelled", "") + } + + return try await api.query(request: GraphQLRequest.syncQuery( + modelSchema: self.modelSchema, + where: self.syncPredicate, + limit: limit, + nextToken: nextToken, + lastSync: lastSyncTime, + authType: authType + )) + }} + .eraseToAnyPublisher() + + switch await RetryableGraphQLOperation(requestStream: authTypes).run() { + case .success(let graphQLResult): + await handleQueryResults(lastSyncTime: lastSyncTime, graphQLResult: graphQLResult) + case .failure(let apiError): + if self.isAuthSignedOutError(apiError: apiError) { + self.log.error("Sync for \(self.modelSchema.name) failed due to signed out error \(apiError.errorDescription)") + } + self.dataStoreConfiguration.errorHandler(DataStoreError.api(apiError)) + self.finish(result: .failure(.api(apiError))) + } + } + + /// Disposes of the query results: Stops if error, reconciles results if success, and kick off a new query if there + /// is a next token + private func handleQueryResults( + lastSyncTime: Int64?, + graphQLResult: Result> + ) async { + guard !isCancelled else { + finish(result: .successfulVoid) + return + } + + guard let reconciliationQueue = reconciliationQueue else { + finish(result: .failure(DataStoreError.nilReconciliationQueue())) + return + } + + let syncQueryResult: SyncQueryResult + switch graphQLResult { + case .success(let queryResult): + syncQueryResult = queryResult + + case .failure(.partial(let queryResult, let errors)): + syncQueryResult = queryResult + errors.map { DataStoreError.api(APIError(errorDescription: $0.message, error: $0)) } + .forEach { dataStoreConfiguration.errorHandler($0) } + + case .failure(let graphQLResponseError): + finish(result: .failure(DataStoreError.api(graphQLResponseError))) + return + } + + let items = syncQueryResult.items + recordsReceived += UInt(items.count) + + reconciliationQueue.offer(items, modelName: modelSchema.name) + for item in items { + initialSyncOperationTopic.send(.enqueued(item, modelName: modelSchema.name)) + } + + if let nextToken = syncQueryResult.nextToken, recordsReceived < syncMaxRecords { + await self.query(lastSyncTime: lastSyncTime, nextToken: nextToken) + } else { + updateModelSyncMetadata(lastSyncTime: syncQueryResult.startedAt) + } + } + + private func updateModelSyncMetadata(lastSyncTime: Int64?) { + guard !isCancelled else { + finish(result: .successfulVoid) + return + } + + guard let storageAdapter = storageAdapter else { + finish(result: .failure(DataStoreError.nilStorageAdapter())) + return + } + + let syncMetadata = ModelSyncMetadata(id: modelSchema.name, + lastSync: lastSyncTime, + syncPredicate: syncPredicateString) + storageAdapter.save(syncMetadata, condition: nil, eagerLoad: true) { result in + switch result { + case .failure(let dataStoreError): + self.finish(result: .failure(dataStoreError)) + case .success: + self.finish(result: .successfulVoid) + } + } + } + + private func isAuthSignedOutError(apiError: APIError) -> Bool { + if case let .operationError(_, _, underlyingError) = apiError, + let authError = underlyingError as? AuthError, + case .signedOut = authError { + return true + } + + return false + } + + private func finish(result: AWSInitialSyncOrchestrator.SyncOperationResult) { + switch result { + case .failure(let error): + initialSyncOperationTopic.send(.finished(modelName: modelSchema.name, error: error)) + initialSyncOperationTopic.send(completion: .failure(error)) + case .success: + initialSyncOperationTopic.send(.finished(modelName: modelSchema.name)) + initialSyncOperationTopic.send(completion: .finished) + } + super.finish() + } + + override func cancel() { + self.queryTask?.cancel() + } +} + +extension InitialSyncOperation: DefaultLogger { + public static var log: Logger { + Amplify.Logging.logger(forCategory: CategoryType.dataStore.displayName, forNamespace: String(describing: self)) + } + public var log: Logger { + Self.log + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/InitialSync/InitialSyncOperationEvent.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/InitialSync/InitialSyncOperationEvent.swift new file mode 100644 index 0000000000..c2053a71e6 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/InitialSync/InitialSyncOperationEvent.swift @@ -0,0 +1,26 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + + +enum InitialSyncOperationEvent { + /// Published at the start of sync query (full or delta) for a particular Model + /// Used by `SyncEventEmitter` and `ModelSyncedEmitted` + case started(modelName: ModelName, syncType: SyncType) + + /// Published when a remote model is enqueued for local store reconciliation. + /// Used by `ModelSyncedEventEmitter` for record counting. + case enqueued(MutationSync, modelName: ModelName) + + /// Published when the sync operation has completed and all remote models have been enqueued for reconciliation. + /// Used by `ModelSyncedEventEmitter` to determine when to send `ModelSyncedEvent` + case finished(modelName: ModelName, error: DataStoreError? = nil) +} + +enum SyncType { + case fullSync + case deltaSync +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/InitialSync/InitialSyncOrchestrator.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/InitialSync/InitialSyncOrchestrator.swift new file mode 100644 index 0000000000..2a95d3252e --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/InitialSync/InitialSyncOrchestrator.swift @@ -0,0 +1,297 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Combine +import Foundation + +protocol InitialSyncOrchestrator { + var publisher: AnyPublisher { get } + func sync(completion: @escaping (Result) -> Void) +} + +// For testing +typealias InitialSyncOrchestratorFactory = + (DataStoreConfiguration, + AuthModeStrategy, + APICategoryGraphQLBehavior?, + IncomingEventReconciliationQueue?, + StorageEngineAdapter?) -> InitialSyncOrchestrator + +final class AWSInitialSyncOrchestrator: InitialSyncOrchestrator { + typealias SyncOperationResult = Result + typealias SyncOperationResultHandler = (SyncOperationResult) -> Void + + private var initialSyncOperationSinks: [String: AnyCancellable] + + private let dataStoreConfiguration: DataStoreConfiguration + private weak var api: APICategoryGraphQLBehavior? + private weak var reconciliationQueue: IncomingEventReconciliationQueue? + private weak var storageAdapter: StorageEngineAdapter? + private let authModeStrategy: AuthModeStrategy + + private var completion: SyncOperationResultHandler? + + private var syncErrors: [DataStoreError] + + // Future optimization: can perform sync on each root in parallel, since we know they won't have any + // interdependencies + let syncOperationQueue: OperationQueue + private let concurrencyQueue = DispatchQueue(label: "com.amazonaws.InitialSyncOrchestrator.concurrencyQueue", + target: DispatchQueue.global()) + + private let initialSyncOrchestratorTopic: PassthroughSubject + var publisher: AnyPublisher { + return initialSyncOrchestratorTopic.eraseToAnyPublisher() + } + + init(dataStoreConfiguration: DataStoreConfiguration, + authModeStrategy: AuthModeStrategy, + api: APICategoryGraphQLBehavior?, + reconciliationQueue: IncomingEventReconciliationQueue?, + storageAdapter: StorageEngineAdapter?) { + self.initialSyncOperationSinks = [:] + self.dataStoreConfiguration = dataStoreConfiguration + self.authModeStrategy = authModeStrategy + self.api = api + self.reconciliationQueue = reconciliationQueue + self.storageAdapter = storageAdapter + + let syncOperationQueue = OperationQueue() + syncOperationQueue.name = "com.amazon.InitialSyncOrchestrator.syncOperationQueue" + syncOperationQueue.maxConcurrentOperationCount = 1 + syncOperationQueue.isSuspended = true + self.syncOperationQueue = syncOperationQueue + + self.syncErrors = [] + self.initialSyncOrchestratorTopic = PassthroughSubject() + } + + /// Performs an initial sync on all models. This should only be called by the + /// RemoteSyncEngine during startup. Calling this multiple times will result in + /// undefined behavior. + func sync(completion: @escaping SyncOperationResultHandler) { + concurrencyQueue.async { + self.completion = completion + + self.log.info("Beginning initial sync") + + let syncableModelSchemas = ModelRegistry.modelSchemas.filter { $0.isSyncable } + self.enqueueSyncableModels(syncableModelSchemas) + + let modelNames = syncableModelSchemas.map { $0.name } + self.dispatchSyncQueriesStarted(for: modelNames) + if !syncableModelSchemas.hasAssociations() { + self.syncOperationQueue.maxConcurrentOperationCount = syncableModelSchemas.count + } + self.syncOperationQueue.isSuspended = false + } + } + + private func enqueueSyncableModels(_ syncableModelSchemas: [ModelSchema]) { + let sortedModelSchemas = syncableModelSchemas.sortByDependencyOrder() + for modelSchema in sortedModelSchemas { + enqueueSyncOperation(for: modelSchema) + } + } + + /// Enqueues sync operations for models and downstream dependencies + private func enqueueSyncOperation(for modelSchema: ModelSchema) { + let initialSyncForModel = InitialSyncOperation(modelSchema: modelSchema, + api: api, + reconciliationQueue: reconciliationQueue, + storageAdapter: storageAdapter, + dataStoreConfiguration: dataStoreConfiguration, + authModeStrategy: authModeStrategy) + + initialSyncOperationSinks[modelSchema.name] = initialSyncForModel + .publisher + .receive(on: concurrencyQueue) + .sink(receiveCompletion: { result in self.onReceiveCompletion(modelSchema: modelSchema, + result: result) }, + receiveValue: onReceiveValue(_:)) + + syncOperationQueue.addOperation(initialSyncForModel) + } + + private func onReceiveValue(_ value: InitialSyncOperationEvent) { + initialSyncOrchestratorTopic.send(value) + } + + private func onReceiveCompletion(modelSchema: ModelSchema, result: Subscribers.Completion) { + if case .failure(let dataStoreError) = result { + let syncError = DataStoreError.sync( + "An error occurred syncing \(modelSchema.name)", + "", + dataStoreError) + self.syncErrors.append(syncError) + } + + initialSyncOperationSinks.removeValue(forKey: modelSchema.name) + + guard initialSyncOperationSinks.isEmpty else { + return + } + + let completionResult = makeCompletionResult() + switch completionResult { + case .success: + initialSyncOrchestratorTopic.send(completion: .finished) + case .failure(let error): + initialSyncOrchestratorTopic.send(completion: .failure(error)) + } + completion?(completionResult) + } + + private func makeCompletionResult() -> Result { + if syncErrors.isEmpty || syncErrors.allSatisfy(isUnauthorizedError) { + return .successfulVoid + } + + var underlyingError: Error? + if let error = syncErrors.first(where: isNetworkError(_:)) { + underlyingError = getUnderlyingNetworkError(error) + } + + let allMessages = syncErrors.map { String(describing: $0) } + let syncError = DataStoreError.sync( + "One or more errors occurred syncing models. See below for detailed error description.", + allMessages.joined(separator: "\n"), + underlyingError + ) + + return .failure(syncError) + } + + private func dispatchSyncQueriesStarted(for modelNames: [String]) { + let syncQueriesStartedEvent = SyncQueriesStartedEvent(models: modelNames) + let syncQueriesStartedEventPayload = HubPayload(eventName: HubPayload.EventName.DataStore.syncQueriesStarted, + data: syncQueriesStartedEvent) + log.verbose("[Lifecycle event 2]: syncQueriesStarted") + Amplify.Hub.dispatch(to: .dataStore, payload: syncQueriesStartedEventPayload) + } +} + +extension AWSInitialSyncOrchestrator: DefaultLogger { + public static var log: Logger { + Amplify.Logging.logger(forCategory: CategoryType.dataStore.displayName, forNamespace: String(describing: self)) + } + public var log: Logger { + Self.log + } +} + +extension AWSInitialSyncOrchestrator: Resettable { + func reset() async { + syncOperationQueue.cancelAllOperations() + syncOperationQueue.waitUntilAllOperationsAreFinished() + } +} + +extension AWSInitialSyncOrchestrator { + private typealias ResponseType = PaginatedList + private func graphqlErrors(from error: GraphQLResponseError?) -> [GraphQLError]? { + if case let .error(errors) = error { + return errors + } + return nil + } + + private func errorTypeValueFrom(graphQLError: GraphQLError) -> String? { + guard case let .string(errorTypeValue) = graphQLError.extensions?["errorType"] else { + return nil + } + return errorTypeValue + } + + func isUnauthorizedError(_ error: DataStoreError) -> Bool { + guard case let .sync(_, _, underlyingError) = error, + let datastoreError = underlyingError as? DataStoreError + else { + return false + } + + // The following check is to categorize the error as an unauthorized error when the process fails to retrieve + // the authorization token. This is taken directly from `AuthTokenURLRequestInterceptor`'s error handling path + // that returns an APIError.operationError with an underlying AuthError. + // + // A signed out user, or a signed in user's session that has expired, will result the `getToken()` to + // return an error. The request is never sent over the network to the service to apply its auth check and is + // returned immediately to this calling code. + // + // The check itself is not the most ideal contract for checking when there is an authorization error, however it + // does have some stability since `operationError` is a local implementation detail. The underlying error check + // for AuthError means that the AuthError could be any case. For example, if the AuthError changes, it will + // still be categorized as unauthorized by this code. If a specific type like `AuthError.unauthorized` is + // introduced in the future, this code will still return true, which is crucial to prevent the sync + // orchestrator from failing the entire sync process. + if case let .api(amplifyError, _) = datastoreError, + let apiError = amplifyError as? APIError, + case .operationError(_, _, let underlyingError) = apiError, + (underlyingError as? AuthError) != nil { + return true + } + + // If token was retrieved, and request was sent, but service returned an error response, + // check that it is an Unauthorized error from the GraphQL response payload. + if case let .api(apiError, _) = datastoreError, + let responseError = apiError as? GraphQLResponseError, + let graphQLError = graphqlErrors(from: responseError)?.first, + let errorTypeValue = errorTypeValueFrom(graphQLError: graphQLError), + case .unauthorized = AppSyncErrorType(errorTypeValue) { + return true + } + + // Check is API error is of unauthorized type + if case let .api(amplifyError, _) = datastoreError, + let apiError = amplifyError as? APIError { + if case .operationError(let errorDescription, _, _) = apiError, + errorDescription.range(of: "Unauthorized", + options: .caseInsensitive) != nil { + return true + } + + if case .httpStatusError(let statusCode, _) = apiError, + statusCode == 401 || statusCode == 403 { + return true + } + } + + return false + } + + private func isNetworkError(_ error: DataStoreError) -> Bool { + guard case let .sync(_, _, underlyingError) = error, + let datastoreError = underlyingError as? DataStoreError + else { + return false + } + + if case let .api(amplifyError, _) = datastoreError, + let apiError = amplifyError as? APIError, + case .networkError = apiError { + return true + } + + return false + } + + private func getUnderlyingNetworkError(_ error: DataStoreError) -> Error? { + guard case let .sync(_, _, underlyingError) = error, + let datastoreError = underlyingError as? DataStoreError + else { + return nil + } + + if case let .api(amplifyError, _) = datastoreError, + let apiError = amplifyError as? APIError, + case let .networkError(_, _, underlyingError) = apiError { + return underlyingError + } + + return nil + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/InitialSync/ModelSyncedEventEmitter.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/InitialSync/ModelSyncedEventEmitter.swift new file mode 100644 index 0000000000..a1380011f4 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/InitialSync/ModelSyncedEventEmitter.swift @@ -0,0 +1,182 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Combine +import Foundation + +enum IncomingModelSyncedEmitterEvent { + case mutationEventApplied(MutationEvent) + case mutationEventDropped(modelName: String, error: DataStoreError? = nil) + case modelSyncedEvent(ModelSyncedEvent) +} + +/// Listens to events published by both the `InitialSyncOrchestrator` and `IncomingEventReconciliationQueue`, +/// and emits a `ModelSyncedEvent` when the initial sync is complete. This class expects +/// `InitialSyncOrchestrator` and `IncomingEventReconciliationQueue` to have matching counts +/// for the events they enqueue and process, respectively. Always send back the reconciled event +/// (`.mutationEventApplied`, `.mutationEventDropped`). The flow also provides a guaranteed sequence of events for the +/// mutation event which causes the `ModelSyncedEvent` to be emitted afterwards by +/// - Check if it `ModelSyncedEvent` should be emitted, if so, emit it. +/// - Then send the mutation event which was used in the check above. +final class ModelSyncedEventEmitter { + private let queue = DispatchQueue(label: "com.amazonaws.ModelSyncedEventEmitterQueue", + target: DispatchQueue.global()) + + private var syncOrchestratorSink: AnyCancellable? + private var reconciliationQueueSink: AnyCancellable? + + private let modelSchema: ModelSchema + private var recordsReceived: Int + private var reconciledReceived: Int + private var initialSyncOperationFinished: Bool + + private var modelSyncedEventBuilder: ModelSyncedEvent.Builder + + private var modelSyncedEventTopic: PassthroughSubject + var publisher: AnyPublisher { + return modelSyncedEventTopic.eraseToAnyPublisher() + } + + var shouldSendModelSyncedEvent: Bool { + initialSyncOperationFinished && reconciledReceived == recordsReceived + } + + /// Used within ModelSyncedEventEmitter instances, not thread-safe, is accessed serially under DispatchQueue. + var dispatchedModelSyncedEvent: Bool + + init(modelSchema: ModelSchema, + initialSyncOrchestrator: InitialSyncOrchestrator?, + reconciliationQueue: IncomingEventReconciliationQueue?) { + self.modelSchema = modelSchema + self.recordsReceived = 0 + self.reconciledReceived = 0 + self.initialSyncOperationFinished = false + self.dispatchedModelSyncedEvent = false + self.modelSyncedEventBuilder = ModelSyncedEvent.Builder() + + self.modelSyncedEventTopic = PassthroughSubject() + + self.syncOrchestratorSink = initialSyncOrchestrator? + .publisher + .receive(on: queue) + .filter { [weak self] in self?.filterSyncOperationEvent($0) == true } + .sink(receiveCompletion: { _ in }, + receiveValue: { [weak self] value in + self?.onReceiveSyncOperationEvent(value: value) + }) + + self.reconciliationQueueSink = reconciliationQueue? + .publisher + .receive(on: queue) + .filter { [weak self] in self?.filterReconciliationQueueEvent($0) == true } + .sink(receiveCompletion: { _ in }, + receiveValue: { [weak self] value in + self?.onReceiveReconciliationEvent(value: value) + }) + } + + /// Filtering `InitialSyncOperationEvent`s that come from `InitialSyncOperation` of the same ModelType + private func filterSyncOperationEvent(_ value: InitialSyncOperationEvent) -> Bool { + switch value { + case .started(let modelName, _): + return modelSchema.name == modelName + case .enqueued(_, let modelName): + return modelSchema.name == modelName + case .finished(let modelName, _): + return modelSchema.name == modelName + } + } + + /// Filtering `IncomingEventReconciliationQueueEvent`s that come from `ReconciliationAndLocalSaveOperation` + /// of the same ModelType + private func filterReconciliationQueueEvent(_ value: IncomingEventReconciliationQueueEvent) -> Bool { + switch value { + case .mutationEventApplied(let event): + return modelSchema.name == event.modelName + case .mutationEventDropped(let modelName, _): + return modelSchema.name == modelName + case .idle, .initialized, .started, .paused: + return false + } + } + + private func onReceiveSyncOperationEvent(value: InitialSyncOperationEvent) { + switch value { + case .started(_, let syncType): + modelSyncedEventBuilder.isFullSync = syncType == .fullSync ? true : false + modelSyncedEventBuilder.isDeltaSync = !modelSyncedEventBuilder.isFullSync + case .enqueued: + recordsReceived += 1 + case .finished: + if recordsReceived == 0 || recordsReceived == reconciledReceived { + sendModelSyncedEvent() + } else { + initialSyncOperationFinished = true + } + } + } + + private func onReceiveReconciliationEvent(value: IncomingEventReconciliationQueueEvent) { + guard !dispatchedModelSyncedEvent else { + switch value { + case .mutationEventApplied(let event): + modelSyncedEventTopic.send(.mutationEventApplied(event)) + case .mutationEventDropped(let modelName, let error): + modelSyncedEventTopic.send(.mutationEventDropped(modelName: modelName, error: error)) + case .idle, .initialized, .started, .paused: + return + } + return + } + + switch value { + case .mutationEventApplied(let event): + reconciledReceived += 1 + switch GraphQLMutationType(rawValue: event.mutationType) { + case .create: + modelSyncedEventBuilder.added += 1 + case .update: + modelSyncedEventBuilder.updated += 1 + case .delete: + modelSyncedEventBuilder.deleted += 1 + default: + log.error("Unexpected mutationType received: \(event.mutationType)") + } + + modelSyncedEventTopic.send(.mutationEventApplied(event)) + if shouldSendModelSyncedEvent { + sendModelSyncedEvent() + } + case .mutationEventDropped(let modelName, let error): + reconciledReceived += 1 + modelSyncedEventTopic.send(.mutationEventDropped(modelName: modelName, error: error)) + if shouldSendModelSyncedEvent { + sendModelSyncedEvent() + } + case .idle, .initialized, .started, .paused: + return + } + } + + private func sendModelSyncedEvent() { + modelSyncedEventBuilder.modelName = modelSchema.name + let modelSyncedEvent = modelSyncedEventBuilder.build() + log.verbose("[Lifecycle event 3]: modelSyncedEvent model: \(modelSchema.name)") + modelSyncedEventTopic.send(.modelSyncedEvent(modelSyncedEvent)) + dispatchedModelSyncedEvent = true + syncOrchestratorSink?.cancel() + } +} + +extension ModelSyncedEventEmitter: DefaultLogger { + public static var log: Logger { + Amplify.Logging.logger(forCategory: CategoryType.dataStore.displayName, forNamespace: String(describing: self)) + } + public var log: Logger { + Self.log + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/InitialSync/ReadyEventEmitter.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/InitialSync/ReadyEventEmitter.swift new file mode 100644 index 0000000000..c0072d94e7 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/InitialSync/ReadyEventEmitter.swift @@ -0,0 +1,69 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Combine + +enum IncomingReadyEventEmitter { + case readyEvent +} + +final class ReadyEventEmitter { + var readySink: AnyCancellable? + + private var readyEventTopic: PassthroughSubject + var publisher: AnyPublisher { + return readyEventTopic.eraseToAnyPublisher() + } + + init(remoteSyncEnginePublisher: AnyPublisher) { + self.readyEventTopic = PassthroughSubject() + + let queriesReadyPublisher = ReadyEventEmitter.makeSyncQueriesReadyPublisher() + let syncEngineStartedPublisher = ReadyEventEmitter.makeRemoteSyncEngineStartedPublisher( + remoteSyncEnginePublisher: remoteSyncEnginePublisher + ) + readySink = Publishers + .Merge(queriesReadyPublisher, syncEngineStartedPublisher) + .sink(receiveCompletion: { completion in + switch completion { + case .finished: + self.readyEventTopic.send(.readyEvent) + case .failure(let dataStoreError): + self.log.error("Failed to emit ready event, error: \(dataStoreError)") + } + }, receiveValue: { _ in }) + } + + private static func makeSyncQueriesReadyPublisher() -> AnyPublisher { + Amplify.Hub + .publisher(for: .dataStore) + .filter { $0.eventName == HubPayload.EventName.DataStore.syncQueriesReady } + .first() + .map { _ in () } + .setFailureType(to: DataStoreError.self) + .eraseToAnyPublisher() + } + + private static func makeRemoteSyncEngineStartedPublisher( + remoteSyncEnginePublisher: AnyPublisher + ) -> AnyPublisher { + remoteSyncEnginePublisher + .filter { if case .syncStarted = $0 { return true } else { return false } } + .first() + .map { _ in () } + .eraseToAnyPublisher() + } +} + +extension ReadyEventEmitter: DefaultLogger { + public static var log: Logger { + Amplify.Logging.logger(forCategory: CategoryType.dataStore.displayName, forNamespace: String(describing: self)) + } + public var log: Logger { + Self.log + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/InitialSync/SyncEventEmitter.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/InitialSync/SyncEventEmitter.swift new file mode 100644 index 0000000000..6615059c9d --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/InitialSync/SyncEventEmitter.swift @@ -0,0 +1,91 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Combine +import Foundation + +enum IncomingSyncEventEmitterEvent { + case mutationEventApplied(MutationEvent) + case mutationEventDropped(modelName: String, error: DataStoreError? = nil) + case modelSyncedEvent(ModelSyncedEvent) + case syncQueriesReadyEvent +} + +/// SyncEventEmitter holds onto one ModelSyncedEventEmitter per model. It counts the number of `modelSyncedEvent` to +/// emit the `syncQueriesReady` and sends back the reconciliation events (`.mutationEventApplied`, +/// `.mutationEventDropped`) to its subscribers. +final class SyncEventEmitter { + private let queue = DispatchQueue(label: "com.amazonaws.SyncEventEmitter", + target: DispatchQueue.global()) + + var modelSyncedEventEmitters: [String: ModelSyncedEventEmitter] + var initialSyncCompleted: AnyCancellable? + + private var syncableModels: Int + private var modelSyncedReceived: Int + + private var syncEventEmitterTopic: PassthroughSubject + var publisher: AnyPublisher { + return syncEventEmitterTopic.eraseToAnyPublisher() + } + + var shouldDispatchSyncQueriesReadyEvent: Bool { + syncableModels == modelSyncedReceived + } + + init(initialSyncOrchestrator: InitialSyncOrchestrator?, + reconciliationQueue: IncomingEventReconciliationQueue?) { + self.modelSyncedEventEmitters = [String: ModelSyncedEventEmitter]() + self.syncEventEmitterTopic = PassthroughSubject() + self.modelSyncedReceived = 0 + + let syncableModelSchemas = ModelRegistry.modelSchemas.filter { $0.isSyncable } + self.syncableModels = syncableModelSchemas.count + + var publishers = [AnyPublisher]() + for syncableModelSchema in syncableModelSchemas { + let modelSyncedEventEmitter = ModelSyncedEventEmitter(modelSchema: syncableModelSchema, + initialSyncOrchestrator: initialSyncOrchestrator, + reconciliationQueue: reconciliationQueue) + modelSyncedEventEmitters[syncableModelSchema.name] = modelSyncedEventEmitter + publishers.append(modelSyncedEventEmitter.publisher) + } + + self.initialSyncCompleted = Publishers + .MergeMany(publishers) + .receive(on: queue) + .sink(receiveCompletion: { _ in }, + receiveValue: { [weak self] value in + self?.onReceiveModelSyncedEmitterEvent(value: value) + }) + } + + private func onReceiveModelSyncedEmitterEvent(value: IncomingModelSyncedEmitterEvent) { + switch value { + case .mutationEventApplied(let mutationEvent): + syncEventEmitterTopic.send(.mutationEventApplied(mutationEvent)) + case .mutationEventDropped(let modelName, let error): + syncEventEmitterTopic.send(.mutationEventDropped(modelName: modelName, error: error)) + case .modelSyncedEvent(let modelSyncedEvent): + modelSyncedReceived += 1 + log.verbose("[Lifecycle event 3]: modelSyncedReceived progress: \(modelSyncedReceived)/\(syncableModels)") + syncEventEmitterTopic.send(.modelSyncedEvent(modelSyncedEvent)) + if shouldDispatchSyncQueriesReadyEvent { + syncEventEmitterTopic.send(.syncQueriesReadyEvent) + } + } + } +} + +extension SyncEventEmitter: DefaultLogger { + public static var log: Logger { + Amplify.Logging.logger(forCategory: CategoryType.dataStore.displayName, forNamespace: String(describing: self)) + } + public var log: Logger { + Self.log + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/AWSMutationDatabaseAdapter/AWSMutationDatabaseAdapter+MutationEventIngester.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/AWSMutationDatabaseAdapter/AWSMutationDatabaseAdapter+MutationEventIngester.swift new file mode 100644 index 0000000000..d6ee6d6c94 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/AWSMutationDatabaseAdapter/AWSMutationDatabaseAdapter+MutationEventIngester.swift @@ -0,0 +1,233 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Combine +import Foundation + +extension AWSMutationDatabaseAdapter: MutationEventIngester { + + /// Accepts a mutation event without a version, applies the latest version from the MutationSyncMetadata table, + /// writes the updated mutation event to the local database, then submits it to `mutationEventSubject` + func submit(mutationEvent: MutationEvent, completion: @escaping (Result) -> Void) { + Task { + log.verbose("\(#function): \(mutationEvent)") + + guard let storageAdapter = self.storageAdapter else { + completion(.failure(DataStoreError.nilStorageAdapter())) + return + } + + self.resolveConflictsThenSave(mutationEvent: mutationEvent, + storageAdapter: storageAdapter, + completion: completion) + } + } + + /// Resolves conflicts for the offered mutationEvent, and either accepts the event, returning a disposition, or + /// rejects the event with an error + func resolveConflictsThenSave(mutationEvent: MutationEvent, + storageAdapter: StorageEngineAdapter, + completion: @escaping (Result) -> Void) { + MutationEvent.pendingMutationEvents( + forMutationEvent: mutationEvent, + storageAdapter: storageAdapter) { result in + switch result { + case .failure(let dataStoreError): + completion(.failure(dataStoreError)) + case .success(let localMutationEvents): + let mutationDisposition = self.disposition(for: mutationEvent, + given: localMutationEvents) + self.resolve(candidate: mutationEvent, + localEvents: localMutationEvents, + per: mutationDisposition, + storageAdapter: storageAdapter, + completionPromise: completion) + } + } + } + + func disposition(for candidate: MutationEvent, + given localEvents: [MutationEvent]) -> MutationDisposition { + + guard !localEvents.isEmpty, let existingEvent = localEvents.first else { + log.verbose("\(#function) no local events, saving candidate") + return .saveCandidate + } + + if candidate.graphQLFilterJSON != nil { + return .saveCandidate + } + + guard let candidateMutationType = GraphQLMutationType(rawValue: candidate.mutationType) else { + let dataStoreError = + DataStoreError.unknown("Couldn't get mutation type for \(candidate.mutationType)", + AmplifyErrorMessages.shouldNotHappenReportBugToAWS()) + return .dropCandidateWithError(dataStoreError) + } + + guard let existingMutationType = GraphQLMutationType(rawValue: existingEvent.mutationType) else { + let dataStoreError = + DataStoreError.unknown("Couldn't get mutation type for \(existingEvent.mutationType)", + AmplifyErrorMessages.shouldNotHappenReportBugToAWS()) + return .dropCandidateWithError(dataStoreError) + } + + log.verbose("\(#function)(existing: \(existingMutationType), candidate: \(candidateMutationType))") + + switch (existingMutationType, candidateMutationType) { + case (.create, .update), + (.update, .update), + (.update, .delete), + (.delete, .delete): + return .replaceLocalWithCandidate + + case (.create, .delete): + return .dropCandidateAndDeleteLocal + + case (_, .create): + let dataStoreError = + DataStoreError.unknown( + "Received a create mutation for an item that has already been created", + """ + Review your app code and ensure you are not issuing incorrect DataStore.save() calls for the same \ + model. Candidate model is below: + \(candidate) + """ + ) + return .dropCandidateWithError(dataStoreError) + + case (.delete, .update): + let dataStoreError = + DataStoreError.unknown( + "Received an update mutation for an item that has been marked as deleted", + """ + Review your app code and ensure you are not issuing incorrect DataStore.save() calls for the same \ + model. Candidate model is below: + \(candidate) + """ + ) + return .dropCandidateWithError(dataStoreError) + } + + } + + func resolve(candidate: MutationEvent, + localEvents: [MutationEvent], + per disposition: MutationDisposition, + storageAdapter: StorageEngineAdapter, + completionPromise: @escaping Future.Promise) { + log.verbose("\(#function) disposition \(disposition)") + + switch disposition { + case .dropCandidateWithError(let dataStoreError): + completionPromise(.failure(dataStoreError)) + case .dropCandidateAndDeleteLocal: + Task { + do { + try await withThrowingTaskGroup(of: Void.self) { group in + for localEvent in localEvents { + group.addTask { + try await withCheckedThrowingContinuation { continuation in + storageAdapter.delete(untypedModelType: MutationEvent.self, + modelSchema: MutationEvent.schema, + withIdentifier: localEvent.identifier(schema: MutationEvent.schema), + condition: nil) { result in + continuation.resume(with: result) + } + } + } + } + try await group.waitForAll() + } + completionPromise(.success(candidate)) + } catch { + completionPromise(.failure(causedBy: error)) + } + } + case .saveCandidate: + save(mutationEvent: candidate, + storageAdapter: storageAdapter, + completionPromise: completionPromise) + case .replaceLocalWithCandidate: + guard !localEvents.isEmpty, let eventToUpdate = localEvents.first else { + // Should be caught upstream, but being defensive + save(mutationEvent: candidate, + storageAdapter: storageAdapter, + completionPromise: completionPromise) + return + } + + if localEvents.count > 1 { + // TODO: Handle errors from delete + localEvents + .suffix(from: 1) + .forEach { storageAdapter.delete(MutationEvent.self, + modelSchema: MutationEvent.schema, + withIdentifier: $0.identifier(schema: MutationEvent.schema), + condition: nil) { _ in } } + } + + let resolvedEvent = getResolvedEvent(for: eventToUpdate, applying: candidate) + + save(mutationEvent: resolvedEvent, + storageAdapter: storageAdapter, + completionPromise: completionPromise) + } + } + + private func getResolvedEvent(for originalEvent: MutationEvent, + applying candidate: MutationEvent) -> MutationEvent { + var resolvedEvent = originalEvent + resolvedEvent.json = candidate.json + + let updatedMutationType: String + if candidate.mutationType == GraphQLMutationType.delete.rawValue { + updatedMutationType = candidate.mutationType + } else { + updatedMutationType = originalEvent.mutationType + } + resolvedEvent.mutationType = updatedMutationType + + return resolvedEvent + } + + /// Saves the deconflicted mutationEvent, invokes `nextEventPromise` if it exists, and the save was successful, + /// and finally invokes the completion promise from the future of the original invocation of `submit` + func save( + mutationEvent: MutationEvent, + storageAdapter: StorageEngineAdapter, + completionPromise: @escaping Future.Promise + ) { + log.verbose("\(#function) mutationEvent: \(mutationEvent)") + let nextEventPromise = self.nextEventPromise.getAndSet(nil) + var eventToPersist = mutationEvent + if nextEventPromise != nil { + eventToPersist.inProcess = true + } + + storageAdapter.save(eventToPersist, condition: nil, eagerLoad: true) { result in + switch result { + case .failure(let dataStoreError): + self.log.verbose("\(#function): Error saving mutation event: \(dataStoreError)") + // restore the `nextEventPromise` value when failed to save mutation event + // as nextEventPromise is expecting to hanlde error of querying unprocessed mutaiton events + // not the failure of saving mutaiton event operation + nextEventPromise.ifSome(self.nextEventPromise.set(_:)) + case .success(let savedMutationEvent): + self.log.verbose("\(#function): saved \(savedMutationEvent)") + nextEventPromise.ifSome { + self.log.verbose("\(#function): invoking nextEventPromise with \(savedMutationEvent)") + $0(.success(savedMutationEvent)) + } + } + self.log.verbose("\(#function): invoking completionPromise with \(result)") + completionPromise(result) + } + + } + +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/AWSMutationDatabaseAdapter/AWSMutationDatabaseAdapter+MutationEventSource.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/AWSMutationDatabaseAdapter/AWSMutationDatabaseAdapter+MutationEventSource.swift new file mode 100644 index 0000000000..63db028a0f --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/AWSMutationDatabaseAdapter/AWSMutationDatabaseAdapter+MutationEventSource.swift @@ -0,0 +1,72 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Combine + +extension AWSMutationDatabaseAdapter: MutationEventSource { + + /// DataStore implements a FIFO queue of MutationEvents by using the local database + /// and querying for the earliest MutationEvent by its `createdAt` field. + /// + /// **Note**: In a previous revision of this code, this query used to filter on `InProcess` == `false` MutationEvents. + /// This was to skip over already in-flight mutation events and grab the next one. However, it was observed in highly + /// concurrent calls to `DataStore.start()` /`stop()` / `save()` that it will interrupt the + /// **OutgoingMutationQueue** of processing and deleting a **MutationEvent** . `DataStore.start()`, + /// which starts the remote sync engine, should perform a step to move all `InProcess` **MutationEvents** back + /// to false, however there's a timing issue that is difficult to pinpoint. **OutgoingMutationQueue**'s query manages + /// to pick up the second MutationEvent in the queue and sends it off, while the first one that is marked as `inProcess` + /// isn't being processed, likely that process was already cancelled. The query below was updated to always dequeue the + /// first regardless of `InProcess` in the [PR #3492](https://github.com/aws-amplify/amplify-swift/pull/3492). + /// By removing the filter, there is a small chance that the same event may be sent twice. Sending the event twice is idempotent + /// and the second response will be `ConditionalCheckFailed`. The `InProcess` flag is still needed for the + /// handling consecutive update scenarios. + /// + /// - Parameter completion: The first MutationEvent in the FIFO queue. + func getNextMutationEvent(completion: @escaping DataStoreCallback) { + log.verbose(#function) + + guard let storageAdapter = storageAdapter else { + completion(.failure(DataStoreError.nilStorageAdapter())) + return + } + let sort = QuerySortDescriptor(fieldName: MutationEvent.keys.createdAt.stringValue, order: .ascending) + storageAdapter.query( + MutationEvent.self, + predicate: nil, + sort: [sort], + paginationInput: nil, + eagerLoad: true) { result in + switch result { + case .failure(let dataStoreError): + completion(.failure(dataStoreError)) + case .success(let mutationEvents): + guard let mutationEvent = mutationEvents.first else { + self.nextEventPromise.set(completion) + return + } + if mutationEvent.inProcess { + log.verbose("The head of the MutationEvent queue was already inProcess (most likely interrupted process): \(mutationEvent)") + completion(.success(mutationEvent)) + } else { + self.markInProcess(mutationEvent: mutationEvent, + storageAdapter: storageAdapter, + completion: completion) + } + } + + } + } + + func markInProcess(mutationEvent: MutationEvent, + storageAdapter: StorageEngineAdapter, + completion: @escaping DataStoreCallback) { + var inProcessEvent = mutationEvent + inProcessEvent.inProcess = true + storageAdapter.save(inProcessEvent, condition: nil, eagerLoad: true, completion: completion) + } + +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/AWSMutationDatabaseAdapter/AWSMutationDatabaseAdapter.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/AWSMutationDatabaseAdapter/AWSMutationDatabaseAdapter.swift new file mode 100644 index 0000000000..28df8af034 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/AWSMutationDatabaseAdapter/AWSMutationDatabaseAdapter.swift @@ -0,0 +1,60 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Combine + +/// Interface for saving and loading MutationEvents from storage +final class AWSMutationDatabaseAdapter { + /// Possible outcomes of a "submit" based on inspecting the locally stored MutationEvents + enum MutationDisposition { + /// Drops the candidate without saving + case dropCandidateWithError(DataStoreError) + + /// Enqueues the candidate event as a new entry in the queue + case saveCandidate + + /// Replace all existing mutation events with the one candidate + case replaceLocalWithCandidate + + /// Happens if the queue has a .create and the incoming event is a .delete + case dropCandidateAndDeleteLocal + } + + weak var storageAdapter: StorageEngineAdapter? + + /// If a request for 'next event' comes in while the queue is empty, this promise will be set, so that the next + /// saved event can fulfill it + var nextEventPromise: AtomicValue.Promise?> + + /// Loads saved events from the database and delivers them to `mutationEventSubject` + init(storageAdapter: StorageEngineAdapter) throws { + self.storageAdapter = storageAdapter + self.nextEventPromise = .init(initialValue: nil) + log.verbose("Initialized") + } + +} + +extension AWSMutationDatabaseAdapter: DefaultLogger { + public static var log: Logger { + Amplify.Logging.logger(forCategory: CategoryType.dataStore.displayName, forNamespace: String(describing: self)) + } + public var log: Logger { + Self.log + } +} + +extension AWSMutationDatabaseAdapter: Resettable { + + func reset() async { + log.verbose("Resetting AWSMutationDatabaseAdapter") + storageAdapter = nil + nextEventPromise.set(nil) + log.verbose("Resetting AWSMutationDatabaseAdapter: finished") + } + +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/MutationEvent/AWSMutationEventPublisher.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/MutationEvent/AWSMutationEventPublisher.swift new file mode 100644 index 0000000000..34094af4b9 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/MutationEvent/AWSMutationEventPublisher.swift @@ -0,0 +1,106 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Combine +import Foundation + +/// Note: This publisher accepts only a single subscriber. It retains a weak reference to +/// its MutationEventSource even after downstream subscribers have issued a `cancel()`, +/// so that subsequent subscribers will still receive event notifications. +final class AWSMutationEventPublisher: Publisher { + typealias Output = MutationEvent + typealias Failure = DataStoreError + + private var subscription: MutationEventSubscription? + weak var eventSource: MutationEventSource? + + init(eventSource: MutationEventSource) { + log.verbose(#function) + self.eventSource = eventSource + } + + /// Receives a new subscriber, completing and dropping the old one if present + func receive(subscriber: S) where S: Subscriber, Failure == S.Failure, Output == S.Input { + log.verbose(#function) + subscription?.subscriber.receive(completion: .finished) + + let subscription = MutationEventSubscription(subscriber: subscriber, publisher: self) + self.subscription = subscription + subscriber.receive(subscription: subscription) + } + + func cancel() { + subscription = nil + } + + func request(_ demand: Subscribers.Demand) { + guard demand != .none else { + return + } + + if let max = demand.max, max < 1 { + return + } + + requestNextEvent() + } + + func requestNextEvent() { + log.verbose(#function) + let promise: DataStoreCallback = { [weak self] result in + guard let self = self, let subscriber = self.subscription?.subscriber else { + return + } + + switch result { + case .failure(let dataStoreError): + subscriber.receive(completion: .failure(dataStoreError)) + case .success(let mutationEvent): + let demand = subscriber.receive(mutationEvent) + DispatchQueue.global().async { + self.request(demand) + } + } + } + + DispatchQueue.global().async { + guard let eventSource = self.eventSource else { + self.log.verbose("AWSMutationPublisher.eventSource is unexpectedly nil") + return + } + + guard self.subscription != nil else { + self.log.debug("Subscription is nil, not getting next mutation event") + return + } + + eventSource.getNextMutationEvent(completion: promise) + } + } + +} + +extension AWSMutationEventPublisher: MutationEventPublisher { + var publisher: AnyPublisher { + eraseToAnyPublisher() + } +} + +extension AWSMutationEventPublisher: DefaultLogger { + public static var log: Logger { + Amplify.Logging.logger(forCategory: CategoryType.dataStore.displayName, forNamespace: String(describing: self)) + } + public var log: Logger { + Self.log + } +} + +extension AWSMutationEventPublisher: Resettable { + func reset() async { + eventSource = nil + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/MutationEvent/MutationEventClearState.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/MutationEvent/MutationEventClearState.swift new file mode 100644 index 0000000000..777204fdcb --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/MutationEvent/MutationEventClearState.swift @@ -0,0 +1,68 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +final class MutationEventClearState { + + let storageAdapter: StorageEngineAdapter + init(storageAdapter: StorageEngineAdapter) { + self.storageAdapter = storageAdapter + } + + func clearStateOutgoingMutations(completion: @escaping BasicClosure) { + let fields = MutationEvent.keys + let predicate = fields.inProcess == true + let sort = QuerySortDescriptor(fieldName: fields.createdAt.stringValue, order: .ascending) + storageAdapter.query(MutationEvent.self, + predicate: predicate, + sort: [sort], + paginationInput: nil, + eagerLoad: true) { result in + switch result { + case .failure(let dataStoreError): + log.error("Failed on clearStateOutgoingMutations: \(dataStoreError)") + case .success(let mutationEvents): + if !mutationEvents.isEmpty { + updateMutationsState(mutationEvents: mutationEvents, + completion: completion) + } else { + completion() + } + } + } + } + + private func updateMutationsState(mutationEvents: [MutationEvent], completion: @escaping BasicClosure) { + var numMutationEventsUpdated = 0 + for mutationEvent in mutationEvents { + var inProcessEvent = mutationEvent + inProcessEvent.inProcess = false + storageAdapter.save(inProcessEvent, condition: nil, eagerLoad: true, completion: { result in + switch result { + case .success: + numMutationEventsUpdated += 1 + if numMutationEventsUpdated >= mutationEvents.count { + completion() + } + case .failure(let error): + self.log.error("Failed to update mutationEvent:\(error)") + } + }) + } + } + +} + +extension MutationEventClearState: DefaultLogger { + public static var log: Logger { + Amplify.Logging.logger(forCategory: CategoryType.dataStore.displayName, forNamespace: String(describing: self)) + } + public var log: Logger { + Self.log + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/MutationEvent/MutationEventIngester.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/MutationEvent/MutationEventIngester.swift new file mode 100644 index 0000000000..d80566330c --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/MutationEvent/MutationEventIngester.swift @@ -0,0 +1,13 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Combine + +/// Ingests MutationEvents from and writes them to the MutationEvent persistent store +protocol MutationEventIngester: AnyObject { + func submit(mutationEvent: MutationEvent, completion: @escaping (Result) -> Void) +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/MutationEvent/MutationEventPublisher.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/MutationEvent/MutationEventPublisher.swift new file mode 100644 index 0000000000..5ca4149af9 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/MutationEvent/MutationEventPublisher.swift @@ -0,0 +1,13 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Combine + +/// Publishes mutation events to downstream subscribers for subsequent sync to the API. +protocol MutationEventPublisher: AnyObject, AmplifyCancellable { + var publisher: AnyPublisher { get } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/MutationEvent/MutationEventSource.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/MutationEvent/MutationEventSource.swift new file mode 100644 index 0000000000..d28df12a08 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/MutationEvent/MutationEventSource.swift @@ -0,0 +1,12 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + + +protocol MutationEventSource: AnyObject { + /// Gets the next available mutation event, marking it as "inProcess" before delivery + func getNextMutationEvent(completion: @escaping DataStoreCallback) +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/MutationEvent/MutationEventSubscriber.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/MutationEvent/MutationEventSubscriber.swift new file mode 100644 index 0000000000..3357f97427 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/MutationEvent/MutationEventSubscriber.swift @@ -0,0 +1,36 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Combine + +final class MutationEventSubscriber: Subscriber { + typealias Input = MutationEvent + typealias Failure = DataStoreError + + private let receiveSubscription: (Subscription) -> Void + private let receiveInput: (MutationEvent) -> Subscribers.Demand + private let receiveCompletion: (Subscribers.Completion) -> Void + + init(subscriber: S) where S: Subscriber, Failure == S.Failure, Input == S.Input { + self.receiveSubscription = subscriber.receive(subscription:) + self.receiveInput = subscriber.receive(_:) + self.receiveCompletion = subscriber.receive(completion:) + } + + func receive(subscription: Subscription) { + receiveSubscription(subscription) + } + + func receive(_ input: MutationEvent) -> Subscribers.Demand { + receiveInput(input) + } + + func receive(completion: Subscribers.Completion) { + receiveCompletion(completion) + } + +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/MutationEvent/MutationEventSubscription.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/MutationEvent/MutationEventSubscription.swift new file mode 100644 index 0000000000..6f05cc233b --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/MutationEvent/MutationEventSubscription.swift @@ -0,0 +1,41 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Combine + +final class MutationEventSubscription: Subscription { + + private var demand = Subscribers.Demand.none + let subscriber: MutationEventSubscriber + private weak var publisher: AWSMutationEventPublisher? + + init(subscriber: S, + publisher: AWSMutationEventPublisher) where S: Subscriber, + S.Failure == DataStoreError, + S.Input == MutationEvent { + self.subscriber = MutationEventSubscriber(subscriber: subscriber) + self.publisher = publisher + } + + func cancel() { + publisher?.cancel() + } + + func request(_ demand: Subscribers.Demand) { + self.demand = demand + publisher?.request(demand) + } +} + +extension MutationEventSubscription: DefaultLogger { + public static var log: Logger { + Amplify.Logging.logger(forCategory: CategoryType.dataStore.displayName, forNamespace: String(describing: self)) + } + public var log: Logger { + Self.log + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/OutgoingMutationQueue/MutationRetryNotifier.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/OutgoingMutationQueue/MutationRetryNotifier.swift new file mode 100644 index 0000000000..b78a0f2633 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/OutgoingMutationQueue/MutationRetryNotifier.swift @@ -0,0 +1,90 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import Combine + +final class MutationRetryNotifier { + private var lock: NSLock + private var nextSyncTimer: DispatchSourceTimer? + private var handlerQueue = DispatchQueue.global(qos: .default) + var retryMutationCallback: () -> Void + private var reachabilitySubscription: Subscription? + + init(advice: RequestRetryAdvice, + networkReachabilityPublisher: AnyPublisher?, + retryMutationCallback: @escaping BasicClosure) { + self.lock = NSLock() + + self.retryMutationCallback = retryMutationCallback + + let deadline = DispatchTime.now() + advice.retryInterval + scheduleTimer(at: deadline) + + networkReachabilityPublisher?.dropFirst().subscribe(self) + } + + deinit { + cancel() + } + + private func scheduleTimer(at deadline: DispatchTime) { + lock.execute { + nextSyncTimer = DispatchSource.makeOneOffDispatchSourceTimer(deadline: deadline, queue: handlerQueue) { + self.notifyCallback() + } + nextSyncTimer?.resume() + } + } + + func cancel() { + lock.execute { + reachabilitySubscription?.cancel() + nextSyncTimer?.cancel() + } + } + + func notifyCallback() { + // Call the cancel routine as the purpose of retry is fulfilled + cancel() + retryMutationCallback() + } +} + +extension MutationRetryNotifier: Subscriber { + func receive(subscription: Subscription) { + log.verbose(#function) + lock.execute { + reachabilitySubscription = subscription + } + subscription.request(.unlimited) + } + + func receive(_ reachabilityUpdate: ReachabilityUpdate) -> Subscribers.Demand { + if reachabilityUpdate.isOnline { + notifyCallback() + return .none + } + return .unlimited + } + + func receive(completion: Subscribers.Completion) { + log.verbose(#function) + lock.execute { + reachabilitySubscription?.cancel() + } + } +} + +extension MutationRetryNotifier: DefaultLogger { + public static var log: Logger { + Amplify.Logging.logger(forCategory: CategoryType.dataStore.displayName, forNamespace: String(describing: self)) + } + public var log: Logger { + Self.log + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/OutgoingMutationQueue/OutgoingMutationQueue+Action.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/OutgoingMutationQueue/OutgoingMutationQueue+Action.swift new file mode 100644 index 0000000000..f876fc956b --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/OutgoingMutationQueue/OutgoingMutationQueue+Action.swift @@ -0,0 +1,52 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Combine + +extension OutgoingMutationQueue { + + /// Actions are declarative, they say what I just did + enum Action { + // Startup/config actions + case initialized + case receivedStart(APICategoryGraphQLBehavior, MutationEventPublisher, IncomingEventReconciliationQueue?) + case receivedSubscription + + // Event loop + case enqueuedEvent + case processedEvent + + // Wrap-up + case receivedStop(BasicClosure) + case doneStopping + + // Terminal actions + case errored(AmplifyError) + + var displayName: String { + switch self { + case .enqueuedEvent: + return "enqueuedEvent" + case .errored: + return "errored" + case .initialized: + return "initialized" + case .processedEvent: + return "processedEvent" + case .receivedStop: + return "receivedStop" + case .doneStopping: + return "doneStopping" + case .receivedStart: + return "receivedStart" + case .receivedSubscription: + return "receivedSubscription" + } + } + } + +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/OutgoingMutationQueue/OutgoingMutationQueue+Resolver.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/OutgoingMutationQueue/OutgoingMutationQueue+Resolver.swift new file mode 100644 index 0000000000..fa9b947992 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/OutgoingMutationQueue/OutgoingMutationQueue+Resolver.swift @@ -0,0 +1,55 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Combine + +extension OutgoingMutationQueue { + + struct Resolver { + static func resolve(currentState: State, action: Action) -> State { + switch (currentState, action) { + + case (.notInitialized, .initialized): + return .stopped + + case (.stopped, .receivedStart(let api, let mutationEventPublisher, let reconciliationQueue)): + return .starting(api, mutationEventPublisher, reconciliationQueue) + + case (.starting, .receivedSubscription): + return .requestingEvent + + case (.requestingEvent, .enqueuedEvent): + return .waitingForEventToProcess + + case (.waitingForEventToProcess, .processedEvent): + return .requestingEvent + + case (.stopped, .receivedStop(let completion)), + (.starting, .receivedStop(let completion)), + (.requestingEvent, .receivedStop(let completion)), + (.waitingForEventToProcess, .receivedStop(let completion)), + (.inError, .receivedStop(let completion)): + return .stopping(completion) + + case (.stopping, .doneStopping): + return .stopped + + case (.inError, _): + return currentState + + case (_, .errored(let amplifyError)): + return .inError(amplifyError) + + default: + log.warn("Unexpected state transition. In \(currentState.displayName), got \(action.displayName)") + log.verbose("Unexpected state transition. In \(currentState), got \(action)") + return currentState + } + + } + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/OutgoingMutationQueue/OutgoingMutationQueue+State.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/OutgoingMutationQueue/OutgoingMutationQueue+State.swift new file mode 100644 index 0000000000..476e18d430 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/OutgoingMutationQueue/OutgoingMutationQueue+State.swift @@ -0,0 +1,48 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Combine + +extension OutgoingMutationQueue { + + /// States are descriptive, they say what is happening in the system right now + enum State { + // Startup/config states + case notInitialized + case stopped + case starting(APICategoryGraphQLBehavior, MutationEventPublisher, IncomingEventReconciliationQueue?) + + // Event loop + case requestingEvent + case waitingForEventToProcess + + // Wrap-up + case stopping(BasicClosure) + + // Terminal states + case inError(AmplifyError) + + var displayName: String { + switch self { + case .notInitialized: + return "notInitialized" + case .stopped: + return "stopped" + case .requestingEvent: + return "requestingEvent" + case .starting: + return "starting" + case .waitingForEventToProcess: + return "waitingForEventToProcess" + case .inError: + return "inError" + case .stopping: + return "stopping" + } + } + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/OutgoingMutationQueue/OutgoingMutationQueue.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/OutgoingMutationQueue/OutgoingMutationQueue.swift new file mode 100644 index 0000000000..82ac706dce --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/OutgoingMutationQueue/OutgoingMutationQueue.swift @@ -0,0 +1,433 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Combine +import Foundation + +/// Submits outgoing mutation events to the provisioned API +protocol OutgoingMutationQueueBehavior: AnyObject { + func stopSyncingToCloud(_ completion: @escaping BasicClosure) + func startSyncingToCloud(api: APICategoryGraphQLBehavior, + mutationEventPublisher: MutationEventPublisher, + reconciliationQueue: IncomingEventReconciliationQueue?) + var publisher: AnyPublisher { get } +} + +final class OutgoingMutationQueue: OutgoingMutationQueueBehavior { + + private let stateMachine: StateMachine + private var stateMachineSink: AnyCancellable? + + private let operationQueue: OperationQueue + + /// A DispatchQueue for synchronizing state on the mutation queue + private let mutationDispatchQueue = DispatchQueue( + label: "com.amazonaws.OutgoingMutationQueue", + target: DispatchQueue.global() + ) + + private weak var api: APICategoryGraphQLBehavior? + private weak var reconciliationQueue: IncomingEventReconciliationQueue? + + private var subscription: Subscription? + private let dataStoreConfiguration: DataStoreConfiguration + private let storageAdapter: StorageEngineAdapter + private var authModeStrategy: AuthModeStrategy + + private let outgoingMutationQueueSubject: PassthroughSubject + public var publisher: AnyPublisher { + return outgoingMutationQueueSubject.eraseToAnyPublisher() + } + + init(_ stateMachine: StateMachine? = nil, + storageAdapter: StorageEngineAdapter, + dataStoreConfiguration: DataStoreConfiguration, + authModeStrategy: AuthModeStrategy) { + self.storageAdapter = storageAdapter + self.dataStoreConfiguration = dataStoreConfiguration + self.authModeStrategy = authModeStrategy + + let operationQueue = OperationQueue() + operationQueue.name = "com.amazonaws.OutgoingMutationOperationQueue" + operationQueue.underlyingQueue = mutationDispatchQueue + operationQueue.maxConcurrentOperationCount = 1 + operationQueue.isSuspended = true + + self.operationQueue = operationQueue + + self.stateMachine = stateMachine ?? + StateMachine(initialState: .notInitialized, + resolver: OutgoingMutationQueue.Resolver.resolve(currentState:action:)) + + self.outgoingMutationQueueSubject = PassthroughSubject() + + self.stateMachineSink = self.stateMachine + .$state + .sink { [weak self] newState in + guard let self else { return } + + self.log.verbose("New state: \(newState)") + self.mutationDispatchQueue.async { + self.respond(to: newState) + } + } + + log.verbose("Initialized") + self.stateMachine.notify(action: .initialized) + } + + // MARK: - Public API + + func startSyncingToCloud(api: APICategoryGraphQLBehavior, + mutationEventPublisher: MutationEventPublisher, + reconciliationQueue: IncomingEventReconciliationQueue?) { + log.verbose(#function) + stateMachine.notify(action: .receivedStart(api, mutationEventPublisher, reconciliationQueue)) + } + + func stopSyncingToCloud(_ completion: @escaping BasicClosure) { + log.verbose(#function) + stateMachine.notify(action: .receivedStop(completion)) + } + + // MARK: - Responders + + /// Listens to incoming state changes and invokes the appropriate asynchronous methods in response. + private func respond(to newState: State) { + log.verbose("\(#function): \(newState)") + + switch newState { + + case .starting(let api, let mutationEventPublisher, let reconciliationQueue): + doStart(api: api, mutationEventPublisher: mutationEventPublisher, reconciliationQueue: reconciliationQueue) + + case .requestingEvent: + requestEvent() + + case .inError(let error): + // Maybe we have to notify the Hub? + log.error(error: error) + + case .stopping(let completion): + doStop(completion: completion) + + case .notInitialized, + .stopped, + .waitingForEventToProcess: + break + } + + } + + // MARK: - Lifecycle + + /// Responder method for `starting`. Starts the operation queue and subscribes to + /// the publisher. After subscribing to the publisher, return actions: + /// - receivedSubscription + private func doStart(api: APICategoryGraphQLBehavior, + mutationEventPublisher: MutationEventPublisher, + reconciliationQueue: IncomingEventReconciliationQueue?) { + log.verbose(#function) + self.api = api + self.reconciliationQueue = reconciliationQueue + + queryMutationEventsFromStorage { [weak self] in + guard let self = self else { return } + + self.operationQueue.isSuspended = false + // State machine notification to ".receivedSubscription" will be handled in `receive(subscription:)` + mutationEventPublisher.publisher.subscribe(self) + } + } + + /// Responder method for `stopping`. Cancels all operations on the operation queue, suspends it, + /// and cancels the publisher subscription. Return actions: + /// - doneStopping + private func doStop(completion: @escaping BasicClosure) { + log.verbose(#function) + doStopWithoutNotifyingStateMachine() + self.stateMachine.notify(action: .doneStopping) + completion() + } + + private func doStopWithoutNotifyingStateMachine() { + log.verbose(#function) + subscription?.cancel() + subscription = nil + operationQueue.cancelAllOperations() + operationQueue.isSuspended = true + operationQueue.waitUntilAllOperationsAreFinished() + } + + // MARK: - Event loop processing + + /// Responder method for `requestingEvent`. Requests an event from the subscription, and lets the subscription + /// handler enqueue it. Return actions: + /// - errored + private func requestEvent() { + log.verbose(#function) + guard let subscription = subscription else { + let dataStoreError = DataStoreError.unknown( + "No subscription when requesting event", + """ + The outgoing mutation queue attempted to request event without an active subscription. + \(AmplifyErrorMessages.reportBugToAWS()) + """ + ) + stateMachine.notify(action: .errored(dataStoreError)) + return + } + subscription.request(.max(1)) + } + + /// Invoked when the subscription receives an event, not as part of the state machine transition + private func enqueue(_ mutationEvent: MutationEvent) { + log.verbose(#function) + guard let api = api else { + let dataStoreError = DataStoreError.configuration( + "API is unexpectedly nil", + """ + The reference to api has been released while an ongoing mutation was being processed. + \(AmplifyErrorMessages.reportBugToAWS()) + """ + ) + stateMachine.notify(action: .errored(dataStoreError)) + return + } + + Task { + let syncMutationToCloudOperation = await SyncMutationToCloudOperation( + mutationEvent: mutationEvent, + getLatestSyncMetadata: { try? self.storageAdapter.queryMutationSyncMetadata(for: mutationEvent.modelId, modelName: mutationEvent.modelName) }, + api: api, + authModeStrategy: authModeStrategy + ) { [weak self] result in + self?.log.verbose( + "[SyncMutationToCloudOperation] mutationEvent finished: \(mutationEvent.id); result: \(result)") + self?.processSyncMutationToCloudResult(result, mutationEvent: mutationEvent, api: api) + } + + dispatchOutboxMutationEnqueuedEvent(mutationEvent: mutationEvent) + dispatchOutboxStatusEvent(isEmpty: false) + operationQueue.addOperation(syncMutationToCloudOperation) + stateMachine.notify(action: .enqueuedEvent) + } + } + + private func processSyncMutationToCloudResult(_ result: GraphQLOperation>.OperationResult, + mutationEvent: MutationEvent, + api: APICategoryGraphQLBehavior) { + if case let .success(graphQLResponse) = result { + if case let .success(graphQLResult) = graphQLResponse { + processSuccessEvent(mutationEvent, + mutationSync: graphQLResult) + } else if case let .failure(graphQLResponseError) = graphQLResponse { + processMutationErrorFromCloud(mutationEvent: mutationEvent, + api: api, + apiError: nil, + graphQLResponseError: graphQLResponseError) + } + } else if case let .failure(apiError) = result { + processMutationErrorFromCloud(mutationEvent: mutationEvent, + api: api, + apiError: apiError, + graphQLResponseError: nil) + } + } + + /// Process the successful response from API by updating the mutation events in + /// mutation event table having `nil` version + private func processSuccessEvent(_ mutationEvent: MutationEvent, + mutationSync: MutationSync?) { + if let mutationSync = mutationSync { + guard let reconciliationQueue = reconciliationQueue else { + let dataStoreError = DataStoreError.configuration( + "reconciliationQueue is unexpectedly nil", + """ + The reference to reconciliationQueue has been released while an ongoing mutation was being processed. + \(AmplifyErrorMessages.reportBugToAWS()) + """ + ) + stateMachine.notify(action: .errored(dataStoreError)) + return + } + reconciliationQueue.offer([mutationSync], modelName: mutationEvent.modelName) + MutationEvent.reconcilePendingMutationEventsVersion( + sent: mutationEvent, + received: mutationSync, + storageAdapter: storageAdapter + ) { _ in + self.completeProcessingEvent(mutationEvent, mutationSync: mutationSync) + } + } else { + completeProcessingEvent(mutationEvent) + } + } + + private func processMutationErrorFromCloud(mutationEvent: MutationEvent, + api: APICategoryGraphQLBehavior, + apiError: APIError?, + graphQLResponseError: GraphQLResponseError>?) { + if let apiError = apiError, apiError.isOperationCancelledError { + log.verbose("SyncMutationToCloudOperation was cancelled, aborting processing") + return + } + + let processMutationErrorFromCloudOperation = ProcessMutationErrorFromCloudOperation( + dataStoreConfiguration: dataStoreConfiguration, + mutationEvent: mutationEvent, + api: api, + storageAdapter: storageAdapter, + graphQLResponseError: graphQLResponseError, + apiError: apiError, + reconciliationQueue: reconciliationQueue + ) { [weak self] result in + guard let self = self else { + return + } + self.log.verbose("[ProcessMutationErrorFromCloudOperation] result: \(result)") + if case let .success(mutationEventOptional) = result, + let outgoingMutationEvent = mutationEventOptional { + self.outgoingMutationQueueSubject.send(outgoingMutationEvent) + } + self.completeProcessingEvent(mutationEvent) + } + operationQueue.addOperation(processMutationErrorFromCloudOperation) + } + + private func completeProcessingEvent(_ mutationEvent: MutationEvent, + mutationSync: MutationSync? = nil) { + // TODO: We shouldn't be inspecting state, we should be using granular enough states to + // ensure we don't encounter forbidden transitions. + if case .stopped = stateMachine.state { + return + } + + // This doesn't belong here--need to add a `delete` API to the MutationEventSource and pass a + // reference into the mutation queue. + Task { + do { + _ = try await Amplify.DataStore.delete(mutationEvent) + self.log.verbose("mutationEvent deleted successfully") + } catch { + self.log.verbose("mutationEvent failed to delete: error: \(error)") + } + if let mutationSync = mutationSync { + self.dispatchOutboxMutationProcessedEvent(mutationEvent: mutationEvent, + mutationSync: mutationSync) + } + self.queryMutationEventsFromStorage { [weak self] in + guard let self else { return } + self.stateMachine.notify(action: .processedEvent) + } + } + } + + private func queryMutationEventsFromStorage(onComplete: @escaping BasicClosure) { + let fields = MutationEvent.keys + let predicate = fields.inProcess == false || fields.inProcess == nil + + storageAdapter.query(MutationEvent.self, + predicate: predicate, + sort: nil, + paginationInput: nil, + eagerLoad: true) { [weak self] result in + guard let self else { return } + + switch result { + case .success(let events): + self.dispatchOutboxStatusEvent(isEmpty: events.isEmpty) + case .failure(let error): + log.error("Error querying mutation events: \(error)") + } + onComplete() + } + } + + private func dispatchOutboxMutationProcessedEvent(mutationEvent: MutationEvent, + mutationSync: MutationSync) { + do { + let localModel = try mutationEvent.decodeModel() + let outboxMutationProcessedEvent = OutboxMutationEvent + .fromModelWithMetadata(modelName: mutationEvent.modelName, + model: localModel, + mutationSync: mutationSync) + let payload = HubPayload(eventName: HubPayload.EventName.DataStore.outboxMutationProcessed, + data: outboxMutationProcessedEvent) + Amplify.Hub.dispatch(to: .dataStore, payload: payload) + } catch { + log.error("\(#function) Couldn't decode local model as \(mutationEvent.modelName) \(error)") + log.error("\(#function) Couldn't decode from \(mutationEvent.json)") + return + } + } + + private func dispatchOutboxMutationEnqueuedEvent(mutationEvent: MutationEvent) { + do { + let localModel = try mutationEvent.decodeModel() + let outboxMutationEnqueuedEvent = OutboxMutationEvent + .fromModelWithoutMetadata(modelName: mutationEvent.modelName, + model: localModel) + let payload = HubPayload(eventName: HubPayload.EventName.DataStore.outboxMutationEnqueued, + data: outboxMutationEnqueuedEvent) + Amplify.Hub.dispatch(to: .dataStore, payload: payload) + } catch { + log.error("\(#function) Couldn't decode local model as \(mutationEvent.modelName) \(error)") + log.error("\(#function) Couldn't decode from \(mutationEvent.json)") + return + } + } + + private func dispatchOutboxStatusEvent(isEmpty: Bool) { + let outboxStatusEvent = OutboxStatusEvent(isEmpty: isEmpty) + let outboxStatusEventPayload = HubPayload(eventName: HubPayload.EventName.DataStore.outboxStatus, + data: outboxStatusEvent) + Amplify.Hub.dispatch(to: .dataStore, payload: outboxStatusEventPayload) + } + +} + +extension OutgoingMutationQueue: Subscriber { + typealias Input = MutationEvent + typealias Failure = DataStoreError + + func receive(subscription: Subscription) { + log.verbose(#function) + // Technically, saving the subscription should probably be done in a separate method, but it seems overkill + // for a lightweight operation, not to mention that the transition from "receiving subscription" to "receiving + // event" happens so quickly that state management becomes difficult. + self.subscription = subscription + stateMachine.notify(action: .receivedSubscription) + } + + func receive(_ mutationEvent: MutationEvent) -> Subscribers.Demand { + log.verbose(#function) + enqueue(mutationEvent) + return .none + } + + // TODO: Resolve with an appropriate state machine notification + func receive(completion: Subscribers.Completion) { + log.verbose(#function) + subscription?.cancel() + } +} + +extension OutgoingMutationQueue: Resettable { + func reset() async { + doStopWithoutNotifyingStateMachine() + } +} + +extension OutgoingMutationQueue: DefaultLogger { + public static var log: Logger { + Amplify.Logging.logger(forCategory: CategoryType.dataStore.displayName, forNamespace: String(describing: self)) + } + public var log: Logger { + Self.log + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/OutgoingMutationQueue/ProcessMutationErrorFromCloudOperation.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/OutgoingMutationQueue/ProcessMutationErrorFromCloudOperation.swift new file mode 100644 index 0000000000..ec29f3d444 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/OutgoingMutationQueue/ProcessMutationErrorFromCloudOperation.swift @@ -0,0 +1,473 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Combine +import Foundation + +// swiftlint:disable type_body_length file_length +/// Checks the GraphQL error response for specific error scenarios related to data synchronziation to the local store. +/// 1. When there is an APIError which is for an unauthenticated user, call the error handler. +/// 2. When there is a "conditional request failed" error, then emit to the Hub a 'conditionalSaveFailed' event. +/// 3. When there is a "conflict unahandled" error, trigger the conflict handler and reconcile the state of the system. +class ProcessMutationErrorFromCloudOperation: AsynchronousOperation { + + typealias MutationSyncAPIRequest = GraphQLRequest + typealias MutationSyncCloudResult = GraphQLOperation>.OperationResult + + private let dataStoreConfiguration: DataStoreConfiguration + private let storageAdapter: StorageEngineAdapter + private let mutationEvent: MutationEvent + private let graphQLResponseError: GraphQLResponseError>? + private let apiError: APIError? + private let completion: (Result) -> Void + private var mutationOperation: AtomicValue>?> + private weak var api: APICategoryGraphQLBehavior? + private weak var reconciliationQueue: IncomingEventReconciliationQueue? + + init(dataStoreConfiguration: DataStoreConfiguration, + mutationEvent: MutationEvent, + api: APICategoryGraphQLBehavior, + storageAdapter: StorageEngineAdapter, + graphQLResponseError: GraphQLResponseError>? = nil, + apiError: APIError? = nil, + reconciliationQueue: IncomingEventReconciliationQueue? = nil, + completion: @escaping (Result) -> Void) { + self.dataStoreConfiguration = dataStoreConfiguration + self.mutationEvent = mutationEvent + self.api = api + self.storageAdapter = storageAdapter + self.graphQLResponseError = graphQLResponseError + self.apiError = apiError + self.reconciliationQueue = reconciliationQueue + self.completion = completion + self.mutationOperation = AtomicValue(initialValue: nil) + + super.init() + } + + override func main() { + log.verbose(#function) + + guard !isCancelled else { + return + } + + if let apiError = apiError { + if isAuthSignedOutError(apiError: apiError) { + log.verbose("User is signed out, passing error back to the error handler, and removing mutation event.") + } else if let underlyingError = apiError.underlyingError { + log.debug("Received APIError: \(apiError.localizedDescription) with underlying error: \(underlyingError.localizedDescription)") + } else { + log.debug("Received APIError: \(apiError.localizedDescription)") + } + dataStoreConfiguration.errorHandler(DataStoreError.api(apiError, mutationEvent)) + finish(result: .success(nil)) + return + } + + guard let graphQLResponseError = graphQLResponseError else { + dataStoreConfiguration.errorHandler( + DataStoreError.api(APIError.unknown("This is unexpected. Missing APIError and GraphQLError.", ""), + mutationEvent)) + finish(result: .success(nil)) + return + } + + guard case let .error(graphQLErrors) = graphQLResponseError else { + dataStoreConfiguration.errorHandler(DataStoreError.api(graphQLResponseError, mutationEvent)) + finish(result: .success(nil)) + return + } + + guard graphQLErrors.count == 1 else { + log.error("Received more than one error response: \(String(describing: graphQLResponseError))") + dataStoreConfiguration.errorHandler(DataStoreError.api(graphQLResponseError, mutationEvent)) + finish(result: .success(nil)) + return + } + + guard let graphQLError = graphQLErrors.first else { + dataStoreConfiguration.errorHandler(DataStoreError.api(graphQLResponseError, mutationEvent)) + finish(result: .success(nil)) + return + } + + if let extensions = graphQLError.extensions, case let .string(errorTypeValue) = extensions["errorType"] { + let errorType = AppSyncErrorType(errorTypeValue) + switch errorType { + case .conditionalCheck: + let payload = HubPayload(eventName: HubPayload.EventName.DataStore.conditionalSaveFailed, + data: mutationEvent) + Amplify.Hub.dispatch(to: .dataStore, payload: payload) + dataStoreConfiguration.errorHandler(DataStoreError.api(graphQLResponseError, mutationEvent)) + finish(result: .success(nil)) + case .conflictUnhandled: + processConflictUnhandled(extensions) + case .unauthorized: + log.debug("Unauthorized mutation \(errorType)") + dataStoreConfiguration.errorHandler(DataStoreError.api(graphQLResponseError, mutationEvent)) + finish(result: .success(nil)) + case .operationDisabled: + log.debug("Operation disabled \(errorType)") + dataStoreConfiguration.errorHandler(DataStoreError.api(graphQLResponseError, mutationEvent)) + finish(result: .success(nil)) + case .unknown(let errorType): + log.debug("Unhandled error with errorType \(errorType)") + dataStoreConfiguration.errorHandler(DataStoreError.api(graphQLResponseError, mutationEvent)) + finish(result: .success(nil)) + } + } else { + log.debug("GraphQLError missing extensions and errorType \(graphQLError)") + dataStoreConfiguration.errorHandler(DataStoreError.api(graphQLResponseError, mutationEvent)) + finish(result: .success(nil)) + } + } + + private func isAuthSignedOutError(apiError: APIError) -> Bool { + if case let .operationError(_, _, underlyingError) = apiError, + let authError = underlyingError as? AuthError, + case .signedOut = authError { + return true + } + + return false + } + + private func processConflictUnhandled(_ extensions: [String: JSONValue]) { + let localModel: Model + do { + localModel = try mutationEvent.decodeModel() + } catch { + let error = DataStoreError.unknown("Couldn't decode local model", "") + finish(result: .failure(error)) + return + } + + let remoteModel: MutationSync + switch getRemoteModel(extensions) { + case .success(let model): + remoteModel = model + case .failure(let error): + finish(result: .failure(error)) + return + } + let latestVersion = remoteModel.syncMetadata.version + + guard let mutationType = GraphQLMutationType(rawValue: mutationEvent.mutationType) else { + let dataStoreError = DataStoreError.decodingError( + "Invalid mutation type", + """ + The incoming mutation event had a mutation type of \(mutationEvent.mutationType), which does not + match any known GraphQL mutation type. Ensure you only send valid mutation types: + \(GraphQLMutationType.allCases) + """ + ) + log.error(error: dataStoreError) + finish(result: .failure(dataStoreError)) + return + } + + switch mutationType { + case .create: + let error = DataStoreError.unknown("Should never get conflict unhandled for create mutation", + "This indicates something unexpected was returned from the service") + finish(result: .failure(error)) + return + case .delete: + processLocalModelDeleted(localModel: localModel, remoteModel: remoteModel, latestVersion: latestVersion) + case .update: + processLocalModelUpdated(localModel: localModel, remoteModel: remoteModel, latestVersion: latestVersion) + } + } + + private func getRemoteModel(_ extensions: [String: JSONValue]) -> Result, Error> { + guard case let .object(data) = extensions["data"] else { + let error = DataStoreError.unknown("Missing remote model from the response from AppSync.", + "This indicates something unexpected was returned from the service") + return .failure(error) + } + do { + let serializedJSON = try JSONEncoder().encode(data) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = ModelDateFormatting.decodingStrategy + return .success(try decoder.decode(MutationSync.self, from: serializedJSON)) + } catch { + return .failure(error) + } + } + + private func processLocalModelDeleted( + localModel: Model, + remoteModel: MutationSync, + latestVersion: Int + ) { + guard !remoteModel.syncMetadata.deleted else { + log.debug("Conflict Unhandled for data deleted in local and remote. Nothing to do, skip processing.") + finish(result: .success(nil)) + return + } + + let conflictData = DataStoreConflictData(local: localModel, remote: remoteModel.model.instance) + dataStoreConfiguration.conflictHandler(conflictData) { result in + switch result { + case .applyRemote: + self.saveCreateOrUpdateMutation(remoteModel: remoteModel) + case .retryLocal: + let request = GraphQLRequest.deleteMutation(of: localModel, + modelSchema: localModel.schema, + version: latestVersion) + self.sendMutation(describedBy: request) + case .retry(let model): + guard let modelSchema = ModelRegistry.modelSchema(from: self.mutationEvent.modelName) else { + return Fatal.preconditionFailure(""" + Could not retrieve schema for the model \(self.mutationEvent.modelName), verify that datastore is + initialized. + """) + } + let request = GraphQLRequest.updateMutation(of: model, + modelSchema: modelSchema, + version: latestVersion) + self.sendMutation(describedBy: request) + } + } + } + + private func processLocalModelUpdated( + localModel: Model, + remoteModel: MutationSync, + latestVersion: Int + ) { + guard !remoteModel.syncMetadata.deleted else { + log.debug("Conflict Unhandled for updated local and deleted remote. Reconcile by deleting local") + saveDeleteMutation(remoteModel: remoteModel) + return + } + + let conflictData = DataStoreConflictData(local: localModel, remote: remoteModel.model.instance) + let latestVersion = remoteModel.syncMetadata.version + dataStoreConfiguration.conflictHandler(conflictData) { result in + switch result { + case .applyRemote: + self.saveCreateOrUpdateMutation(remoteModel: remoteModel) + case .retryLocal: + guard let modelSchema = ModelRegistry.modelSchema(from: self.mutationEvent.modelName) else { + return Fatal.preconditionFailure(""" + Could not retrieve schema for the model \(self.mutationEvent.modelName), verify that datastore is + initialized. + """) + } + let request = GraphQLRequest.updateMutation(of: localModel, + modelSchema: modelSchema, + version: latestVersion) + self.sendMutation(describedBy: request) + case .retry(let model): + guard let modelSchema = ModelRegistry.modelSchema(from: self.mutationEvent.modelName) else { + return Fatal.preconditionFailure(""" + Could not retrieve schema for the model \(self.mutationEvent.modelName), verify that datastore is + initialized. + """) + } + let request = GraphQLRequest.updateMutation(of: model, + modelSchema: modelSchema, + version: latestVersion) + self.sendMutation(describedBy: request) + } + } + } + + // MARK: Sync to cloud + + private func sendMutation(describedBy apiRequest: MutationSyncAPIRequest) { + guard !isCancelled else { + return + } + + guard let api = self.api else { + log.error("\(#function): API unexpectedly nil") + let apiError = APIError.unknown("API unexpectedly nil", "") + finish(result: .failure(apiError)) + return + } + + log.verbose("\(#function) sending mutation with data: \(apiRequest)") + Task { [weak self] in + do { + let result = try await api.mutate(request: apiRequest) + guard let self = self, !self.isCancelled else { + self?.finish(result: .failure(APIError.operationError("Mutation operation cancelled", ""))) + return + } + + self.log.verbose("sendMutationToCloud received asyncEvent: \(result)") + self.validate(cloudResult: result, request: apiRequest) + } catch { + self?.finish(result: .failure(APIError.operationError("Failed to do mutation", "", error))) + } + } + } + + private func validate(cloudResult: GraphQLResponse, request: MutationSyncAPIRequest) { + guard !isCancelled else { + return + } + + switch cloudResult { + case .success(let mutationSyncResult): + guard let reconciliationQueue = reconciliationQueue else { + let dataStoreError = DataStoreError.configuration( + "reconciliationQueue is unexpectedly nil", + """ + The reference to reconciliationQueue has been released while an ongoing mutation was being processed. + \(AmplifyErrorMessages.reportBugToAWS()) + """ + ) + finish(result: .failure(dataStoreError)) + return + } + + reconciliationQueue.offer([mutationSyncResult], modelName: mutationEvent.modelName) + case .failure(let graphQLResponseError): + dataStoreConfiguration.errorHandler(graphQLResponseError) + } + + finish(result: .success(nil)) + } + + // MARK: Reconcile Local Store + + private func saveDeleteMutation(remoteModel: MutationSync) { + log.verbose(#function) + let modelName = remoteModel.model.modelName + + guard let modelType = ModelRegistry.modelType(from: modelName) else { + let error = DataStoreError.invalidModelName("Invalid Model \(modelName)") + finish(result: .failure(error)) + return + } + + guard let modelSchema = ModelRegistry.modelSchema(from: modelName) else { + let error = DataStoreError.invalidModelName("Invalid Model \(modelName)") + finish(result: .failure(error)) + return + } + + let identifier = remoteModel.model.identifier(schema: modelSchema) + + storageAdapter.delete(untypedModelType: modelType, + modelSchema: modelSchema, + withIdentifier: identifier, + condition: nil) { response in + switch response { + case .failure(let dataStoreError): + let error = DataStoreError.unknown("Delete failed \(dataStoreError)", "") + finish(result: .failure(error)) + return + case .success: + self.saveMetadata(storageAdapter: storageAdapter, inProcessModel: remoteModel) + } + } + } + + private func saveCreateOrUpdateMutation(remoteModel: MutationSync) { + log.verbose(#function) + storageAdapter.save(untypedModel: remoteModel.model.instance, eagerLoad: true) { response in + switch response { + case .failure(let dataStoreError): + let error = DataStoreError.unknown("Save failed \(dataStoreError)", "") + self.finish(result: .failure(error)) + return + case .success(let savedModel): + let anyModel: AnyModel + do { + anyModel = try savedModel.eraseToAnyModel() + } catch { + let error = DataStoreError.unknown("eraseToAnyModel failed \(error)", "") + self.finish(result: .failure(error)) + return + } + let inProcessModel = MutationSync(model: anyModel, syncMetadata: remoteModel.syncMetadata) + self.saveMetadata(storageAdapter: self.storageAdapter, inProcessModel: inProcessModel) + } + } + } + + private func saveMetadata(storageAdapter: StorageEngineAdapter, + inProcessModel: MutationSync) { + log.verbose(#function) + storageAdapter.save(inProcessModel.syncMetadata, condition: nil, eagerLoad: true) { result in + switch result { + case .failure(let dataStoreError): + let error = DataStoreError.unknown("Save metadata failed \(dataStoreError)", "") + self.finish(result: .failure(error)) + return + case .success(let syncMetadata): + let appliedModel = MutationSync(model: inProcessModel.model, syncMetadata: syncMetadata) + self.notify(savedModel: appliedModel) + } + } + } + + private func notify(savedModel: MutationSync) { + log.verbose(#function) + + guard !isCancelled else { + return + } + + let mutationType: MutationEvent.MutationType + let version = savedModel.syncMetadata.version + if savedModel.syncMetadata.deleted { + mutationType = .delete + } else if version == 1 { + mutationType = .create + } else { + mutationType = .update + } + + guard let mutationEvent = try? MutationEvent(untypedModel: savedModel.model.instance, + mutationType: mutationType, + version: version) + else { + let error = DataStoreError.unknown("Could not create MutationEvent", "") + finish(result: .failure(error)) + return + } + + let payload = HubPayload(eventName: HubPayload.EventName.DataStore.syncReceived, + data: mutationEvent) + Amplify.Hub.dispatch(to: .dataStore, payload: payload) + + finish(result: .success(mutationEvent)) + } + + override func cancel() { + mutationOperation.get()?.cancel() + let error = DataStoreError(error: OperationCancelledError()) + finish(result: .failure(error)) + } + + private func finish(result: Result) { + mutationOperation.with { operation in + operation?.removeResultListener() + operation = nil + } + DispatchQueue.global().async { + self.completion(result) + } + finish() + } +} + +extension ProcessMutationErrorFromCloudOperation: DefaultLogger { + public static var log: Logger { + Amplify.Logging.logger(forCategory: CategoryType.dataStore.displayName, forNamespace: String(describing: self)) + } + public var log: Logger { + Self.log + } +} +// swiftlint:enable type_body_length file_length diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/OutgoingMutationQueue/SyncMutationToCloudOperation.mmd b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/OutgoingMutationQueue/SyncMutationToCloudOperation.mmd new file mode 100644 index 0000000000..f548ad2aa1 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/OutgoingMutationQueue/SyncMutationToCloudOperation.mmd @@ -0,0 +1,56 @@ +%% SyncToCloudOperation +sequenceDiagram + participant OutgoingMutationQueue + participant StorageAdapter + participant ProcessMutationErrorFromCloudOperation + participant SyncToCloudOperation + participant API + participant RequestRetryablePolicy + participant NetworkReachability + participant MutationRetryNotifier + participant Hub + + OutgoingMutationQueue->>SyncToCloudOperation: Add SyncToCloudOperation(MutationEvent, authType) + SyncToCloudOperation->>SyncToCloudOperation: Create GraphQL request (mutationType, authType) + SyncToCloudOperation->>API: API.mutate(request) + + alt Success + API->>SyncToCloudOperation: GraphQL response + SyncToCloudOperation->>OutgoingMutationQueue: finish + OutgoingMutationQueue->>StorageAdapter: delete MutationEvent + else Failure + loop while shouldRetry=true + API->>SyncToCloudOperation: GraphQL response + SyncToCloudOperation->>RequestRetryablePolicy: getRetryAdviceIfRetryable + alt NetworkError (notConnected, dnsLookupFailed, cannotConnectToHost, cannotFindHost, timedOut, etc.), HttpStatusError (Retry-After header, 500-599, 429) + RequestRetryablePolicy->>RequestRetryablePolicy: retryInterval = 2^attempt * 100 + jitter + RequestRetryablePolicy->>SyncToCloudOperation: retryAdvice=(shouldRetry=true, retryInterval) + SyncToCloudOperation->>MutationRetryNotifier: schedule next mutation (retryInterval) + MutationRetryNotifier->>NetworkReachability: get last network status + alt NetworkReachability(isOnline=true) + NetworkReachability->>MutationRetryNotifier: immediately perform next mutation + end + MutationRetryNotifier->>SyncToCloudOperation: perform next mutation + SyncToCloudOperation->>API: API.mutate(request) + else HttpStatusError (401) / OperationError (AuthError) + RequestRetryablePolicy->>SyncToCloudOperation: retry with next auth type immediately + SyncToCloudOperation->>API: API.mutate(request, authType[+1]) + else + RequestRetryablePolicy->>SyncToCloudOperation: retryAdvice=false + SyncToCloudOperation->>OutgoingMutationQueue: finish(result) + end + end + end + + alt SyncToCloudOperation finish with AppSync error + OutgoingMutationQueue->>ProcessMutationErrorFromCloudOperation: process AppSync error + alt conditional check failed + ProcessMutationErrorFromCloudOperation->>Hub: Event: conditionSaveFailed + ProcessMutationErrorFromCloudOperation->>OutgoingMutationQueue: finish + else conflict unhandled + ProcessMutationErrorFromCloudOperation->>ProcessMutationErrorFromCloudOperation: ProcessConflictUnhandled() + else unauthorized, operation disabled, unhandled errorType, unknown + ProcessMutationErrorFromCloudOperation->>OutgoingMutationQueue: finish + end + end + \ No newline at end of file diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/OutgoingMutationQueue/SyncMutationToCloudOperation.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/OutgoingMutationQueue/SyncMutationToCloudOperation.swift new file mode 100644 index 0000000000..a428a3b991 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/OutgoingMutationQueue/SyncMutationToCloudOperation.swift @@ -0,0 +1,381 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Combine +import Foundation + +/// Publishes a mutation event to the specified Cloud API. Upon receipt of the API response, validates to ensure it is +/// not a retriable error. If it is, attempts a retry until either success or terminal failure. Upon success or +/// terminal failure, publishes the event response to the appropriate ModelReconciliationQueue subject. +class SyncMutationToCloudOperation: AsynchronousOperation { + + typealias MutationSyncCloudResult = GraphQLOperation>.OperationResult + + private weak var api: APICategoryGraphQLBehavior? + private let mutationEvent: MutationEvent + private let getLatestSyncMetadata: () -> MutationSyncMetadata? + private let completion: GraphQLOperation>.ResultListener + private let requestRetryablePolicy: RequestRetryablePolicy + + private var networkReachabilityPublisher: AnyPublisher? + private var mutationOperation: Task? + private var mutationRetryNotifier: MutationRetryNotifier? + private var currentAttemptNumber: Int + private var authTypesIterator: AWSAuthorizationTypeIterator? + + init(mutationEvent: MutationEvent, + getLatestSyncMetadata: @escaping () -> MutationSyncMetadata?, + api: APICategoryGraphQLBehavior, + authModeStrategy: AuthModeStrategy, + networkReachabilityPublisher: AnyPublisher? = nil, + currentAttemptNumber: Int = 1, + requestRetryablePolicy: RequestRetryablePolicy? = RequestRetryablePolicy(), + completion: @escaping GraphQLOperation>.ResultListener) async { + self.mutationEvent = mutationEvent + self.getLatestSyncMetadata = getLatestSyncMetadata + self.api = api + self.networkReachabilityPublisher = networkReachabilityPublisher + self.completion = completion + self.currentAttemptNumber = currentAttemptNumber + self.requestRetryablePolicy = requestRetryablePolicy ?? RequestRetryablePolicy() + + if let modelSchema = ModelRegistry.modelSchema(from: mutationEvent.modelName), + let mutationType = GraphQLMutationType(rawValue: mutationEvent.mutationType) { + + self.authTypesIterator = await authModeStrategy.authTypesFor(schema: modelSchema, + operation: mutationType.toModelOperation()) + } + + super.init() + } + + override func main() { + log.verbose(#function) + + sendMutationToCloud(withAuthType: authTypesIterator?.next()) + } + + override func cancel() { + log.verbose(#function) + mutationOperation?.cancel() + mutationRetryNotifier?.cancel() + mutationRetryNotifier = nil + + let apiError = APIError(error: OperationCancelledError()) + finish(result: .failure(apiError)) + } + + private func sendMutationToCloud(withAuthType authType: AWSAuthorizationType? = nil) { + guard !isCancelled else { + return + } + + log.debug(#function) + guard let mutationType = GraphQLMutationType(rawValue: mutationEvent.mutationType) else { + let dataStoreError = DataStoreError.decodingError( + "Invalid mutation type", + """ + The incoming mutation event had a mutation type of \(mutationEvent.mutationType), which does not + match any known GraphQL mutation type. Ensure you only send valid mutation types: + \(GraphQLMutationType.allCases) + """ + ) + log.error(error: dataStoreError) + let apiError = APIError.unknown("Invalid mutation type", "", dataStoreError) + finish(result: .failure(apiError)) + return + } + + if let apiRequest = createAPIRequest(mutationType: mutationType, authType: authType) { + sendMutation(describedBy: apiRequest) + } + } + + /// Always retrieve and use the largest version when available. The source of the version comes + /// from either the MutationEvent itself, which represents the queue request, or the persisted version + /// from the metadata table. + /// + /// **Version in the Mutation Event**. If there are mulitple mutation events pending, each outgoing + /// mutation processing will result in synchronously updating the pending mutation's version + /// before enqueuing the mutation response for reconciliation. + /// + /// **Version persisted in the metadata table**: Reconciliation will persist the latest version in the + /// metadata table. In cases of quick consecutive updates, the MutationEvent's version could + /// be greater than the persisted since the MutationEvent is updated from the original thread that + /// processed the outgoing mutation. + private func getLatestVersion(_ mutationEvent: MutationEvent) -> Int? { + let latestSyncedMetadataVersion = getLatestSyncMetadata()?.version + let mutationEventVersion = mutationEvent.version + switch (latestSyncedMetadataVersion, mutationEventVersion) { + case let (.some(syncedVersion), .some(version)): + return max(syncedVersion, version) + case let (.some(syncedVersion), .none): + return syncedVersion + case let (.none, .some(version)): + return version + case (.none, .none): + return nil + } + } + + /// Creates a GraphQLRequest based on given `mutationType` + /// - Parameters: + /// - mutationType: mutation type + /// - authType: authorization type, if provided overrides the auth used to perform the API request + /// - Returns: a GraphQL request + private func createAPIRequest( + mutationType: GraphQLMutationType, + authType: AWSAuthorizationType? = nil + ) -> GraphQLRequest>? { + let version = getLatestVersion(mutationEvent) + var request: GraphQLRequest> + + do { + var graphQLFilter: GraphQLFilter? + if let graphQLFilterJSON = mutationEvent.graphQLFilterJSON { + graphQLFilter = try GraphQLFilterConverter.fromJSON(graphQLFilterJSON) + } + + switch mutationType { + case .delete: + let model = try mutationEvent.decodeModel() + guard let modelSchema = ModelRegistry.modelSchema(from: mutationEvent.modelName) else { + return Fatal.preconditionFailure(""" + Could not retrieve schema for the model \(mutationEvent.modelName), verify that datastore is + initialized. + """) + } + request = GraphQLRequest.deleteMutation(of: model, + modelSchema: modelSchema, + where: graphQLFilter, + version: version) + case .update: + let model = try mutationEvent.decodeModel() + guard let modelSchema = ModelRegistry.modelSchema(from: mutationEvent.modelName) else { + return Fatal.preconditionFailure(""" + Could not retrieve schema for the model \(mutationEvent.modelName), verify that datastore is + initialized. + """) + } + request = GraphQLRequest.updateMutation(of: model, + modelSchema: modelSchema, + where: graphQLFilter, + version: version) + case .create: + let model = try mutationEvent.decodeModel() + guard let modelSchema = ModelRegistry.modelSchema(from: mutationEvent.modelName) else { + return Fatal.preconditionFailure(""" + Could not retrieve schema for the model \(mutationEvent.modelName), verify that datastore is + initialized. + """) + } + request = GraphQLRequest.createMutation(of: model, + modelSchema: modelSchema, + version: version) + } + } catch { + let apiError = APIError.unknown("Couldn't decode model", "", error) + finish(result: .failure(apiError)) + return nil + } + + let awsPluginOptions = AWSAPIPluginDataStoreOptions(authType: authType, + modelName: mutationEvent.modelName) + request.options = GraphQLRequest.Options(pluginOptions: awsPluginOptions) + return request + } + + /// Creates and invokes a GraphQL mutation operation for `apiRequest` to the + /// service, and invokes `respond(toCloudResult:withAPIRequest:)` in the mutation's + /// completion handler + /// - Parameter apiRequest: The GraphQLRequest used to create the mutation operation + private func sendMutation(describedBy apiRequest: GraphQLRequest>) { + guard let api = api else { + log.error("\(#function): API unexpectedly nil") + let apiError = APIError.unknown("API unexpectedly nil", "") + finish(result: .failure(apiError)) + return + } + log.verbose("\(#function) sending mutation with sync data: \(apiRequest)") + + mutationOperation = Task { [weak self] in + let result: GraphQLResponse> + do { + result = try await api.mutate(request: apiRequest) + } catch { + result = .failure(.unknown("Failed to send sync mutation request", "", error)) + } + + self?.respond( + toCloudResult: result, + withAPIRequest: apiRequest + ) + } + + } + + private func respond( + toCloudResult result: GraphQLResponse>, + withAPIRequest apiRequest: GraphQLRequest> + ) { + guard !self.isCancelled else { + Amplify.log.debug("SyncMutationToCloudOperation cancelled, aborting") + return + } + + log.verbose("GraphQL mutation operation received result: \(result)") + validate(cloudResult: result, request: apiRequest) + } + + private func validate( + cloudResult: GraphQLResponse>, + request: GraphQLRequest> + ) { + guard !isCancelled, let mutationOperation, !mutationOperation.isCancelled else { + return + } + + if case .failure(let error) = cloudResult, + let apiError = error.underlyingError as? APIError { + let advice = getRetryAdviceIfRetryable(error: apiError) + + guard advice.shouldRetry else { + finish(result: .failure(apiError)) + return + } + + resolveReachabilityPublisher(request: request) + if let pluginOptions = request.options?.pluginOptions as? AWSAPIPluginDataStoreOptions, pluginOptions.authType != nil, + let nextAuthType = authTypesIterator?.next() { + scheduleRetry(advice: advice, withAuthType: nextAuthType) + } else { + scheduleRetry(advice: advice) + } + return + } + + finish(result: .success(cloudResult)) + } + + private func resolveReachabilityPublisher(request: GraphQLRequest>) { + if networkReachabilityPublisher == nil { + if let reachability = api as? APICategoryReachabilityBehavior { + do { + #if os(watchOS) + networkReachabilityPublisher = try reachability.reachabilityPublisher() + #else + networkReachabilityPublisher = try reachability.reachabilityPublisher(for: request.apiName) + #endif + } catch { + log.error("\(#function): Unable to listen on reachability: \(error)") + } + } + } + } + + func getRetryAdviceIfRetryable(error: APIError) -> RequestRetryAdvice { + var advice = RequestRetryAdvice(shouldRetry: false, retryInterval: DispatchTimeInterval.never) + + switch error { + case .networkError(_, _, let error): + // currently expecting APIOperationResponse to be an URLError + let urlError = error as? URLError + advice = requestRetryablePolicy.retryRequestAdvice(urlError: urlError, + httpURLResponse: nil, + attemptNumber: currentAttemptNumber) + + // we can't unify the following two cases (case 1 and case 2) as they have different associated values. + // should retry with a different authType if server returned "Unauthorized Error" + case .httpStatusError(_, let httpURLResponse) where httpURLResponse.statusCode == 401: // case 1 + advice = shouldRetryWithDifferentAuthType() + case .operationError(_, _, let error): // case 2 + if let authError = error as? AuthError { // case 2 + // Not all AuthError's are unauthorized errors. If `AuthError.sessionExpired` or `.signedOut` then + // the request never made it to the server. We should keep trying until the user is signed in. + // Otherwise we may be making the wrong determination to remove this mutation event. + switch authError { + case .sessionExpired, .signedOut: + // use `userAuthenticationRequired` to ensure advice to retry is true. + advice = requestRetryablePolicy.retryRequestAdvice(urlError: URLError(.userAuthenticationRequired), + httpURLResponse: nil, + attemptNumber: currentAttemptNumber) + default: + // should retry with a different authType if request failed locally with any other AuthError + advice = shouldRetryWithDifferentAuthType() + } + } + case .httpStatusError(_, let httpURLResponse): + advice = requestRetryablePolicy.retryRequestAdvice(urlError: nil, + httpURLResponse: httpURLResponse, + attemptNumber: currentAttemptNumber) + default: + break + } + return advice + } + + private func shouldRetryWithDifferentAuthType() -> RequestRetryAdvice { + let shouldRetry = authTypesIterator?.hasNext == true + return RequestRetryAdvice(shouldRetry: shouldRetry, retryInterval: .milliseconds(0)) + } + + private func scheduleRetry(advice: RequestRetryAdvice, + withAuthType authType: AWSAuthorizationType? = nil) { + log.verbose("\(#function) scheduling retry for mutation \(advice)") + mutationRetryNotifier = MutationRetryNotifier( + advice: advice, + networkReachabilityPublisher: networkReachabilityPublisher + ) { [weak self] in + self?.respondToMutationNotifierTriggered(withAuthType: authType) + } + currentAttemptNumber += 1 + } + + + private func respondToMutationNotifierTriggered(withAuthType authType: AWSAuthorizationType?) { + log.verbose("\(#function) mutationRetryNotifier triggered") + sendMutationToCloud(withAuthType: authType) + mutationRetryNotifier = nil + } + + /// Cleans up operation resources, finalizes AsynchronousOperation states, and invokes `completion` with `result` + /// - Parameter result: The MutationSyncCloudResult to pass to `completion` + private func finish(result: MutationSyncCloudResult) { + log.verbose(#function) + mutationOperation?.cancel() + mutationOperation = nil + + DispatchQueue.global().async { + self.completion(result) + } + + finish() + } +} + +// MARK: - GraphQLMutationType + toModelOperation +private extension GraphQLMutationType { + func toModelOperation() -> ModelOperation { + switch self { + case .create: + return .create + case .update: + return .update + case .delete: + return .delete + } + } +} + +extension SyncMutationToCloudOperation: DefaultLogger { + public static var log: Logger { + Amplify.Logging.logger(forCategory: CategoryType.dataStore.displayName, forNamespace: String(describing: self)) + } + public var log: Logger { + Self.log + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/RemoteSyncEngine+Action.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/RemoteSyncEngine+Action.swift new file mode 100644 index 0000000000..c8db5db860 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/RemoteSyncEngine+Action.swift @@ -0,0 +1,73 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Combine + +extension RemoteSyncEngine { + + /// Actions are declarative, they say what I just did + enum Action { + // Startup/config actions + case receivedStart + + case pausedSubscriptions + case pausedMutationQueue(StorageEngineAdapter) + case clearedStateOutgoingMutations(APICategoryGraphQLBehavior, StorageEngineAdapter) + case initializedSubscriptions + case performedInitialSync + case activatedCloudSubscriptions(APICategoryGraphQLBehavior, MutationEventPublisher, IncomingEventReconciliationQueue?) + case activatedMutationQueue + case notifiedSyncStarted + case cleanedUp(AmplifyError) + case cleanedUpForTermination + case scheduleRestart(AmplifyError) + case scheduledRestartTriggered + + // Terminal actions + case receivedCancel + case errored(AmplifyError) + case finished + + var displayName: String { + switch self { + case .receivedStart: + return "receivedStart" + case .pausedSubscriptions: + return "pausedSubscriptions" + case .pausedMutationQueue: + return "pausedMutationQueue" + case .clearedStateOutgoingMutations: + return "resetStateOutgoingMutations" + case .initializedSubscriptions: + return "initializedSubscriptions" + case .performedInitialSync: + return "performedInitialSync" + case .activatedCloudSubscriptions: + return "activatedCloudSubscriptions" + case .activatedMutationQueue: + return "activatedMutationQueue" + case .notifiedSyncStarted: + return "notifiedSyncStarted" + case .cleanedUp: + return "cleanedUp" + case .cleanedUpForTermination: + return "cleanedUpForTermination" + case .scheduleRestart: + return "scheduleRestart" + case .scheduledRestartTriggered: + return "scheduledRestartTriggered" + case .receivedCancel: + return "receivedCancel" + case .errored: + return "errored" + case .finished: + return "finished" + } + + } + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/RemoteSyncEngine+AuthModeStrategyDelegate.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/RemoteSyncEngine+AuthModeStrategyDelegate.swift new file mode 100644 index 0000000000..1b0f31b89f --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/RemoteSyncEngine+AuthModeStrategyDelegate.swift @@ -0,0 +1,36 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Combine +import Foundation + +extension RemoteSyncEngine: AuthModeStrategyDelegate { + + func isUserLoggedIn() async -> Bool { + if let authProviderFactory = api as? APICategoryAuthProviderFactoryBehavior, + let oidcAuthProvider = authProviderFactory.apiAuthProviderFactory().oidcAuthProvider() { + + // if OIDC is used as authentication provider + // use `getLatestAuthToken` + var isLoggedInWithOIDC = false + do { + _ = try await oidcAuthProvider.getLatestAuthToken() + isLoggedInWithOIDC = true + } catch { + isLoggedInWithOIDC = false + } + + return isLoggedInWithOIDC + } + + guard let auth = auth else { + return false + } + + return (try? await auth.getCurrentUser()) != nil + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/RemoteSyncEngine+IncomingEventReconciliationQueueEvent.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/RemoteSyncEngine+IncomingEventReconciliationQueueEvent.swift new file mode 100644 index 0000000000..20d2446742 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/RemoteSyncEngine+IncomingEventReconciliationQueueEvent.swift @@ -0,0 +1,78 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Combine +import Foundation + +extension RemoteSyncEngine { + func onReceiveCompletion(receiveCompletion: Subscribers.Completion) { + switch stateMachine.state { + case .initializingSubscriptions: + notifyError(receiveCompletion: receiveCompletion) + case .syncEngineActive: + notifyError(receiveCompletion: receiveCompletion) + default: + break + } + } + + func notifyError(receiveCompletion: Subscribers.Completion) { + switch receiveCompletion { + case .failure(let error): + stateMachine.notify(action: .errored(error)) + case .finished: + stateMachine.notify(action: .finished) + } + } + + func onReceive(receiveValue: IncomingEventReconciliationQueueEvent) { + switch receiveValue { + case .initialized: + log.verbose("[InitializeSubscription.5] RemoteSyncEngine IncomingEventReconciliationQueueEvent.initialized") + log.verbose("[Lifecycle event 1]: subscriptionsEstablished") + let payload = HubPayload(eventName: HubPayload.EventName.DataStore.subscriptionsEstablished) + Amplify.Hub.dispatch(to: .dataStore, payload: payload) + remoteSyncTopicPublisher.send(.subscriptionsInitialized) + stateMachine.notify(action: .initializedSubscriptions) + case .started: + log.verbose("[InitializeSubscription.6] RemoteSyncEngine IncomingEventReconciliationQueueEvent.started") + guard let api = self.api else { + let error = DataStoreError.internalOperation("api is unexpectedly `nil`", "", nil) + stateMachine.notify(action: .errored(error)) + return + } + remoteSyncTopicPublisher.send(.subscriptionsActivated) + stateMachine.notify(action: .activatedCloudSubscriptions(api, + mutationEventPublisher, + reconciliationQueue)) + case .paused: + remoteSyncTopicPublisher.send(.subscriptionsPaused) + case .idle, .mutationEventDropped, .mutationEventApplied: + break + } + } + + func onReceive(receiveValue: IncomingSyncEventEmitterEvent) { + switch receiveValue { + case .mutationEventApplied(let mutationEvent): + remoteSyncTopicPublisher.send(.mutationEvent(mutationEvent)) + case .mutationEventDropped: + break + case .modelSyncedEvent(let modelSyncedEvent): + remoteSyncTopicPublisher.send(.modelSyncedEvent(modelSyncedEvent)) + case .syncQueriesReadyEvent: + remoteSyncTopicPublisher.send(.syncQueriesReadyEvent) + } + } + + func onReceive(receiveValue: IncomingReadyEventEmitter) { + switch receiveValue { + case .readyEvent: + remoteSyncTopicPublisher.send(.readyEvent) + } + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/RemoteSyncEngine+Resolver.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/RemoteSyncEngine+Resolver.swift new file mode 100644 index 0000000000..1e291e63cc --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/RemoteSyncEngine+Resolver.swift @@ -0,0 +1,73 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Combine + +extension RemoteSyncEngine { + struct Resolver { + // swiftlint:disable cyclomatic_complexity + static func resolve(currentState: State, action: Action) -> State { + switch (currentState, action) { + case (.notStarted, .receivedStart): + return .pausingSubscriptions + + case (.pausingSubscriptions, .pausedSubscriptions): + return .pausingMutationQueue + + case (.pausingMutationQueue, .pausedMutationQueue(let storageEngineAdapter)): + return .clearingStateOutgoingMutations(storageEngineAdapter) + + case (.clearingStateOutgoingMutations, .clearedStateOutgoingMutations(let api, let storageEngineAdapter)): + return .initializingSubscriptions(api, storageEngineAdapter) + + case (.initializingSubscriptions, .initializedSubscriptions): + return .performingInitialSync + case (.initializingSubscriptions, .errored(let error)): + return .cleaningUp(error) + + case (.performingInitialSync, .performedInitialSync): + return .activatingCloudSubscriptions + case (.performingInitialSync, .errored(let error)): + return .cleaningUp(error) + + case (.activatingCloudSubscriptions, .activatedCloudSubscriptions(let api, + let mutationEventPublisher, + let reconciliationQueue)): + return .activatingMutationQueue(api, mutationEventPublisher, reconciliationQueue) + case (.activatingCloudSubscriptions, .errored(let error)): + return .cleaningUp(error) + + case (.activatingMutationQueue, .activatedMutationQueue): + return .notifyingSyncStarted + case (.activatingMutationQueue, .errored(let error)): + return .cleaningUp(error) + + case (.notifyingSyncStarted, .notifiedSyncStarted): + return .syncEngineActive + + case (.syncEngineActive, .errored(let error)): + return .cleaningUp(error) + + case (_, .finished): + return .cleaningUpForTermination + + case (.cleaningUp, .cleanedUp(let error)): + return .schedulingRestart(error) + case (.cleaningUpForTermination, .cleanedUpForTermination): + return .terminate + + case (.schedulingRestart, .scheduledRestartTriggered): + return .pausingSubscriptions + + default: + log.warn("Unexpected state transition. In \(currentState.displayName), got \(action.displayName)") + log.verbose("Unexpected state transition. In \(currentState), got \(action)") + return currentState + } + } + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/RemoteSyncEngine+Retryable.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/RemoteSyncEngine+Retryable.swift new file mode 100644 index 0000000000..b237ef1187 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/RemoteSyncEngine+Retryable.swift @@ -0,0 +1,78 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// All methods in this extension must be invoked from workQueue (as in during a `respond` call +extension RemoteSyncEngine { + + func resetCurrentAttemptNumber() { + currentAttemptNumber = 1 + } + + func scheduleRestartOrTerminate(error: AmplifyError) { + let advice = getRetryAdvice(error: error) + if advice.shouldRetry { + scheduleRestart(advice: advice) + } else { + remoteSyncTopicPublisher.send(completion: .failure(DataStoreError.api(error))) + if let completionBlock = finishedCompletionBlock { + completionBlock(.failure(causedBy: error)) + finishedCompletionBlock = nil + } + } + } + + private func getRetryAdvice(error: Error) -> RequestRetryAdvice { + var urlErrorOptional: URLError? + if let dataStoreError = error as? DataStoreError, + let underlyingError = dataStoreError.underlyingError as? URLError { + urlErrorOptional = underlyingError + } else if let urlError = error as? URLError { + urlErrorOptional = urlError + } else if let dataStoreError = error as? DataStoreError, + case .api(let amplifyError, _) = dataStoreError, + let apiError = amplifyError as? APIError, + case .networkError(_, _, let error) = apiError, + let urlError = error as? URLError { + urlErrorOptional = urlError + } + + let advice = requestRetryablePolicy.retryRequestAdvice(urlError: urlErrorOptional, + httpURLResponse: nil, + attemptNumber: currentAttemptNumber) + return advice + } + + private func scheduleRestart(advice: RequestRetryAdvice) { + log.verbose("\(#function) scheduling retry for restarting remote sync engine") + remoteSyncTopicPublisher.send(.schedulingRestart) + resolveReachabilityPublisher() + mutationRetryNotifier = MutationRetryNotifier( + advice: advice, + networkReachabilityPublisher: networkReachabilityPublisher + ) { + self.taskQueue.async { + self.mutationRetryNotifier = nil + self.stateMachine.notify(action: .scheduledRestartTriggered) + } + } + currentAttemptNumber += 1 + } + + private func resolveReachabilityPublisher() { + if networkReachabilityPublisher == nil { + if let reachability = api as? APICategoryReachabilityBehavior { + do { + networkReachabilityPublisher = try reachability.reachabilityPublisher() + } catch { + log.error("\(#function): Unable to listen on reachability: \(error)") + } + } + } + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/RemoteSyncEngine+State.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/RemoteSyncEngine+State.swift new file mode 100644 index 0000000000..84d796545b --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/RemoteSyncEngine+State.swift @@ -0,0 +1,66 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Combine + +extension RemoteSyncEngine { + + /// States are descriptive, they say what is happening in the system right now + enum State { + case notStarted + + case pausingSubscriptions + case pausingMutationQueue + case clearingStateOutgoingMutations(StorageEngineAdapter) + case initializingSubscriptions(APICategoryGraphQLBehavior, StorageEngineAdapter) + case performingInitialSync + case activatingCloudSubscriptions + case activatingMutationQueue(APICategoryGraphQLBehavior, MutationEventPublisher, IncomingEventReconciliationQueue?) + case notifyingSyncStarted + + case syncEngineActive + + case cleaningUp(AmplifyError) + case cleaningUpForTermination + + case schedulingRestart(AmplifyError) + case terminate + + var displayName: String { + switch self { + case .notStarted: + return "notStarted" + case .pausingSubscriptions: + return "pausingSubscriptions" + case .pausingMutationQueue: + return "pausingMutationQueue" + case .clearingStateOutgoingMutations: + return "clearingStateOutgoingMutations" + case .initializingSubscriptions: + return "initializingSubscriptions" + case .performingInitialSync: + return "performingInitialSync" + case .activatingCloudSubscriptions: + return "activatingCloudSubscriptions" + case .activatingMutationQueue: + return "activatingMutationQueue" + case .notifyingSyncStarted: + return "notifyingSyncStarted" + case .syncEngineActive: + return "syncEngineActive" + case .cleaningUp: + return "cleaningUp" + case .cleaningUpForTermination: + return "cleaningUpForTermination" + case .schedulingRestart: + return "schedulingRestart" + case .terminate: + return "terminate" + } + } + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/RemoteSyncEngine.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/RemoteSyncEngine.swift new file mode 100644 index 0000000000..575a9f0b54 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/RemoteSyncEngine.swift @@ -0,0 +1,450 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Combine +import Foundation + +// swiftlint:disable type_body_length file_length +class RemoteSyncEngine: RemoteSyncEngineBehavior { + + weak var storageAdapter: StorageEngineAdapter? + + private var dataStoreConfiguration: DataStoreConfiguration + + // Authorization mode strategy + private var authModeStrategy: AuthModeStrategy + + // Assigned at `start` + weak var api: APICategoryGraphQLBehavior? + weak var auth: AuthCategoryBehavior? + + // Assigned and released inside `performInitialQueries`, but we maintain a reference so we can `reset` + private var initialSyncOrchestrator: InitialSyncOrchestrator? + private let initialSyncOrchestratorFactory: InitialSyncOrchestratorFactory + + var syncEventEmitter: SyncEventEmitter? + var readyEventEmitter: ReadyEventEmitter? + + private let mutationEventIngester: MutationEventIngester + let mutationEventPublisher: MutationEventPublisher + private let outgoingMutationQueue: OutgoingMutationQueueBehavior + private var outgoingMutationQueueSink: AnyCancellable? + + private var reconciliationQueueSink: AnyCancellable? + private var syncEventEmitterSink: AnyCancellable? + private var readyEventEmitterSink: AnyCancellable? + + let remoteSyncTopicPublisher: PassthroughSubject + var publisher: AnyPublisher { + return remoteSyncTopicPublisher.eraseToAnyPublisher() + } + + /// Synchronizes startup operations + let taskQueue = TaskQueue() + + // Assigned at `setUpCloudSubscriptions` + var reconciliationQueue: IncomingEventReconciliationQueue? + var reconciliationQueueFactory: IncomingEventReconciliationQueueFactory + + let stateMachine: StateMachine + private var stateMachineSink: AnyCancellable? + + var networkReachabilityPublisher: AnyPublisher? + private var networkReachabilitySink: AnyCancellable? + var mutationRetryNotifier: MutationRetryNotifier? + let requestRetryablePolicy: RequestRetryablePolicy + var currentAttemptNumber: Int + + var finishedCompletionBlock: DataStoreCallback? + + /// Initializes the CloudSyncEngine with the specified storageAdapter as the provider for persistence of + /// MutationEvents, sync metadata, and conflict resolution metadata. Immediately initializes the incoming mutation + /// queue so it can begin accepting incoming mutations from DataStore. + convenience init(storageAdapter: StorageEngineAdapter, + dataStoreConfiguration: DataStoreConfiguration, + outgoingMutationQueue: OutgoingMutationQueueBehavior? = nil, + initialSyncOrchestratorFactory: InitialSyncOrchestratorFactory? = nil, + reconciliationQueueFactory: IncomingEventReconciliationQueueFactory? = nil, + stateMachine: StateMachine? = nil, + networkReachabilityPublisher: AnyPublisher? = nil, + requestRetryablePolicy: RequestRetryablePolicy? = nil) throws { + + let mutationDatabaseAdapter = try AWSMutationDatabaseAdapter(storageAdapter: storageAdapter) + let awsMutationEventPublisher = AWSMutationEventPublisher(eventSource: mutationDatabaseAdapter) + + // initialize auth strategy + let resolvedAuthStrategy: AuthModeStrategy = dataStoreConfiguration.authModeStrategyType.resolveStrategy() + + let outgoingMutationQueue = outgoingMutationQueue ?? + OutgoingMutationQueue(storageAdapter: storageAdapter, + dataStoreConfiguration: dataStoreConfiguration, + authModeStrategy: resolvedAuthStrategy) + // swiftlint:disable line_length + let reconciliationQueueFactory = reconciliationQueueFactory ?? + AWSIncomingEventReconciliationQueue.init(modelSchemas:api:storageAdapter:syncExpressions:auth:authModeStrategy:modelReconciliationQueueFactory:disableSubscriptions:) + // swiftlint:enable line_length + let initialSyncOrchestratorFactory = initialSyncOrchestratorFactory ?? + AWSInitialSyncOrchestrator.init(dataStoreConfiguration:authModeStrategy:api:reconciliationQueue:storageAdapter:) + + let resolver = RemoteSyncEngine.Resolver.resolve(currentState:action:) + let stateMachine = stateMachine ?? StateMachine(initialState: .notStarted, + resolver: resolver) + + let requestRetryablePolicy = requestRetryablePolicy ?? RequestRetryablePolicy() + + self.init(storageAdapter: storageAdapter, + dataStoreConfiguration: dataStoreConfiguration, + authModeStrategy: resolvedAuthStrategy, + outgoingMutationQueue: outgoingMutationQueue, + mutationEventIngester: mutationDatabaseAdapter, + mutationEventPublisher: awsMutationEventPublisher, + initialSyncOrchestratorFactory: initialSyncOrchestratorFactory, + reconciliationQueueFactory: reconciliationQueueFactory, + stateMachine: stateMachine, + networkReachabilityPublisher: networkReachabilityPublisher, + requestRetryablePolicy: requestRetryablePolicy) + } + + init(storageAdapter: StorageEngineAdapter, + dataStoreConfiguration: DataStoreConfiguration, + authModeStrategy: AuthModeStrategy, + outgoingMutationQueue: OutgoingMutationQueueBehavior, + mutationEventIngester: MutationEventIngester, + mutationEventPublisher: MutationEventPublisher, + initialSyncOrchestratorFactory: @escaping InitialSyncOrchestratorFactory, + reconciliationQueueFactory: @escaping IncomingEventReconciliationQueueFactory, + stateMachine: StateMachine, + networkReachabilityPublisher: AnyPublisher?, + requestRetryablePolicy: RequestRetryablePolicy) { + self.storageAdapter = storageAdapter + self.dataStoreConfiguration = dataStoreConfiguration + self.authModeStrategy = authModeStrategy + self.mutationEventIngester = mutationEventIngester + self.mutationEventPublisher = mutationEventPublisher + self.outgoingMutationQueue = outgoingMutationQueue + self.initialSyncOrchestratorFactory = initialSyncOrchestratorFactory + self.reconciliationQueueFactory = reconciliationQueueFactory + self.remoteSyncTopicPublisher = PassthroughSubject() + self.networkReachabilityPublisher = networkReachabilityPublisher + self.requestRetryablePolicy = requestRetryablePolicy + + self.currentAttemptNumber = 1 + + self.stateMachine = stateMachine + self.stateMachineSink = self.stateMachine + .$state + .sink { [weak self] newState in + guard let self = self else { + return + } + self.log.verbose("New state: \(newState)") + self.taskQueue.async { + await self.respond(to: newState) + } + } + + self.authModeStrategy.authDelegate = self + } + + // swiftlint:disable cyclomatic_complexity + /// Listens to incoming state changes and invokes the appropriate asynchronous methods in response. + private func respond(to newState: State) async { + log.verbose("\(#function): \(newState)") + + switch newState { + case .notStarted: + break + case .pausingSubscriptions: + pauseSubscriptions() + case .pausingMutationQueue: + pauseMutations() + case .clearingStateOutgoingMutations(let storageAdapter): + clearStateOutgoingMutations(storageAdapter: storageAdapter) + case .initializingSubscriptions(let api, let storageAdapter): + await initializeSubscriptions(api: api, storageAdapter: storageAdapter) + case .performingInitialSync: + performInitialSync() + case .activatingCloudSubscriptions: + activateCloudSubscriptions() + case .activatingMutationQueue(let api, let mutationEventPublisher, let reconciliationQueue): + startMutationQueue(api: api, + mutationEventPublisher: mutationEventPublisher, + reconciliationQueue: reconciliationQueue) + case .notifyingSyncStarted: + notifySyncStarted() + + case .syncEngineActive: + log.debug("RemoteSyncEngine SyncEngineActive") + + case .cleaningUp(let error): + cleanup(error: error) + + case .cleaningUpForTermination: + cleanupForTermination() + + case .schedulingRestart(let error): + scheduleRestartOrTerminate(error: error) + + case .terminate: + terminate() + } + } + // swiftlint:enable cyclomatic_complexity + + func start(api: APICategoryGraphQLBehavior, auth: AuthCategoryBehavior?) { + guard storageAdapter != nil else { + log.error(error: DataStoreError.nilStorageAdapter()) + remoteSyncTopicPublisher.send(completion: .failure(DataStoreError.nilStorageAdapter())) + return + } + self.api = api + self.auth = auth + + if networkReachabilityPublisher == nil, + let reachability = api as? APICategoryReachabilityBehavior { + do { + networkReachabilityPublisher = try reachability.reachabilityPublisher() + } catch { + log.error("\(#function): Unable to listen on reachability: \(error)") + } + } + + networkReachabilitySink = + networkReachabilityPublisher? + .sink { [weak self] in self?.onReceiveNetworkStatus(networkStatus: $0) } + + remoteSyncTopicPublisher.send(.storageAdapterAvailable) + stateMachine.notify(action: .receivedStart) + } + + func stop(completion: @escaping DataStoreCallback) { + if finishedCompletionBlock == nil { + finishedCompletionBlock = completion + } + stateMachine.notify(action: .finished) + } + + func isSyncing() -> Bool { + if case .notStarted = stateMachine.state { + return false + } + return true + } + + func terminate() { + remoteSyncTopicPublisher.send(completion: .finished) + cleanup() + if let completionBlock = finishedCompletionBlock { + completionBlock(.successfulVoid) + finishedCompletionBlock = nil + } + } + + func submit(_ mutationEvent: MutationEvent, completion: @escaping (Result) -> Void) { + mutationEventIngester.submit(mutationEvent: mutationEvent, completion: completion) + } + + // MARK: - Startup sequence + private func pauseSubscriptions() { + log.debug(#function) + reconciliationQueue?.pause() + + remoteSyncTopicPublisher.send(.subscriptionsPaused) + stateMachine.notify(action: .pausedSubscriptions) + } + + private func pauseMutations() { + log.debug(#function) + outgoingMutationQueue.stopSyncingToCloud { + self.remoteSyncTopicPublisher.send(.mutationsPaused) + if let storageAdapter = self.storageAdapter { + self.stateMachine.notify(action: .pausedMutationQueue(storageAdapter)) + } + } + } + + private func clearStateOutgoingMutations(storageAdapter: StorageEngineAdapter) { + log.debug(#function) + let mutationEventClearState = MutationEventClearState(storageAdapter: storageAdapter) + mutationEventClearState.clearStateOutgoingMutations { + if let api = self.api { + self.remoteSyncTopicPublisher.send(.clearedStateOutgoingMutations) + self.stateMachine.notify(action: .clearedStateOutgoingMutations(api, storageAdapter)) + } + } + } + + private func initializeSubscriptions(api: APICategoryGraphQLBehavior, + storageAdapter: StorageEngineAdapter) async { + log.debug("[InitializeSubscription] \(#function)") + let syncableModelSchemas = ModelRegistry.modelSchemas.filter { $0.isSyncable } + reconciliationQueue = await reconciliationQueueFactory(syncableModelSchemas, + api, + storageAdapter, + dataStoreConfiguration.syncExpressions, + auth, + authModeStrategy, + nil, + dataStoreConfiguration.disableSubscriptions) + reconciliationQueueSink = reconciliationQueue? + .publisher + .sink( + receiveCompletion: { [weak self] in self?.onReceiveCompletion(receiveCompletion: $0) }, + receiveValue: { [weak self] in self?.onReceive(receiveValue: $0) } + ) + } + + private func performInitialSync() { + log.debug("[InitializeSubscription.6] \(#function)") + + let initialSyncOrchestrator = initialSyncOrchestratorFactory(dataStoreConfiguration, + authModeStrategy, + api, + reconciliationQueue, + storageAdapter) + + // Hold a reference so we can `reset` while initial sync is in process + self.initialSyncOrchestrator = initialSyncOrchestrator + + syncEventEmitter = SyncEventEmitter(initialSyncOrchestrator: initialSyncOrchestrator, + reconciliationQueue: reconciliationQueue) + + syncEventEmitterSink = syncEventEmitter? + .publisher + .sink( + receiveCompletion: { _ in }, + receiveValue: { [weak self] in self?.onReceive(receiveValue: $0) } + ) + + readyEventEmitter = ReadyEventEmitter(remoteSyncEnginePublisher: publisher) + readyEventEmitterSink = readyEventEmitter? + .publisher.sink( + receiveCompletion: { _ in }, + receiveValue: { [weak self] in self?.onReceive(receiveValue: $0) } + ) + + initialSyncOrchestrator.sync { [weak self] result in + guard let self = self else { + return + } + + if case .failure(let dataStoreError) = result { + self.log.error(dataStoreError.errorDescription) + self.log.error(dataStoreError.recoverySuggestion) + if let underlyingError = dataStoreError.underlyingError { + self.log.error("\(underlyingError)") + } + + self.stateMachine.notify(action: .errored(dataStoreError)) + } else { + self.log.info("Successfully finished sync") + + self.remoteSyncTopicPublisher.send(.performedInitialSync) + self.stateMachine.notify(action: .performedInitialSync) + } + self.initialSyncOrchestrator = nil + } + } + + private func activateCloudSubscriptions() { + log.debug(#function) + guard let reconciliationQueue = reconciliationQueue else { + let error = DataStoreError.internalOperation("reconciliationQueue is unexpectedly `nil`", "", nil) + stateMachine.notify(action: .errored(error)) + return + } + + reconciliationQueue.start() + } + + private func startMutationQueue(api: APICategoryGraphQLBehavior, + mutationEventPublisher: MutationEventPublisher, + reconciliationQueue: IncomingEventReconciliationQueue?) { + log.debug(#function) + outgoingMutationQueue.startSyncingToCloud(api: api, + mutationEventPublisher: mutationEventPublisher, + reconciliationQueue: reconciliationQueue) + + remoteSyncTopicPublisher.send(.mutationQueueStarted) + stateMachine.notify(action: .activatedMutationQueue) + } + + private func cleanup(error: AmplifyError) { + cleanup() + outgoingMutationQueue.stopSyncingToCloud { + self.remoteSyncTopicPublisher.send(.cleanedUp) + self.stateMachine.notify(action: .cleanedUp(error)) + } + } + + private func cleanupForTermination() { + cleanup() + outgoingMutationQueue.stopSyncingToCloud { + self.mutationEventPublisher.cancel() + self.remoteSyncTopicPublisher.send(.cleanedUpForTermination) + self.stateMachine.notify(action: .cleanedUpForTermination) + } + } + + /// Must be invoked from workQueue (as in during a `respond` call + private func notifySyncStarted() { + resetCurrentAttemptNumber() + log.verbose("[Lifecycle event 5]: syncStarted") + Amplify.Hub.dispatch(to: .dataStore, + payload: HubPayload(eventName: HubPayload.EventName.DataStore.syncStarted)) + + remoteSyncTopicPublisher.send(.syncStarted) + stateMachine.notify(action: .notifiedSyncStarted) + } + + private func onReceiveNetworkStatus(networkStatus: ReachabilityUpdate) { + let networkStatusEvent = NetworkStatusEvent(active: networkStatus.isOnline) + let networkStatusEventPayload = HubPayload(eventName: HubPayload.EventName.DataStore.networkStatus, + data: networkStatusEvent) + Amplify.Hub.dispatch(to: .dataStore, payload: networkStatusEventPayload) + } + + /// Must be invoked from workQueue (as during a `respond` call) + func cleanup() { + reconciliationQueue?.cancel() + reconciliationQueue = nil + reconciliationQueueSink = nil + syncEventEmitter = nil + syncEventEmitterSink = nil + readyEventEmitter = nil + } +} + +extension RemoteSyncEngine: DefaultLogger { + public static var log: Logger { + Amplify.Logging.logger(forCategory: CategoryType.dataStore.displayName, forNamespace: String(describing: self)) + } + public var log: Logger { + Self.log + } +} + +extension RemoteSyncEngine: Resettable { + func reset() async { + let mirror = Mirror(reflecting: self) + for child in mirror.children { + let label = child.label ?? "some RemoteSyncEngine child" + guard label != "api", + label != "auth" else { + log.verbose("Not resetting \(label) from RemoteSyncEngine") + continue + } + + if let resettable = child.value as? Resettable { + log.verbose("Resetting \(label)") + await resettable.reset() + self.log.verbose("Resetting \(label): finished") + } + } + } +} +// swiftlint:enable type_body_length file_length diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/RemoteSyncEngineBehavior.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/RemoteSyncEngineBehavior.swift new file mode 100644 index 0000000000..d61c93feb7 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/RemoteSyncEngineBehavior.swift @@ -0,0 +1,53 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Combine + +enum RemoteSyncEngineEvent { + case storageAdapterAvailable + case subscriptionsPaused + case mutationsPaused + case clearedStateOutgoingMutations + case subscriptionsInitialized + case performedInitialSync + case subscriptionsActivated + case mutationQueueStarted + case syncStarted + case cleanedUp + case cleanedUpForTermination + case mutationEvent(MutationEvent) + case modelSyncedEvent(ModelSyncedEvent) + case syncQueriesReadyEvent + case readyEvent + case schedulingRestart +} + +/// Behavior to sync mutation events to the remote API, and to subscribe to mutations from the remote API +protocol RemoteSyncEngineBehavior: AnyObject { + + /// Start the sync process with a "delta sync" merge + /// + /// The order of the startup sequence is important: + /// 1. Subscription and Mutation processing to the network are paused + /// 1. Subscription connections are established and incoming messages are written to a queue + /// 1. Queries are run and objects applied to the Datastore + /// 1. Subscription processing runs off the queue and flows as normal, reconciling any items against + /// the updates in the Datastore + /// 1. Mutation processor drains messages off the queue in serial and sends to the service, invoking + /// any local callbacks on error if necessary + func start(api: APICategoryGraphQLBehavior, auth: AuthCategoryBehavior?) + + func stop(completion: @escaping DataStoreCallback) + + func isSyncing() -> Bool + + /// Submits a new mutation for synchronization to the remote API. The response will be handled by the appropriate + /// reconciliation queue + func submit(_ mutationEvent: MutationEvent, completion: @escaping (Result) -> Void) + + var publisher: AnyPublisher { get } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/RequestRetryable.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/RequestRetryable.swift new file mode 100644 index 0000000000..f215daeda0 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/RequestRetryable.swift @@ -0,0 +1,25 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +struct RequestRetryAdvice { + let shouldRetry: Bool + let retryInterval: DispatchTimeInterval + init(shouldRetry: Bool, + retryInterval: DispatchTimeInterval = .seconds(60)) { + self.shouldRetry = shouldRetry + self.retryInterval = retryInterval + } + +} + +protocol RequestRetryable { + func retryRequestAdvice(urlError: URLError?, + httpURLResponse: HTTPURLResponse?, + attemptNumber: Int) -> RequestRetryAdvice +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/RequestRetryablePolicy.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/RequestRetryablePolicy.swift new file mode 100644 index 0000000000..220834d187 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/RequestRetryablePolicy.swift @@ -0,0 +1,112 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +class RequestRetryablePolicy: RequestRetryable { + + private static let maxWaitMilliseconds = 300 * 1_000 // 5 minutes of max retry duration. + private static let jitterMilliseconds: Float = 100.0 + + private static let maxExponentForExponentialBackoff = 31 + + public func retryRequestAdvice(urlError: URLError?, + httpURLResponse: HTTPURLResponse?, + attemptNumber: Int) -> RequestRetryAdvice { + var attemptNumber = attemptNumber + if attemptNumber <= 0 { + assertionFailure("attemptNumber should be > 0") + attemptNumber = 1 + } + + if let urlError = urlError { + return determineRetryRequestAdvice(basedOn: urlError, attemptNumber: attemptNumber) + } else { + return determineRetryRequestAdvice(basedOn: httpURLResponse, attemptNumber: attemptNumber) + } + } + + private func determineRetryRequestAdvice(basedOn urlError: URLError, + attemptNumber: Int) -> RequestRetryAdvice { + switch urlError.code { + case .notConnectedToInternet, + .dnsLookupFailed, + .cannotConnectToHost, + .cannotFindHost, + .timedOut, + .dataNotAllowed, + .cannotParseResponse, + .networkConnectionLost, + .secureConnectionFailed, + .userAuthenticationRequired: + let waitMillis = retryDelayInMillseconds(for: attemptNumber) + return RequestRetryAdvice(shouldRetry: true, retryInterval: .milliseconds(waitMillis)) + default: + break + } + return RequestRetryAdvice(shouldRetry: false) + } + + private func determineRetryRequestAdvice(basedOn httpURLResponse: HTTPURLResponse?, + attemptNumber: Int) -> RequestRetryAdvice { + /// If there was no error and no response, then we should not retry. + guard let httpURLResponse = httpURLResponse else { + return RequestRetryAdvice(shouldRetry: false) + } + + if let retryAfterValueInSeconds = getRetryAfterHeaderValue(from: httpURLResponse) { + return RequestRetryAdvice(shouldRetry: true, retryInterval: .seconds(retryAfterValueInSeconds)) + } + + switch httpURLResponse.statusCode { + case 500 ... 599, 429: + let waitMillis = retryDelayInMillseconds(for: attemptNumber) + if waitMillis <= RequestRetryablePolicy.maxWaitMilliseconds { + return RequestRetryAdvice(shouldRetry: true, retryInterval: .milliseconds(waitMillis)) + } + default: + break + } + return RequestRetryAdvice(shouldRetry: false) + } + + /// Returns a delay in milliseconds for the current attempt number. The delay includes random jitter as + /// described in https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/ + private func retryDelayInMillseconds(for attemptNumber: Int) -> Int { + var exponent = attemptNumber + if attemptNumber > RequestRetryablePolicy.maxExponentForExponentialBackoff { + exponent = RequestRetryablePolicy.maxExponentForExponentialBackoff + } + + let jitter = Double(getRandomBetween0And1() * RequestRetryablePolicy.jitterMilliseconds) + let delay = Int(Double(truncating: pow(2.0, exponent) as NSNumber) * 100.0 + jitter) + return delay + } + + private func getRandomBetween0And1() -> Float { + return Float.random(in: 0 ... 1) + } + + /// Returns the value of the "Retry-After" header as an Int, or nil if the value isn't present or cannot + /// be converted to an Int + /// + /// - Parameter response: The response to get the header from + /// - Returns: The value of the "Retry-After" header, or nil if not present or not convertable to Int + private func getRetryAfterHeaderValue(from response: HTTPURLResponse) -> Int? { + let waitTime: Int? + switch response.allHeaderFields["Retry-After"] { + case let retryTime as String: + waitTime = Int(retryTime) + case let retryTime as Int: + waitTime = retryTime + default: + waitTime = nil + } + + return waitTime + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/AWSIncomingEventReconciliationQueue.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/AWSIncomingEventReconciliationQueue.swift new file mode 100644 index 0000000000..0d4f2314f4 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/AWSIncomingEventReconciliationQueue.swift @@ -0,0 +1,248 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Combine +import Foundation + +typealias DisableSubscriptions = () -> Bool + +// Used for testing: +typealias IncomingEventReconciliationQueueFactory = + ([ModelSchema], + APICategoryGraphQLBehavior, + StorageEngineAdapter, + [DataStoreSyncExpression], + AuthCategoryBehavior?, + AuthModeStrategy, + ModelReconciliationQueueFactory?, + @escaping DisableSubscriptions +) async -> IncomingEventReconciliationQueue + +final class AWSIncomingEventReconciliationQueue: IncomingEventReconciliationQueue { + + private var modelReconciliationQueueSinks: AtomicValue<[String: AnyCancellable]> = AtomicValue(initialValue: [:]) + + private let eventReconciliationQueueTopic: CurrentValueSubject + var publisher: AnyPublisher { + return eventReconciliationQueueTopic.eraseToAnyPublisher() + } + + private let connectionStatusSerialQueue: DispatchQueue + private var reconcileAndSaveQueue: ReconcileAndSaveOperationQueue + private var reconciliationQueues: AtomicValue<[ModelName: ModelReconciliationQueue]> = AtomicValue(initialValue: [:]) + private var reconciliationQueueConnectionStatus: [ModelName: Bool] + private var modelReconciliationQueueFactory: ModelReconciliationQueueFactory + + private var isInitialized: Bool { + log.verbose("[InitializeSubscription.5] \(reconciliationQueueConnectionStatus.count)/\(modelSchemasCount) initialized") + return modelSchemasCount == reconciliationQueueConnectionStatus.count + } + private let modelSchemasCount: Int + + init(modelSchemas: [ModelSchema], + api: APICategoryGraphQLBehavior, + storageAdapter: StorageEngineAdapter, + syncExpressions: [DataStoreSyncExpression], + auth: AuthCategoryBehavior? = nil, + authModeStrategy: AuthModeStrategy, + modelReconciliationQueueFactory: ModelReconciliationQueueFactory? = nil, + disableSubscriptions: @escaping () -> Bool = { false }) async { + self.modelSchemasCount = modelSchemas.count + self.modelReconciliationQueueSinks.set([:]) + self.eventReconciliationQueueTopic = CurrentValueSubject(.idle) + self.reconciliationQueues.set([:]) + self.reconciliationQueueConnectionStatus = [:] + self.reconcileAndSaveQueue = ReconcileAndSaveQueue(modelSchemas) + + if let modelReconciliationQueueFactory = modelReconciliationQueueFactory { + self.modelReconciliationQueueFactory = modelReconciliationQueueFactory + } else { + self.modelReconciliationQueueFactory = AWSModelReconciliationQueue.init + } + + // TODO: Add target for SyncEngine system to help prevent thread explosion and increase performance + // https://github.com/aws-amplify/amplify-ios/issues/399 + self.connectionStatusSerialQueue + = DispatchQueue(label: "com.amazonaws.DataStore.AWSIncomingEventReconciliationQueue") + + let subscriptionsDisabled = disableSubscriptions() + + #if targetEnvironment(simulator) && os(watchOS) + if !subscriptionsDisabled { + let message = """ + DataStore uses subscriptions via websockets, which work on the watchOS simulator but not on the device. + Running DataStore on watchOS with subscriptions enabled is only possible during special circumstances + such as actively streaming audio. See https://github.com/aws-amplify/amplify-swift/pull/3368 for more details. + """ + self.log.verbose(message) + } + #endif + + for modelSchema in modelSchemas { + let modelName = modelSchema.name + let syncExpression = syncExpressions.first(where: { + $0.modelSchema.name == modelName + }) + let modelPredicate = syncExpression?.modelPredicate() ?? nil + guard reconciliationQueues.get()[modelName] == nil else { + log.warn("Duplicate model name found: \(modelName), not subscribing") + continue + } + let queue = await self.modelReconciliationQueueFactory(modelSchema, + storageAdapter, + api, + reconcileAndSaveQueue, + modelPredicate, + auth, + authModeStrategy, + subscriptionsDisabled ? OperationDisabledIncomingSubscriptionEventPublisher() : nil) + + reconciliationQueues.with { reconciliationQueues in + reconciliationQueues[modelName] = queue + } + log.verbose("[InitializeSubscription.5] Sink reconciliationQueues \(modelName) \(reconciliationQueues.get().count)") + let modelReconciliationQueueSink = queue.publisher.sink(receiveCompletion: onReceiveCompletion(completed:), + receiveValue: onReceiveValue(receiveValue:)) + modelReconciliationQueueSinks.with { modelReconciliationQueueSinks in + modelReconciliationQueueSinks[modelName] = modelReconciliationQueueSink + } + log.verbose("[InitializeSubscription.5] Sink done reconciliationQueues \(modelName) \(reconciliationQueues.get().count)") + } + } + + func start() { + reconciliationQueues.get().values.forEach { $0.start() } + eventReconciliationQueueTopic.send(.started) + } + + func pause() { + reconciliationQueues.get().values.forEach { $0.pause() } + eventReconciliationQueueTopic.send(.paused) + } + + func offer(_ remoteModels: [MutationSync], modelName: ModelName) { + guard let queue = reconciliationQueues.get()[modelName] else { + // TODO: Error handling + return + } + + queue.enqueue(remoteModels) + } + + private func onReceiveCompletion(completed: Subscribers.Completion) { + connectionStatusSerialQueue.async { + self.reconciliationQueueConnectionStatus = [:] + } + switch completed { + case .failure(let error): + eventReconciliationQueueTopic.send(completion: .failure(error)) + case .finished: + eventReconciliationQueueTopic.send(completion: .finished) + } + } + + private func onReceiveValue(receiveValue: ModelReconciliationQueueEvent) { + switch receiveValue { + case .mutationEvent(let event): + eventReconciliationQueueTopic.send(.mutationEventApplied(event)) + case .mutationEventDropped(let modelName, let error): + eventReconciliationQueueTopic.send(.mutationEventDropped(modelName: modelName, error: error)) + case .connected(modelName: let modelName): + connectionStatusSerialQueue.async { + self.log.verbose("[InitializeSubscription.4] .connected \(modelName)") + self.reconciliationQueueConnectionStatus[modelName] = true + if self.isInitialized { + self.log.verbose("[InitializeSubscription.6] connected isInitialized") + self.eventReconciliationQueueTopic.send(.initialized) + } + } + case .disconnected(modelName: let modelName, reason: .operationDisabled), + .disconnected(modelName: let modelName, reason: .unauthorized): + connectionStatusSerialQueue.async { + self.log.verbose("[InitializeSubscription.4] subscription disconnected [\(modelName)] reason: [\(receiveValue)]") + // A disconnected subscription due to operation disabled or unauthorized will still contribute + // to the overall state of the reconciliation queue system on sending the `.initialized` event + // since subscriptions may be disabled and have to reconcile locally sourced mutation evemts. + self.reconciliationQueueConnectionStatus[modelName] = true + if self.isInitialized { + self.log.verbose("[InitializeSubscription.6] disconnected isInitialized") + self.eventReconciliationQueueTopic.send(.initialized) + } + } + default: + break + } + } + + func cancel() { + modelReconciliationQueueSinks.get().values.forEach { $0.cancel() } + reconciliationQueues.get().values.forEach { $0.cancel()} + connectionStatusSerialQueue.sync { + self.reconciliationQueues.set([:]) + self.modelReconciliationQueueSinks.set([:]) + } + } + + private func dispatchSyncQueriesReady() { + let syncQueriesReadyPayload = HubPayload(eventName: HubPayload.EventName.DataStore.syncQueriesReady) + Amplify.Hub.dispatch(to: .dataStore, payload: syncQueriesReadyPayload) + } + +} + +extension AWSIncomingEventReconciliationQueue: DefaultLogger { + public static var log: Logger { + Amplify.Logging.logger(forCategory: CategoryType.analytics.displayName, forNamespace: String(describing: self)) + } + public var log: Logger { + Self.log + } +} + +// MARK: - Static factory +extension AWSIncomingEventReconciliationQueue { + static let factory: IncomingEventReconciliationQueueFactory = { + modelSchemas, api, storageAdapter, syncExpressions, auth, authModeStrategy, _, disableSubscriptions in + await AWSIncomingEventReconciliationQueue(modelSchemas: modelSchemas, + api: api, + storageAdapter: storageAdapter, + syncExpressions: syncExpressions, + auth: auth, + authModeStrategy: authModeStrategy, + modelReconciliationQueueFactory: nil, + disableSubscriptions: disableSubscriptions) + } +} + +// MARK: - AWSIncomingEventReconciliationQueue + Resettable +extension AWSIncomingEventReconciliationQueue: Resettable { + + func reset() async { + for queue in reconciliationQueues.get().values { + guard let queue = queue as? Resettable else { + continue + } + log.verbose("Resetting reconciliationQueue") + await queue.reset() + log.verbose("Resetting reconciliationQueue: finished") + } + + log.verbose("Resetting reconcileAndSaveQueue") + reconcileAndSaveQueue.cancelAllOperations() + // Reset is used in internal testing only. Some operations get kicked off at this point and do not finish + // We're sometimes hitting a deadlock when waiting for them to finish. Commenting this out and letting + // the tests continue onto the next works pretty well, but ideally ReconcileAndLocalSaveOperation's should + // always finish. We can uncomment this to explore a better fix that will still gives us test stability. + // reconcileAndSaveQueue.waitUntilOperationsAreFinished() + log.verbose("Resetting reconcileAndSaveQueue: finished") + + log.verbose("Cancelling AWSIncomingEventReconciliationQueue") + cancel() + log.verbose("Cancelling AWSIncomingEventReconciliationQueue: finished") + } + +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/AWSIncomingSubscriptionEventPublisher.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/AWSIncomingSubscriptionEventPublisher.swift new file mode 100644 index 0000000000..b4abcdc698 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/AWSIncomingSubscriptionEventPublisher.swift @@ -0,0 +1,79 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Combine +import Foundation + +/// Facade to hide the AsyncEventQueue/ModelMapper structures from the ModelReconciliationQueue. +/// Provides a publisher for all incoming subscription types (onCreate, onUpdate, onDelete) for a single Model type. +final class AWSIncomingSubscriptionEventPublisher: IncomingSubscriptionEventPublisher { + + private let asyncEvents: IncomingAsyncSubscriptionEventPublisher + private let mapper: IncomingAsyncSubscriptionEventToAnyModelMapper + private let subscriptionEventSubject: PassthroughSubject + private var mapperSink: AnyCancellable? + var publisher: AnyPublisher { + return subscriptionEventSubject.eraseToAnyPublisher() + } + + init(modelSchema: ModelSchema, + api: APICategoryGraphQLBehavior, + modelPredicate: QueryPredicate?, + auth: AuthCategoryBehavior?, + authModeStrategy: AuthModeStrategy) async { + self.subscriptionEventSubject = PassthroughSubject() + self.asyncEvents = await IncomingAsyncSubscriptionEventPublisher(modelSchema: modelSchema, + api: api, + modelPredicate: modelPredicate, + auth: auth, + authModeStrategy: authModeStrategy) + + self.mapper = IncomingAsyncSubscriptionEventToAnyModelMapper() + asyncEvents.subscribe(subscriber: mapper) + self.mapperSink = mapper + .publisher + .sink( + receiveCompletion: { [weak self] in self?.onReceiveCompletion(receiveCompletion: $0) }, + receiveValue: { [weak self] in self?.onReceive(receiveValue: $0) } + ) + } + + private func onReceiveCompletion(receiveCompletion: Subscribers.Completion) { + subscriptionEventSubject.send(completion: receiveCompletion) + } + + private func onReceive(receiveValue: IncomingAsyncSubscriptionEvent) { + if case .connectionConnected = receiveValue { + subscriptionEventSubject.send(.connectionConnected) + } else if case .payload(let mutationSyncAnyModel) = receiveValue { + subscriptionEventSubject.send(.mutationEvent(mutationSyncAnyModel)) + } + } + + func cancel() { + mapperSink?.cancel() + mapperSink = nil + + asyncEvents.cancel() + mapper.cancel() + } +} + +// MARK: Resettable +extension AWSIncomingSubscriptionEventPublisher: Resettable { + + func reset() async { + Amplify.log.verbose("Resetting asyncEvents") + asyncEvents.reset() + Amplify.log.verbose("Resetting asyncEvents: finished") + + Amplify.log.verbose("Resetting mapper") + await mapper.reset() + Amplify.log.verbose("Resetting mapper: finished") + } + +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/IncomingAsyncSubscriptionEventPublisher.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/IncomingAsyncSubscriptionEventPublisher.swift new file mode 100644 index 0000000000..499ea6ab43 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/IncomingAsyncSubscriptionEventPublisher.swift @@ -0,0 +1,334 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Combine +import Foundation + +/// Collects all subscription types for a given model into a single subscribable publisher. +/// +/// The queue "Element" is AnyModel to allow for queues to be collected into an aggregate structure upstream, but each +/// individual EventQueue operates on a single, specific Model type. +/// +/// At initialization, the Queue sets up subscriptions, via the provided `APICategoryGraphQLBehavior`, for each type +/// `GraphQLSubscriptionType` and holds a reference to the returned operation. The operations' listeners enqueue +/// incoming successful events onto a `Publisher`, that queue processors can subscribe to. +final class IncomingAsyncSubscriptionEventPublisher: AmplifyCancellable { + typealias Payload = MutationSync + typealias Event = GraphQLSubscriptionEvent + + private var onCreateOperation: RetryableGraphQLSubscriptionOperation? + private var onCreateValueListener: GraphQLSubscriptionOperation.InProcessListener? + private var onCreateConnected: Bool + + private var onUpdateOperation: RetryableGraphQLSubscriptionOperation? + private var onUpdateValueListener: GraphQLSubscriptionOperation.InProcessListener? + private var onUpdateConnected: Bool + + private var onDeleteOperation: RetryableGraphQLSubscriptionOperation? + private var onDeleteValueListener: GraphQLSubscriptionOperation.InProcessListener? + private var onDeleteConnected: Bool + + private let connectionStatusQueue: OperationQueue + private var combinedConnectionStatusIsConnected: Bool { + return onCreateConnected && onUpdateConnected && onDeleteConnected + } + + private let incomingSubscriptionEvents = PassthroughSubject() + private var cancelables = Set() + private let awsAuthService: AWSAuthServiceBehavior + + private let consistencyQueue: DispatchQueue + private let taskQueue: TaskQueue + private let modelName: ModelName + + init(modelSchema: ModelSchema, + api: APICategoryGraphQLBehavior, + modelPredicate: QueryPredicate?, + auth: AuthCategoryBehavior?, + authModeStrategy: AuthModeStrategy, + awsAuthService: AWSAuthServiceBehavior? = nil) async { + self.onCreateConnected = false + self.onUpdateConnected = false + self.onDeleteConnected = false + self.consistencyQueue = DispatchQueue( + label: "com.amazonaws.Amplify.RemoteSyncEngine.\(modelSchema.name)" + ) + self.taskQueue = TaskQueue() + self.modelName = modelSchema.name + + self.connectionStatusQueue = OperationQueue() + connectionStatusQueue.name + = "com.amazonaws.Amplify.RemoteSyncEngine.\(modelSchema.name).IncomingAsyncSubscriptionEventPublisher" + connectionStatusQueue.maxConcurrentOperationCount = 1 + connectionStatusQueue.isSuspended = false + + self.awsAuthService = awsAuthService ?? AWSAuthService() + + // onCreate operation + self.onCreateValueListener = onCreateValueListenerHandler(event:) + self.onCreateOperation = await retryableOperation( + subscriptionType: .create, + modelSchema: modelSchema, + authModeStrategy: authModeStrategy, + auth: auth, + api: api + ) + onCreateOperation?.subscribe() + .sink(receiveCompletion: genericCompletionListenerHandler(result:), receiveValue: onCreateValueListener!) + .store(in: &cancelables) + + // onUpdate operation + self.onUpdateValueListener = onUpdateValueListenerHandler(event:) + self.onUpdateOperation = await retryableOperation( + subscriptionType: .update, + modelSchema: modelSchema, + authModeStrategy: authModeStrategy, + auth: auth, + api: api + ) + onUpdateOperation?.subscribe() + .sink(receiveCompletion: genericCompletionListenerHandler(result:), receiveValue: onUpdateValueListener!) + .store(in: &cancelables) + + // onDelete operation + self.onDeleteValueListener = onDeleteValueListenerHandler(event:) + self.onDeleteOperation = await retryableOperation( + subscriptionType: .delete, + modelSchema: modelSchema, + authModeStrategy: authModeStrategy, + auth: auth, + api: api + ) + onDeleteOperation?.subscribe() + .sink(receiveCompletion: genericCompletionListenerHandler(result:), receiveValue: onDeleteValueListener!) + .store(in: &cancelables) + } + + + func retryableOperation( + subscriptionType: IncomingAsyncSubscriptionType, + modelSchema: ModelSchema, + authModeStrategy: AuthModeStrategy, + auth: AuthCategoryBehavior?, + api: APICategoryGraphQLBehavior + ) async -> RetryableGraphQLSubscriptionOperation { + let authTypeProvider = await authModeStrategy.authTypesFor( + schema: modelSchema, + operations: subscriptionType.operations + ) + + return RetryableGraphQLSubscriptionOperation( + requestStream: authTypeProvider.publisher() + .map { Optional.some($0) } // map to optional to have nil as element + .replaceEmpty(with: nil) // use a nil element to trigger default auth if no auth provided + .map { authType in { [weak self] in + guard let self else { + throw APIError.operationError("GraphQL subscription cancelled", "") + } + + return api.subscribe(request: await IncomingAsyncSubscriptionEventPublisher.makeAPIRequest( + for: modelSchema, + subscriptionType: subscriptionType.subscriptionType, + api: api, + auth: auth, + authType: authType, + awsAuthService: self.awsAuthService + )) + }} + .eraseToAnyPublisher() + ) + } + + func onCreateValueListenerHandler(event: Event) { + log.verbose("onCreateValueListener: \(event)") + let onCreateConnectionOp = CancelAwareBlockOperation { + self.onCreateConnected = self.isConnectionStatusConnected(for: event) + self.sendConnectionEventIfConnected(event: event) + } + genericValueListenerHandler(event: event, cancelAwareBlock: onCreateConnectionOp) + } + + func onUpdateValueListenerHandler(event: Event) { + log.verbose("onUpdateValueListener: \(event)") + let onUpdateConnectionOp = CancelAwareBlockOperation { + self.onUpdateConnected = self.isConnectionStatusConnected(for: event) + self.sendConnectionEventIfConnected(event: event) + } + genericValueListenerHandler(event: event, cancelAwareBlock: onUpdateConnectionOp) + } + + func onDeleteValueListenerHandler(event: Event) { + log.verbose("onDeleteValueListener: \(event)") + let onDeleteConnectionOp = CancelAwareBlockOperation { + self.onDeleteConnected = self.isConnectionStatusConnected(for: event) + self.sendConnectionEventIfConnected(event: event) + } + genericValueListenerHandler(event: event, cancelAwareBlock: onDeleteConnectionOp) + } + + func isConnectionStatusConnected(for event: Event) -> Bool { + if case .connection(.connected) = event { + return true + } + return false + } + + func sendConnectionEventIfConnected(event: Event) { + if combinedConnectionStatusIsConnected { + send(event) + } + } + + func genericValueListenerHandler(event: Event, cancelAwareBlock: CancelAwareBlockOperation) { + if case .connection = event { + connectionStatusQueue.addOperation(cancelAwareBlock) + } else { + send(event) + } + } + + func genericCompletionListenerHandler(result: Subscribers.Completion) { + switch result { + case .finished: + send(completion: .finished) + case .failure(let apiError): + log.verbose("[InitializeSubscription.1] API.subscribe failed for `\(modelName)` error: \(apiError.errorDescription)") + let dataStoreError = DataStoreError(error: apiError) + send(completion: .failure(dataStoreError)) + } + } + + static func makeAPIRequest(for modelSchema: ModelSchema, + subscriptionType: GraphQLSubscriptionType, + api: APICategoryGraphQLBehavior, + auth: AuthCategoryBehavior?, + authType: AWSAuthorizationType?, + awsAuthService: AWSAuthServiceBehavior) async -> GraphQLRequest { + let request: GraphQLRequest + if modelSchema.hasAuthenticationRules, + let _ = auth, + let tokenString = try? await awsAuthService.getUserPoolAccessToken(), + case .success(let claims) = awsAuthService.getTokenClaims(tokenString: tokenString) { + request = GraphQLRequest.subscription(to: modelSchema, + subscriptionType: subscriptionType, + claims: claims, + authType: authType) + } else if modelSchema.hasAuthenticationRules, + let oidcAuthProvider = hasOIDCAuthProviderAvailable(api: api), + let tokenString = try? await oidcAuthProvider.getLatestAuthToken(), + case .success(let claims) = awsAuthService.getTokenClaims(tokenString: tokenString) { + request = GraphQLRequest.subscription(to: modelSchema, + subscriptionType: subscriptionType, + claims: claims, + authType: authType) + } else { + request = GraphQLRequest.subscription(to: modelSchema, + subscriptionType: subscriptionType, + authType: authType) + } + + return request + } + + static func hasOIDCAuthProviderAvailable(api: APICategoryGraphQLBehavior) -> AmplifyOIDCAuthProvider? { + if let apiPlugin = api as? APICategoryAuthProviderFactoryBehavior, + let oidcAuthProvider = apiPlugin.apiAuthProviderFactory().oidcAuthProvider() { + return oidcAuthProvider + } + return nil + } + + func subscribe(subscriber: S) where S.Input == Event, S.Failure == DataStoreError { + incomingSubscriptionEvents.subscribe(subscriber) + } + + func send(_ event: Event) { + taskQueue.async { [weak self] in + guard let self else { return } + self.incomingSubscriptionEvents.send(event) + } + } + + func send(completion: Subscribers.Completion) { + taskQueue.async { [weak self] in + guard let self else { return } + self.incomingSubscriptionEvents.send(completion: completion) + } + } + + func cancel() { + consistencyQueue.sync { + genericCompletionListenerHandler(result: .finished) + + onCreateOperation?.cancel() + onCreateOperation = nil + onCreateValueListener = nil + + onUpdateOperation?.cancel() + onUpdateOperation = nil + onUpdateValueListener = nil + + onDeleteOperation?.cancel() + onDeleteOperation = nil + onDeleteValueListener = nil + + connectionStatusQueue.cancelAllOperations() + connectionStatusQueue.waitUntilAllOperationsAreFinished() + } + } + + func reset() { + consistencyQueue.sync { + onCreateOperation?.cancel() + onCreateOperation = nil + onCreateValueListener?(.connection(.disconnected)) + + onUpdateOperation?.cancel() + onUpdateOperation = nil + onUpdateValueListener?(.connection(.disconnected)) + + onDeleteOperation?.cancel() + onDeleteOperation = nil + onDeleteValueListener?(.connection(.disconnected)) + + genericCompletionListenerHandler(result: .finished) + } + } + +} + +enum IncomingAsyncSubscriptionType { + case create + case delete + case update + + var operations: [ModelOperation] { + switch self { + case .create: return [.create, .read] + case .delete: return [.delete, .read] + case .update: return [.update, .read] + } + } + + var subscriptionType: GraphQLSubscriptionType { + switch self { + case .create: return .onCreate + case .delete: return .onDelete + case .update: return .onUpdate + } + } + +} + +extension IncomingAsyncSubscriptionEventPublisher: DefaultLogger { + public static var log: Logger { + Amplify.Logging.logger(forCategory: CategoryType.dataStore.displayName, forNamespace: String(describing: self)) + } + public var log: Logger { + Self.log + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/IncomingAsyncSubscriptionEventToAnyModelMapper.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/IncomingAsyncSubscriptionEventToAnyModelMapper.swift new file mode 100644 index 0000000000..cf33218e63 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/IncomingAsyncSubscriptionEventToAnyModelMapper.swift @@ -0,0 +1,111 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Combine + +enum IncomingAsyncSubscriptionEvent { + case payload(MutationSync) + case connectionConnected + case connectionDisconnected +} + +// swiftlint:disable type_name +/// Subscribes to an IncomingSubscriptionAsyncEventQueue, and publishes AnyModel +final class IncomingAsyncSubscriptionEventToAnyModelMapper: Subscriber, AmplifyCancellable { + // swiftlint:enable type_name + + typealias Input = IncomingAsyncSubscriptionEventPublisher.Event + typealias Failure = DataStoreError + typealias Payload = MutationSync + + var subscription: Subscription? + + private let modelsFromSubscription: PassthroughSubject + + var publisher: AnyPublisher { + modelsFromSubscription.eraseToAnyPublisher() + } + + init() { + self.modelsFromSubscription = PassthroughSubject() + } + + // MARK: - Subscriber + + func receive(subscription: Subscription) { + log.info("Received subscription: \(subscription)") + self.subscription = subscription + subscription.request(.max(1)) + } + + func receive(_ subscriptionEvent: IncomingAsyncSubscriptionEventPublisher.Event) -> Subscribers.Demand { + log.verbose("\(#function): \(subscriptionEvent)") + dispose(of: subscriptionEvent) + return .max(1) + } + + func receive(completion: Subscribers.Completion) { + log.info("Received completion: \(completion)") + modelsFromSubscription.send(completion: completion) + } + + // MARK: - Event processing + + private func dispose(of subscriptionEvent: GraphQLSubscriptionEvent) { + log.verbose("dispose(of subscriptionEvent): \(subscriptionEvent)") + switch subscriptionEvent { + case .connection(let connectionState): + // Connection events are informational only at this level. The terminal state is represented by the + // OperationResult. + log.info("connectionState now \(connectionState)") + switch connectionState { + case .connected: + modelsFromSubscription.send(.connectionConnected) + case .disconnected: + modelsFromSubscription.send(.connectionDisconnected) + default: + break + } + case .data(let graphQLResponse): + dispose(of: graphQLResponse) + } + } + + private func dispose(of graphQLResponse: GraphQLResponse) { + log.verbose("dispose(of graphQLResponse): \(graphQLResponse)") + switch graphQLResponse { + case .success(let mutationSync): + modelsFromSubscription.send(.payload(mutationSync)) + case .failure(let failure): + log.error(error: failure) + } + } + + func cancel() { + subscription?.cancel() + subscription = nil + } +} + +extension IncomingAsyncSubscriptionEventToAnyModelMapper: Resettable { + func reset() async { + log.verbose("Resetting modelsFromSubscription and subscription") + modelsFromSubscription.send(completion: .finished) + subscription?.cancel() + subscription = nil + log.verbose("Resetting modelsFromSubscription and subscription: finished") + } +} + +extension IncomingAsyncSubscriptionEventToAnyModelMapper: DefaultLogger { + public static var log: Logger { + Amplify.Logging.logger(forCategory: CategoryType.dataStore.displayName, forNamespace: String(describing: self)) + } + public var log: Logger { + Self.log + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/IncomingEventReconciliationQueue.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/IncomingEventReconciliationQueue.swift new file mode 100644 index 0000000000..15a97020c2 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/IncomingEventReconciliationQueue.swift @@ -0,0 +1,28 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Combine + +enum IncomingEventReconciliationQueueEvent { + case idle + case initialized + case started + case paused + case mutationEventApplied(MutationEvent) + case mutationEventDropped(modelName: String, error: DataStoreError? = nil) +} + +/// A queue that reconciles all incoming events for a model: responses from locally-sourced mutations, and subscription +/// events for create, update, and delete events initiated by remote systems. In addition to pausing and resuming +/// automatically-configured subscriptions for models, the queue provides an `offer` method for submitting events +/// directly from other network events such as mutation callbacks or from base/initial sync queries. +protocol IncomingEventReconciliationQueue: AnyObject, AmplifyCancellable { + func start() + func pause() + func offer(_ remoteModels: [MutationSync], modelName: ModelName) + var publisher: AnyPublisher { get } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/IncomingSubscriptionEventPublisher.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/IncomingSubscriptionEventPublisher.swift new file mode 100644 index 0000000000..535b97e1fc --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/IncomingSubscriptionEventPublisher.swift @@ -0,0 +1,17 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Combine + +enum IncomingSubscriptionEventPublisherEvent { + case connectionConnected + case mutationEvent(MutationSync) +} + +protocol IncomingSubscriptionEventPublisher: AmplifyCancellable { + var publisher: AnyPublisher { get } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/OperationDisabledSubscriptionEventPublisher.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/OperationDisabledSubscriptionEventPublisher.swift new file mode 100644 index 0000000000..9702c9dd02 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/OperationDisabledSubscriptionEventPublisher.swift @@ -0,0 +1,29 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Combine + +final class OperationDisabledIncomingSubscriptionEventPublisher: IncomingSubscriptionEventPublisher { + + private let subscriptionEventSubject: PassthroughSubject + + var publisher: AnyPublisher { + return subscriptionEventSubject.eraseToAnyPublisher() + } + + init() { + self.subscriptionEventSubject = PassthroughSubject() + + let apiError = APIError.operationError(AppSyncErrorType.operationDisabled.rawValue, "", nil) + let dataStoreError = DataStoreError.api(apiError, nil) + subscriptionEventSubject.send(completion: .failure(dataStoreError)) + + } + + func cancel() { + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/ReconcileAndLocalSave/AWSModelReconciliationQueue.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/ReconcileAndLocalSave/AWSModelReconciliationQueue.swift new file mode 100644 index 0000000000..58ac4ecf7c --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/ReconcileAndLocalSave/AWSModelReconciliationQueue.swift @@ -0,0 +1,299 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Combine +import Foundation + +// Used for testing: +typealias ModelReconciliationQueueFactory = ( + ModelSchema, + StorageEngineAdapter, + APICategoryGraphQLBehavior, + ReconcileAndSaveOperationQueue, + QueryPredicate?, + AuthCategoryBehavior?, + AuthModeStrategy, + IncomingSubscriptionEventPublisher? +) async -> ModelReconciliationQueue + +/// A queue of reconciliation operations, merged from incoming subscription events and responses to locally-sourced +/// mutations for a single model type. +/// +/// Although subscriptions are listened to and enqueued at initialization, you must call `start` on a +/// AWSModelReconciliationQueue to write events to the DataStore. +/// +/// Internally, a AWSModelReconciliationQueue manages the +/// `incomingSubscriptionEventQueue` to buffer incoming remote events (e.g., +/// subscriptions, mutation results), and is passed the reference of +/// `ReconcileAndSaveOperationQueue`, used to reconcile & save mutation sync events +/// to local storage. A reference to the `ReconcileAndSaveOperationQueue` is used +/// here since some models have to be reconciled in dependency order and +/// `ReconcileAndSaveOperationQueue` is responsible for managing the ordering of +/// these events. These queues are required because each of these actions have +/// different points in the sync lifecycle at which they may be activated. +/// +/// Flow: +/// - `AWSModelReconciliationQueue` init() +/// - `reconcileAndSaveQueue` queue for reconciliation and local save operations, passed in from initializer. +/// - `incomingSubscriptionEventQueue` created, but suspended +/// - `incomingEventsSink` listener set up for incoming remote events +/// - when `incomingEventsSink` listener receives an event, it adds an operation to `incomingSubscriptionEventQueue` +/// - Elsewhere in the system, the initial sync queries begin, and submit events via `enqueue`. That method creates a +/// `ReconcileAndLocalSaveOperation` for the event, and enqueues it on `reconcileAndSaveQueue`. `reconcileAndSaveQueue` +/// serially processes the events +/// - Once initial sync is done, the `ReconciliationQueue` is `start`ed, which activates the +/// `incomingSubscriptionEventQueue`. +/// - `incomingRemoteEventQueue` processes its operations, which are simply to call `enqueue` for each received remote +/// event. +final class AWSModelReconciliationQueue: ModelReconciliationQueue { + /// Exposes a publisher for incoming subscription events + private let incomingSubscriptionEvents: IncomingSubscriptionEventPublisher + + private let modelSchema: ModelSchema + weak var storageAdapter: StorageEngineAdapter? + private let modelPredicate: QueryPredicate? + + /// A buffer queue for incoming subsscription events, waiting for this ReconciliationQueue to be `start`ed. Once + /// the ReconciliationQueue is started, each event in the `incomingRemoveEventQueue` will be submitted to the + /// `reconcileAndSaveQueue`. + private let incomingSubscriptionEventQueue: OperationQueue + + /// Applies incoming mutation or subscription events serially to local data store for this model type. This queue + /// is always active. + private let reconcileAndSaveQueue: ReconcileAndSaveOperationQueue + + private var incomingEventsSink: AnyCancellable? + private var reconcileAndLocalSaveOperationSinks: AtomicValue> + + private let modelReconciliationQueueSubject: CurrentValueSubject + var publisher: AnyPublisher { + return modelReconciliationQueueSubject.eraseToAnyPublisher() + } + + init(modelSchema: ModelSchema, + storageAdapter: StorageEngineAdapter?, + api: APICategoryGraphQLBehavior, + reconcileAndSaveQueue: ReconcileAndSaveOperationQueue, + modelPredicate: QueryPredicate?, + auth: AuthCategoryBehavior?, + authModeStrategy: AuthModeStrategy, + incomingSubscriptionEvents: IncomingSubscriptionEventPublisher? = nil) async { + + self.modelSchema = modelSchema + self.storageAdapter = storageAdapter + + self.modelPredicate = modelPredicate + self.modelReconciliationQueueSubject = CurrentValueSubject(.idle) + + self.reconcileAndSaveQueue = reconcileAndSaveQueue + + self.incomingSubscriptionEventQueue = OperationQueue() + incomingSubscriptionEventQueue.name = "com.amazonaws.DataStore.\(modelSchema.name).remoteEvent" + incomingSubscriptionEventQueue.maxConcurrentOperationCount = 1 + incomingSubscriptionEventQueue.underlyingQueue = DispatchQueue.global() + incomingSubscriptionEventQueue.isSuspended = true + + let resolvedIncomingSubscriptionEvents: IncomingSubscriptionEventPublisher + if let incomingSubscriptionEvents = incomingSubscriptionEvents { + resolvedIncomingSubscriptionEvents = incomingSubscriptionEvents + } else { + resolvedIncomingSubscriptionEvents = await AWSIncomingSubscriptionEventPublisher( + modelSchema: modelSchema, + api: api, + modelPredicate: modelPredicate, + auth: auth, + authModeStrategy: authModeStrategy + ) + } + + self.incomingSubscriptionEvents = resolvedIncomingSubscriptionEvents + self.reconcileAndLocalSaveOperationSinks = AtomicValue(initialValue: Set()) + self.incomingEventsSink = resolvedIncomingSubscriptionEvents + .publisher + .sink(receiveCompletion: { [weak self] completion in + self?.receiveCompletion(completion) + }, receiveValue: { [weak self] receiveValue in + self?.receive(receiveValue) + }) + } + + /// (Re)starts the incoming subscription event queue. + func start() { + incomingSubscriptionEventQueue.isSuspended = false + modelReconciliationQueueSubject.send(.started) + } + + /// Pauses only the incoming subscription event queue. Events submitted via `enqueue` will still be processed + func pause() { + incomingSubscriptionEventQueue.isSuspended = true + modelReconciliationQueueSubject.send(.paused) + } + + /// Cancels all outstanding operations on both the incoming subscription event queue and the reconcile queue, and + /// unsubscribes from the incoming events publisher. The queue may not be restarted after cancelling. + func cancel() { + incomingEventsSink?.cancel() + incomingSubscriptionEvents.cancel() + reconcileAndSaveQueue.cancelOperations(modelName: modelSchema.name) + incomingSubscriptionEventQueue.cancelAllOperations() + } + + func enqueue(_ remoteModels: [MutationSync]) { + guard !remoteModels.isEmpty else { + log.debug("\(#function) skipping reconciliation, no models to enqueue.") + return + } + + let reconcileOp = ReconcileAndLocalSaveOperation(modelSchema: modelSchema, + remoteModels: remoteModels, + storageAdapter: storageAdapter) + var reconcileAndLocalSaveOperationSink: AnyCancellable? + reconcileAndLocalSaveOperationSink = reconcileOp + .publisher + .sink(receiveCompletion: { [weak self] completion in + guard let self = self else { + return + } + self.reconcileAndLocalSaveOperationSinks.with { $0.remove(reconcileAndLocalSaveOperationSink) } + if case .failure = completion { + self.modelReconciliationQueueSubject.send(completion: completion) + } + }, receiveValue: { [weak self] value in + guard let self = self else { + return + } + switch value { + case .mutationEventDropped(let modelName, let error): + self.modelReconciliationQueueSubject.send(.mutationEventDropped(modelName: modelName, error: error)) + case .mutationEvent(let event): + self.modelReconciliationQueueSubject.send(.mutationEvent(event)) + } + }) + reconcileAndLocalSaveOperationSinks.with { $0.insert(reconcileAndLocalSaveOperationSink) } + reconcileAndSaveQueue.addOperation(reconcileOp, modelName: modelSchema.name) + } + + private func receive(_ receive: IncomingSubscriptionEventPublisherEvent) { + switch receive { + case .mutationEvent(let remoteModel): + if let predicate = modelPredicate { + guard predicate.evaluate(target: remoteModel.model.instance) else { + return + } + } + incomingSubscriptionEventQueue.addOperation(CancelAwareBlockOperation { [weak self] in + self?.enqueue([remoteModel]) + }) + case .connectionConnected: + modelReconciliationQueueSubject.send(.connected(modelName: modelSchema.name)) + } + } + + private func receiveCompletion(_ completion: Subscribers.Completion) { + switch completion { + case .finished: + log.info("receivedCompletion: finished") + modelReconciliationQueueSubject.send(completion: .finished) + case .failure(let dataStoreError): + if case let .api(error, _) = dataStoreError, + case let APIError.operationError(errorDescription, _, underlyingError) = error, + isUnauthorizedError(errorDescription: errorDescription, underlyingError) { + log.verbose("[InitializeSubscription.3] AWSModelReconciliationQueue determined unauthorized \(modelSchema.name)") + modelReconciliationQueueSubject.send(.disconnected(modelName: modelSchema.name, reason: .unauthorized)) + return + } + if case let .api(error, _) = dataStoreError, + case let APIError.operationError(errorMessage, _, underlyingError) = error, + isOperationDisabledError(errorMessage, underlyingError) { + log.verbose("[InitializeSubscription.3] AWSModelReconciliationQueue determined isOperationDisabledError \(modelSchema.name)") + modelReconciliationQueueSubject.send(.disconnected(modelName: modelSchema.name, reason: .operationDisabled)) + return + } + log.error("[InitializeSubscription.3] AWSModelReconciliationQueue receiveCompletion: error: \(dataStoreError)") + modelReconciliationQueueSubject.send(completion: .failure(dataStoreError)) + } + } +} + +extension AWSModelReconciliationQueue: DefaultLogger { + public static var log: Logger { + Amplify.Logging.logger(forCategory: CategoryType.dataStore.displayName, forNamespace: String(describing: self)) + } + public var log: Logger { + Self.log + } +} + +// MARK: Resettable +extension AWSModelReconciliationQueue: Resettable { + + func reset() async { + log.verbose("Resetting incomingEventsSink") + incomingEventsSink?.cancel() + log.verbose("Resetting incomingEventsSink: finished") + + if let resettable = incomingSubscriptionEvents as? Resettable { + log.verbose("Resetting incomingSubscriptionEvents") + await resettable.reset() + self.log.verbose("Resetting incomingSubscriptionEvents: finished") + } + + log.verbose("Resetting incomingSubscriptionEventQueue") + incomingSubscriptionEventQueue.cancelAllOperations() + await withCheckedContinuation { (continuation: CheckedContinuation) in + incomingSubscriptionEventQueue.waitUntilAllOperationsAreFinished() + continuation.resume() + } + log.verbose("Resetting incomingSubscriptionEventQueue: finished") + } +} + +// MARK: Errors handling +extension AWSModelReconciliationQueue { + private typealias ResponseType = MutationSync + private func graphqlErrors(from error: GraphQLResponseError?) -> [GraphQLError]? { + if case let .error(errors) = error { + return errors + } + return nil + } + + private func errorTypeValueFrom(graphQLError: GraphQLError) -> String? { + guard case let .string(errorTypeValue) = graphQLError.extensions?["errorType"] else { + return nil + } + return errorTypeValue + } + + private func isUnauthorizedError(errorDescription: String, _ error: Error?) -> Bool { + if errorDescription.range(of: "Unauthorized", options: .caseInsensitive) != nil { + return true + } + if let responseError = error as? GraphQLResponseError, + let graphQLError = graphqlErrors(from: responseError)?.first, + let errorTypeValue = errorTypeValueFrom(graphQLError: graphQLError), + case .unauthorized = AppSyncErrorType(errorTypeValue) { + return true + } + return false + } + + private func isOperationDisabledError(_ errorMessage: String?, _ error: Error?) -> Bool { + if let errorMessage = errorMessage, + case .operationDisabled = AppSyncErrorType(errorMessage) { + return true + } + + if let responseError = error as? GraphQLResponseError, + let graphQLError = graphqlErrors(from: responseError)?.first, + let errorTypeValue = errorTypeValueFrom(graphQLError: graphQLError), + case .operationDisabled = AppSyncErrorType(errorTypeValue) { + return true + } + return false + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/ReconcileAndLocalSave/ModelReconciliationQueue.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/ReconcileAndLocalSave/ModelReconciliationQueue.swift new file mode 100644 index 0000000000..d30cbb46cb --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/ReconcileAndLocalSave/ModelReconciliationQueue.swift @@ -0,0 +1,31 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Combine + +enum ModelConnectionDisconnectedReason { + case unauthorized + case operationDisabled +} + +enum ModelReconciliationQueueEvent { + case idle + case started + case paused + case connected(modelName: String) + case disconnected(modelName: String, reason: ModelConnectionDisconnectedReason) + case mutationEvent(MutationEvent) + case mutationEventDropped(modelName: String, error: DataStoreError? = nil) +} + +protocol ModelReconciliationQueue { + func start() + func pause() + func cancel() + func enqueue(_ remoteModels: [MutationSync]) + var publisher: AnyPublisher { get } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/ReconcileAndLocalSave/ReconcileAndLocalSaveOperation+Action.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/ReconcileAndLocalSave/ReconcileAndLocalSaveOperation+Action.swift new file mode 100644 index 0000000000..4fa1e4e224 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/ReconcileAndLocalSave/ReconcileAndLocalSaveOperation+Action.swift @@ -0,0 +1,26 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + + +extension ReconcileAndLocalSaveOperation { + + /// Actions are declarative, they say what I just did + enum Action { + /// Operation has been started by the queue + case started([RemoteModel]) + + /// Operation completed reconcilliation + case reconciled + + /// Operation has been cancelled by the queue + case cancelled + + /// Operation has encountered an error + case errored(AmplifyError) + } + +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/ReconcileAndLocalSave/ReconcileAndLocalSaveOperation+Resolver.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/ReconcileAndLocalSave/ReconcileAndLocalSaveOperation+Resolver.swift new file mode 100644 index 0000000000..999d8ed8bc --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/ReconcileAndLocalSave/ReconcileAndLocalSaveOperation+Resolver.swift @@ -0,0 +1,40 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + + +extension ReconcileAndLocalSaveOperation { + struct Resolver { + + /// It's not absolutely required to make `resolve` a static, but it helps in two ways: + /// 1. It makes it easier to avoid retain cycles, since the reducer can't close over the state machine's owning + /// instance + /// 2. It helps enforce "pure function" behavior since `resolve` can only make decisions about the current state + /// and the action, rather than peeking out to some other state of the instance. + static func resolve(currentState: State, action: Action) -> State { + switch (currentState, action) { + + case (.waiting, .started(let remoteModels)): + return .reconciling(remoteModels) + + case (.reconciling, .reconciled): + return .finished + + case (_, .errored(let amplifyError)): + return .inError(amplifyError) + + case (.finished, _): + return .finished + + default: + log.warn("Unexpected state transition. In \(currentState), got \(action)") + return currentState + } + + } + + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/ReconcileAndLocalSave/ReconcileAndLocalSaveOperation+State.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/ReconcileAndLocalSave/ReconcileAndLocalSaveOperation+State.swift new file mode 100644 index 0000000000..b8086b221e --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/ReconcileAndLocalSave/ReconcileAndLocalSaveOperation+State.swift @@ -0,0 +1,25 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + + +extension ReconcileAndLocalSaveOperation { + + /// States are descriptive, they say what is happening in the system right now + enum State { + /// Waiting to be started by the queue + case waiting + + /// Reconciling remote models with local data + case reconciling([RemoteModel]) + + /// Operation has successfully completed + case finished + + /// Operation completed with an unexpected error + case inError(AmplifyError) + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/ReconcileAndLocalSave/ReconcileAndLocalSaveOperation.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/ReconcileAndLocalSave/ReconcileAndLocalSaveOperation.swift new file mode 100644 index 0000000000..a9d008993c --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/ReconcileAndLocalSave/ReconcileAndLocalSaveOperation.swift @@ -0,0 +1,535 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Combine +import Foundation + +// swiftlint:disable type_body_length file_length +/// Reconciles an incoming model mutation with the stored model. If there is no conflict (e.g., the incoming model has +/// a later version than the stored model), then write the new data to the store. +class ReconcileAndLocalSaveOperation: AsynchronousOperation { + + /// Disambiguation for the version of the model incoming from the remote API + typealias RemoteModel = MutationSync + + /// Disambiguation for the sync metadata for the model stored in local datastore + typealias LocalMetadata = MutationSyncMetadata + + /// Disambiguation for the version of the model that was applied to the local datastore. In the case of a create or + /// update mutation, this represents the saved model. In the case of a delete mutation, this is the data that was + /// sent from the remote API as part of the mutation. + typealias AppliedModel = MutationSync + + let id: UUID = UUID() + private let workQueue = DispatchQueue(label: "com.amazonaws.ReconcileAndLocalSaveOperation", + target: DispatchQueue.global()) + + private weak var storageAdapter: StorageEngineAdapter? + private let stateMachine: StateMachine + private let remoteModels: [RemoteModel] + private let modelSchema: ModelSchema + private let stopwatch: Stopwatch + private var stateMachineSink: AnyCancellable? + private var cancellables: Set + private let mutationEventPublisher: PassthroughSubject + public var publisher: AnyPublisher { + return mutationEventPublisher.eraseToAnyPublisher() + } + + var isEagerLoad: Bool = true + + init(modelSchema: ModelSchema, + remoteModels: [RemoteModel], + storageAdapter: StorageEngineAdapter?, + stateMachine: StateMachine? = nil) { + self.modelSchema = modelSchema + self.remoteModels = remoteModels + self.storageAdapter = storageAdapter + self.stopwatch = Stopwatch() + self.stateMachine = stateMachine ?? StateMachine(initialState: .waiting, + resolver: Resolver.resolve(currentState:action:)) + self.mutationEventPublisher = PassthroughSubject() + + self.cancellables = Set() + + // `isEagerLoad` is true by default, unless the models contain the rootPath + // which is indication that codegenerated model types support for lazy loading. + if isEagerLoad && ModelRegistry.modelType(from: modelSchema.name)?.rootPath != nil { + self.isEagerLoad = false + } + + super.init() + + self.stateMachineSink = self.stateMachine + .$state + .sink { [weak self] newState in + guard let self = self else { + return + } + self.log.verbose("New state: \(newState)") + self.workQueue.async { + self.respond(to: newState) + } + } + } + + override func main() { + log.verbose(#function) + + guard !isCancelled else { + return + } + + stopwatch.start() + stateMachine.notify(action: .started(remoteModels)) + } + + /// Listens to incoming state changes and invokes the appropriate asynchronous methods in response. + func respond(to newState: State) { + log.verbose("\(#function): \(newState)") + + switch newState { + case .waiting: + break + + case .reconciling(let remoteModels): + reconcile(remoteModels: remoteModels) + + case .inError(let error): + // Maybe we have to notify the Hub? + log.error(error: error) + notifyFinished() + + case .finished: + // Maybe we have to notify the Hub? + notifyFinished() + } + } + + // MARK: - Responder methods + + /// The reconcile function incorporates incoming mutation events into the local database through the following steps: + /// 1. Retrieve the local metadata of the models. + /// 2. Generate dispositions based on incoming mutation events and local metadata. + /// 3. Categorize dispositions into: + /// 3.1 Apply metadata only for those with existing pending mutations. + /// 3.1.1 Notify the count of these incoming mutation events as dropped items. + /// 3.2 Apply incoming mutation and metadata for those without existing pending mutations. + /// 4. Notify the final result. + func reconcile(remoteModels: [RemoteModel]) { + guard !isCancelled else { + log.info("\(#function) - cancelled, aborting") + return + } + + guard let storageAdapter = storageAdapter else { + let error = DataStoreError.nilStorageAdapter() + notifyDropped(count: remoteModels.count, error: error) + stateMachine.notify(action: .errored(error)) + return + } + + guard !remoteModels.isEmpty else { + stateMachine.notify(action: .reconciled) + return + } + + do { + try storageAdapter.transaction { + self.queryLocalMetadata(remoteModels) + .subscribe(on: workQueue) + .map { (remoteModels, localMetadatas) in + self.getDispositions(for: remoteModels, localMetadatas: localMetadatas) + } + .flatMap { dispositions in + self.queryPendingMutations(withModels: dispositions.map(\.remoteModel.model)) + .map { pendingMutations in (pendingMutations, dispositions) } + } + .map { (pendingMutations, dispositions) in + self.separateDispositions(pendingMutations: pendingMutations, dispositions: dispositions) + } + .flatMap { (dispositions, dispositionOnlyApplyMetadata) in + self.waitAllPublisherFinishes(publishers: dispositionOnlyApplyMetadata.map(self.saveMetadata(disposition:))) + .flatMap { _ in self.applyRemoteModelsDispositions(dispositions) } + } + .sink( + receiveCompletion: { + if case .failure(let error) = $0 { + self.stateMachine.notify(action: .errored(error)) + } + }, + receiveValue: { + self.stateMachine.notify(action: .reconciled) + } + ) + .store(in: &cancellables) + } + } catch let dataStoreError as DataStoreError { + stateMachine.notify(action: .errored(dataStoreError)) + } catch { + let dataStoreError = DataStoreError.invalidOperation(causedBy: error) + stateMachine.notify(action: .errored(dataStoreError)) + } + } + + func queryPendingMutations(withModels models: [Model]) -> Future<[MutationEvent], DataStoreError> { + Future<[MutationEvent], DataStoreError> { promise in + var result: Result<[MutationEvent], DataStoreError> = .failure(Self.unfulfilledDataStoreError()) + guard !self.isCancelled else { + self.log.info("\(#function) - cancelled, aborting") + result = .success([]) + promise(result) + return + } + guard let storageAdapter = self.storageAdapter else { + let error = DataStoreError.nilStorageAdapter() + self.notifyDropped(count: models.count, error: error) + result = .failure(error) + promise(result) + return + } + + guard !models.isEmpty else { + result = .success([]) + promise(result) + return + } + + MutationEvent.pendingMutationEvents( + forModels: models, + storageAdapter: storageAdapter + ) { queryResult in + switch queryResult { + case .failure(let dataStoreError): + self.notifyDropped(count: models.count, error: dataStoreError) + result = .failure(dataStoreError) + case .success(let mutationEvents): + result = .success(mutationEvents) + } + promise(result) + } + } + } + + func separateDispositions( + pendingMutations: [MutationEvent], + dispositions: [RemoteSyncReconciler.Disposition] + ) -> ([RemoteSyncReconciler.Disposition], [RemoteSyncReconciler.Disposition]) { + guard !dispositions.isEmpty else { + return ([], []) + } + + + let pendingMutationModelIds = Set(pendingMutations.map(\.modelId)) + + let dispositionsToApply = dispositions.filter { + !pendingMutationModelIds.contains($0.remoteModel.model.identifier) + } + + let dispositionsOnlyApplyMetadata = dispositions.filter { + pendingMutationModelIds.contains($0.remoteModel.model.identifier) + } + + notifyDropped(count: dispositionsOnlyApplyMetadata.count) + return (dispositionsToApply, dispositionsOnlyApplyMetadata) + } + + func queryLocalMetadata(_ remoteModels: [RemoteModel]) -> Future<([RemoteModel], [LocalMetadata]), DataStoreError> { + Future<([RemoteModel], [LocalMetadata]), DataStoreError> { promise in + var result: Result<([RemoteModel], [LocalMetadata]), DataStoreError> = + .failure(Self.unfulfilledDataStoreError()) + defer { + promise(result) + } + guard !self.isCancelled else { + self.log.info("\(#function) - cancelled, aborting") + result = .success(([], [])) + return + } + guard let storageAdapter = self.storageAdapter else { + let error = DataStoreError.nilStorageAdapter() + self.notifyDropped(count: remoteModels.count, error: error) + result = .failure(error) + return + } + + guard !remoteModels.isEmpty else { + result = .success(([], [])) + return + } + + do { + let localMetadatas = try storageAdapter.queryMutationSyncMetadata( + for: remoteModels.map { $0.model.identifier }, + modelName: self.modelSchema.name) + result = .success((remoteModels, localMetadatas)) + } catch { + let error = DataStoreError(error: error) + self.notifyDropped(count: remoteModels.count, error: error) + result = .failure(error) + return + } + } + } + + func getDispositions(for remoteModels: [RemoteModel], + localMetadatas: [LocalMetadata]) -> [RemoteSyncReconciler.Disposition] { + guard !remoteModels.isEmpty else { + return [] + } + + let dispositions = RemoteSyncReconciler.getDispositions(remoteModels, + localMetadatas: localMetadatas) + notifyDropped(count: remoteModels.count - dispositions.count) + return dispositions + } + + func applyRemoteModelsDisposition( + storageAdapter: StorageEngineAdapter, + disposition: RemoteSyncReconciler.Disposition + ) -> AnyPublisher, Never> { + let operation: Future + switch disposition { + case .create, .update: + operation = self.save(storageAdapter: storageAdapter, remoteModel: disposition.remoteModel) + case .delete: + operation = self.delete(storageAdapter: storageAdapter, remoteModel: disposition.remoteModel) + } + + return operation + .flatMap { self.saveMetadata(storageAdapter: storageAdapter, result: $0, mutationType: disposition.mutationType) } + .map { _ in Result.success(()) } + .catch { Just>(.failure($0))} + .eraseToAnyPublisher() + } + + // TODO: refactor - move each the publisher constructions to its own utility method for readability of the + // `switch` and a single method that you can invoke in the `map` + func applyRemoteModelsDispositions( + _ dispositions: [RemoteSyncReconciler.Disposition] + ) -> Future { + guard !self.isCancelled else { + self.log.info("\(#function) - cancelled, aborting") + return Future { $0(.successfulVoid) } + } + + guard let storageAdapter = self.storageAdapter else { + let error = DataStoreError.nilStorageAdapter() + self.notifyDropped(count: dispositions.count, error: error) + return Future { $0(.failure(error)) } + } + + guard !dispositions.isEmpty else { + return Future { $0(.successfulVoid) } + } + + let publishers = dispositions.map { + applyRemoteModelsDisposition(storageAdapter: storageAdapter, disposition: $0) + } + + return self.waitAllPublisherFinishes(publishers: publishers) + } + + enum ApplyRemoteModelResult { + case applied(RemoteModel, AppliedModel) + case dropped + } + + private func delete(storageAdapter: StorageEngineAdapter, + remoteModel: RemoteModel) -> Future { + Future { promise in + guard let modelType = ModelRegistry.modelType(from: self.modelSchema.name) else { + let error = DataStoreError.invalidModelName(self.modelSchema.name) + promise(.failure(error)) + return + } + + storageAdapter.delete(untypedModelType: modelType, + modelSchema: self.modelSchema, + withIdentifier: remoteModel.model.identifier(schema: self.modelSchema), + condition: nil) { response in + switch response { + case .failure(let dataStoreError): + self.notifyDropped(error: dataStoreError) + if storageAdapter.shouldIgnoreError(error: dataStoreError) { + promise(.success(.dropped)) + } else { + promise(.failure(dataStoreError)) + } + case .success: + promise(.success(.applied(remoteModel, remoteModel))) + } + } + } + } + + private func save( + storageAdapter: StorageEngineAdapter, + remoteModel: RemoteModel + ) -> Future { + Future { promise in + storageAdapter.save(untypedModel: remoteModel.model.instance, eagerLoad: self.isEagerLoad) { response in + switch response { + case .failure(let dataStoreError): + self.notifyDropped(error: dataStoreError) + if storageAdapter.shouldIgnoreError(error: dataStoreError) { + promise(.success(.dropped)) + } else { + promise(.failure(dataStoreError)) + } + case .success(let savedModel): + let anyModel: AnyModel + do { + anyModel = try savedModel.eraseToAnyModel() + let appliedModel = MutationSync(model: anyModel, syncMetadata: remoteModel.syncMetadata) + promise(.success(.applied(remoteModel, appliedModel))) + } catch { + let dataStoreError = DataStoreError(error: error) + self.notifyDropped(error: dataStoreError) + promise(.failure(dataStoreError)) + } + } + } + } + } + + private func saveMetadata( + disposition: RemoteSyncReconciler.Disposition + ) -> AnyPublisher { + guard let storageAdapter = self.storageAdapter else { + return Just(()).eraseToAnyPublisher() + } + return saveMetadata(storageAdapter: storageAdapter, remoteModel: disposition.remoteModel, mutationType: disposition.mutationType) + .map { _ in () } + .catch { _ in Just(()) } + .eraseToAnyPublisher() + } + + private func saveMetadata( + storageAdapter: StorageEngineAdapter, + result: ApplyRemoteModelResult, + mutationType: MutationEvent.MutationType + ) -> AnyPublisher { + switch result { + case .applied(let remoteModel, let appliedModel): + return self.saveMetadata(storageAdapter: storageAdapter, remoteModel: remoteModel, mutationType: mutationType) + .map { MutationSync(model: appliedModel.model, syncMetadata: $0) } + .map { [weak self] in self?.notify(appliedModel: $0, mutationType: mutationType) } + .eraseToAnyPublisher() + case .dropped: + return Just(()).setFailureType(to: DataStoreError.self).eraseToAnyPublisher() + } + } + + private func saveMetadata( + storageAdapter: StorageEngineAdapter, + remoteModel: RemoteModel, + mutationType: MutationEvent.MutationType + ) -> Future { + Future { promise in + storageAdapter.save( + remoteModel.syncMetadata, + condition: nil, + eagerLoad: self.isEagerLoad + ) { result in + switch result { + case .failure(let error): + self.notifyDropped(error: error) + case .success: + self.notifyHub(remoteModel: remoteModel, mutationType: mutationType) + } + promise(result) + } + } + } + + private func notifyDropped(count: Int = 1, error: DataStoreError? = nil) { + for _ in 0 ..< count { + mutationEventPublisher.send(.mutationEventDropped(modelName: modelSchema.name, error: error)) + } + } + + /// Inform the mutationEvents subscribers about the updated model, + /// which incorporates lazy loading information if applicable. + private func notify(appliedModel: AppliedModel, mutationType: MutationEvent.MutationType) { + guard let json = try? appliedModel.model.instance.toJSON() else { + log.error("Could not notify mutation event") + return + } + + let modelIdentifier = appliedModel.model.instance.identifier(schema: modelSchema).stringValue + let mutationEvent = MutationEvent(modelId: modelIdentifier, + modelName: modelSchema.name, + json: json, + mutationType: mutationType, + version: appliedModel.syncMetadata.version) + mutationEventPublisher.send(.mutationEvent(mutationEvent)) + } + + /// Inform the remote mutationEvents to Hub event subscribers, + /// which only contains information received from AppSync server. + private func notifyHub( + remoteModel: RemoteModel, + mutationType: MutationEvent.MutationType + ) { + // TODO: Dispatch/notify error if we can't erase to any model? Would imply an error in JSON decoding, + // which shouldn't be possible this late in the process. Possibly notify global conflict/error handler? + guard let json = try? remoteModel.model.instance.toJSON() else { + log.error("Could not notify Hub mutation event") + return + } + + let modelIdentifier = remoteModel.model.instance.identifier(schema: modelSchema).stringValue + let mutationEvent = MutationEvent(modelId: modelIdentifier, + modelName: modelSchema.name, + json: json, + mutationType: mutationType, + version: remoteModel.syncMetadata.version) + + let payload = HubPayload(eventName: HubPayload.EventName.DataStore.syncReceived, + data: mutationEvent) + Amplify.Hub.dispatch(to: .dataStore, payload: payload) + } + + private func notifyFinished() { + if log.logLevel == .debug { + log.debug("total time: \(stopwatch.stop())s") + } + mutationEventPublisher.send(completion: .finished) + finish() + } + + private static func unfulfilledDataStoreError(name: String = #function) -> DataStoreError { + .unknown("\(name) did not fulfill promise", AmplifyErrorMessages.shouldNotHappenReportBugToAWS(), nil) + } + + private func waitAllPublisherFinishes(publishers: [AnyPublisher]) -> Future { + Future { promise in + Publishers.MergeMany(publishers) + .collect() + .sink(receiveCompletion: { _ in + promise(.successfulVoid) + }, receiveValue: { _ in }) + .store(in: &self.cancellables) + } + } +} + +extension ReconcileAndLocalSaveOperation: DefaultLogger { + public static var log: Logger { + Amplify.Logging.logger(forCategory: CategoryType.dataStore.displayName, forNamespace: String(describing: self)) + } + public var log: Logger { + Self.log + } +} + +enum ReconcileAndLocalSaveOperationEvent { + case mutationEvent(MutationEvent) + case mutationEventDropped(modelName: String, error: DataStoreError? = nil) +} +// swiftlint:enable type_body_length file_length diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/ReconcileAndLocalSave/ReconcileAndLocalSaveQueue.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/ReconcileAndLocalSave/ReconcileAndLocalSaveQueue.swift new file mode 100644 index 0000000000..a04377b857 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/ReconcileAndLocalSave/ReconcileAndLocalSaveQueue.swift @@ -0,0 +1,118 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Combine +import Foundation + +protocol ReconcileAndSaveOperationQueue { + func addOperation(_ operation: ReconcileAndLocalSaveOperation, modelName: String) + + func cancelOperations(modelName: String) + + func cancelAllOperations() + + func waitUntilOperationsAreFinished() + + init(_ modelSchemas: [ModelSchema]) +} + +enum ReconcileAndSaveQueueEvent { + case operationAdded(id: UUID) + case operationRemoved(id: UUID) + case cancelledOperations(modelName: String) +} + +/// A queue used to enqueue `ReconcileAndLocalSaveOperation`s which perform reconcile and save for incoming mutation +/// sync events to local storage for all model types. +/// +/// Internally, a `ReconcileAndSaveQueue` will manage an operation queue with concurrency of 1 to perform serial +/// operations to ensure models are processed in the order of dependency. For example, an initial sync +/// will perform sync queries for models based on its dependency order. The results are then processed serially by this +/// queue to ensure that a model A that depends on B, and B depends on C, will be reconciled in the order of C then B +/// then A. This also ensures that reconciliation for individual subscription events are also processed in the order +/// in which they are received by the system. +/// +/// Additionally, this queue allows per model type cancellations on the operations that are enqueued by calling +/// `cancelOperations(modelName)`. This allows per model type clean up, while allowing other model reconcilliations to +/// continue to operate. +class ReconcileAndSaveQueue: ReconcileAndSaveOperationQueue { + + private let serialQueue = DispatchQueue(label: "com.amazonaws.ReconcileAndSaveQueue.serialQueue", + target: DispatchQueue.global()) + private let reconcileAndSaveQueue: OperationQueue + private var modelReconcileAndSaveOperations: [String: [UUID: ReconcileAndLocalSaveOperation]] + private var reconcileAndLocalSaveOperationSinks: AtomicValue> + + private let reconcileAndSaveQueueSubject: PassthroughSubject + var publisher: AnyPublisher { + reconcileAndSaveQueueSubject.eraseToAnyPublisher() + } + required init(_ modelSchemas: [ModelSchema]) { + self.reconcileAndSaveQueueSubject = PassthroughSubject() + self.reconcileAndSaveQueue = OperationQueue() + reconcileAndSaveQueue.name = "com.amazonaws.DataStore.reconcile" + reconcileAndSaveQueue.maxConcurrentOperationCount = 1 + reconcileAndSaveQueue.underlyingQueue = DispatchQueue.global() + reconcileAndSaveQueue.isSuspended = false + + self.modelReconcileAndSaveOperations = [String: [UUID: ReconcileAndLocalSaveOperation]]() + for model in modelSchemas { + modelReconcileAndSaveOperations[model.name] = [UUID: ReconcileAndLocalSaveOperation]() + } + self.reconcileAndLocalSaveOperationSinks = AtomicValue(initialValue: Set()) + } + + func addOperation(_ operation: ReconcileAndLocalSaveOperation, modelName: String) { + + serialQueue.async { + var reconcileAndLocalSaveOperationSink: AnyCancellable? + reconcileAndLocalSaveOperationSink = operation.publisher.sink { _ in + self.serialQueue.async { + self.reconcileAndLocalSaveOperationSinks.with { $0.remove(reconcileAndLocalSaveOperationSink) } + self.modelReconcileAndSaveOperations[modelName]?[operation.id] = nil + self.reconcileAndSaveQueueSubject.send(.operationRemoved(id: operation.id)) + } + } receiveValue: { _ in } + + self.reconcileAndLocalSaveOperationSinks.with { $0.insert(reconcileAndLocalSaveOperationSink) } + self.modelReconcileAndSaveOperations[modelName]?[operation.id] = operation + self.reconcileAndSaveQueue.addOperation(operation) + self.reconcileAndSaveQueueSubject.send(.operationAdded(id: operation.id)) + } + } + + func cancelOperations(modelName: String) { + serialQueue.async { + if let operations = self.modelReconcileAndSaveOperations[modelName] { + operations.values.forEach { operation in + operation.cancel() + } + } + self.modelReconcileAndSaveOperations[modelName]?.removeAll() + self.reconcileAndSaveQueueSubject.send(.cancelledOperations(modelName: modelName)) + } + } + + func cancelAllOperations() { + serialQueue.async { + self.reconcileAndSaveQueue.cancelAllOperations() + for (modelName, _) in self.modelReconcileAndSaveOperations { + self.modelReconcileAndSaveOperations[modelName]?.removeAll() + self.reconcileAndSaveQueueSubject.send(.cancelledOperations(modelName: modelName)) + } + } + } + + // This method should only be used in the `reset` chain, which is an internal reset functionality that is used + // for resetting the state of the system in testing. It blocks the current thread by not executing the work on + // the serial queue since underlying operation queue's `waitUntilAllOperationsAreFinished()` behaves the same way. + // See the following link for more details: + // https://developer.apple.com/documentation/foundation/operationqueue/1407971-waituntilalloperationsarefinishe + func waitUntilOperationsAreFinished() { + reconcileAndSaveQueue.waitUntilAllOperationsAreFinished() + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/ReconcileAndLocalSave/RemoteSyncReconciler.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/ReconcileAndLocalSave/RemoteSyncReconciler.swift new file mode 100644 index 0000000000..021a0021d7 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/ReconcileAndLocalSave/RemoteSyncReconciler.swift @@ -0,0 +1,97 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + + +/// Reconciles incoming sync mutations with the state of the local store, and mutation queue. +struct RemoteSyncReconciler { + typealias LocalMetadata = ReconcileAndLocalSaveOperation.LocalMetadata + typealias RemoteModel = ReconcileAndLocalSaveOperation.RemoteModel + + enum Disposition { + case create(RemoteModel) + case update(RemoteModel) + case delete(RemoteModel) + + var remoteModel: RemoteModel { + switch self { + case .create(let model), .update(let model), .delete(let model): + return model + } + } + + var mutationType: MutationEvent.MutationType { + switch self { + case .create: return .create + case .update: return .update + case .delete: return .delete + } + } + } + + /// Reconciles the incoming `remoteModels` against the local metadata to get the disposition + /// + /// GroupBy the remoteModels by model identifier and apply only the latest version of the remoteModel + /// + /// - Parameters: + /// - remoteModels: models retrieved from the remote store + /// - localMetadatas: metadata retrieved from the local store + /// - Returns: disposition of models to apply locally + static func getDispositions( + _ remoteModels: [RemoteModel], + localMetadatas: [LocalMetadata] + ) -> [Disposition] { + let remoteModelsGroupByIdentifier = remoteModels.reduce([String: [RemoteModel]]()) { + $0.merging([ + $1.model.identifier: ($0[$1.model.identifier] ?? []) + [$1] + ], uniquingKeysWith: { $1 }) + } + + let optimizedRemoteModels = remoteModelsGroupByIdentifier.values.compactMap { + $0.sorted(by: { $0.syncMetadata.version > $1.syncMetadata.version }).first + } + + guard !optimizedRemoteModels.isEmpty else { + return [] + } + + guard !localMetadatas.isEmpty else { + return optimizedRemoteModels.compactMap { getDisposition($0, localMetadata: nil) } + } + + let metadataByModelId = localMetadatas.reduce(into: [:]) { $0[$1.modelId] = $1 } + let dispositions = optimizedRemoteModels.compactMap { + getDisposition($0, localMetadata: metadataByModelId[$0.model.identifier]) + } + + return dispositions + } + + /// Reconcile a remote model against local metadata + /// If there is no local metadata for the corresponding remote model, and the remote model is not deleted, apply a + /// `.create` disposition + /// If there is no local metadata for the corresponding remote model, and the remote model is deleted, drop it + /// If there is local metadata for the corresponding remote model, and the remote model is not deleted, apply an + /// `.update` disposition + /// if there is local metadata for the corresponding remote model, and the remote model is deleted, apply a + /// `.delete` disposition + /// + /// - Parameters: + /// - remoteModel: model retrieved from the remote store + /// - localMetadata: metadata corresponding to the remote model + /// - Returns: disposition of the model, `nil` if to be dropped + static func getDisposition(_ remoteModel: RemoteModel, localMetadata: LocalMetadata?) -> Disposition? { + guard let localMetadata = localMetadata else { + return remoteModel.syncMetadata.deleted ? nil : .create(remoteModel) + } + + guard remoteModel.syncMetadata.version > localMetadata.version else { + return nil + } + + return remoteModel.syncMetadata.deleted ? .delete(remoteModel) : .update(remoteModel) + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/Support/CancelAwareBlockOperation.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/Support/CancelAwareBlockOperation.swift new file mode 100644 index 0000000000..18b0135fbd --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/Support/CancelAwareBlockOperation.swift @@ -0,0 +1,22 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +final class CancelAwareBlockOperation: Operation { + private let block: BasicClosure + init(block: @escaping BasicClosure) { + self.block = block + } + + override func main() { + guard !isCancelled else { + return + } + block() + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/Support/DataStoreError+Plugin.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/Support/DataStoreError+Plugin.swift new file mode 100644 index 0000000000..0d89e8e7a2 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/Support/DataStoreError+Plugin.swift @@ -0,0 +1,53 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + + +/// Convenience error types +extension DataStoreError { + + static func nilAPIHandle(file: StaticString = #file, + function: StaticString = #function, + line: UInt = #line) -> DataStoreError { + .internalOperation( + "The reference to Amplify API is unexpectedly nil in an internal operation", + """ + \(AmplifyErrorMessages.reportBugToAWS()) \ + The reference to API has been released while the DataStore was attempting to access the remote API. \ + \(file), \(function), \(line) + """ + ) + } + + static func nilReconciliationQueue(file: StaticString = #file, + function: StaticString = #function, + line: UInt = #line) -> DataStoreError { + .internalOperation( + "The reference to IncomingEventReconciliationQueue is unexpectedly nil in an internal operation", + """ + \(AmplifyErrorMessages.reportBugToAWS()) \ + The reference to IncomingEventReconciliationQueue has been released while the DataStore was attempting to \ + enqueue a remote subscription or mutation event. \ + \(file), \(function), \(line) + """ + ) + } + + static func nilStorageAdapter(file: StaticString = #file, + function: StaticString = #function, + line: UInt = #line) -> DataStoreError { + .internalOperation( + "storageAdapter is unexpectedly nil in an internal operation", + """ + \(AmplifyErrorMessages.reportBugToAWS()) \ + The reference to storageAdapter has been released while the DataStore was attempting to access the local \ + database. \ + \(file), \(function), \(line) + """ + ) + } + +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/Support/Model+Compare.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/Support/Model+Compare.swift new file mode 100644 index 0000000000..b60d22bf09 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/Support/Model+Compare.swift @@ -0,0 +1,166 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +// swiftlint:disable cyclomatic_complexity +extension ModelSchema { + + private static let serviceUpdatedFields: Set = ["updatedAt", "createdAt"] + + /// Compare two `Model` based on a given `ModelSchema` + /// Returns true if equal, false otherwise + /// Currently, schemas where system timestamps fields (createdAt, updatedAt) + /// are renamed using with `@model`'s `timestamps` attribute and explicitly + /// added to the input schema are not supported by this check since they are not + /// marked as "read-only" fields and will fail the check when the service generates + /// and returns the value of `createdAt` or `updatedAt`. + /// for e.g. + /// type Post @model(timestamps:{createdAt: "createdOn", updatedAt: "updatedOn"}) { + /// id: ID! + /// title: String! + /// tags: [String!]! + /// createdOn: AWSDateTime + /// updatedOn: AWSDateTime + /// } + func compare(_ model1: Model, _ model2: Model) -> Bool { + let modelType1 = ModelRegistry.modelType(from: model1.modelName) + let modelType2 = ModelRegistry.modelType(from: model2.modelName) + if modelType1 != modelType2 { + // no need to compare models as they have different type + return false + } + + for (fieldName, modelField) in fields { + // read only fields or fields updated from the service are skipped for equality check + // examples of such fields include `createdAt`, `updatedAt` and `coverId` in `Record` + if modelField.isReadOnly || ModelSchema.serviceUpdatedFields.contains(modelField.name) { + continue + } + + let value1 = model1[fieldName] ?? nil + let value2 = model2[fieldName] ?? nil + + // check equality for different `ModelFieldType` + switch modelField.type { + case .string: + guard let value1Optional = value1 as? String?, let value2Optional = value2 as? String? else { + return false + } + if !compare(value1Optional, value2Optional) { + return false + } + case .int: + if let value1Optional = value1 as? Int?, let value2Optional = value2 as? Int? { + if !compare(value1Optional, value2Optional) { + return false + } + } + if let value1Optional = value1 as? Int64?, let value2Optional = value2 as? Int64? { + if !compare(value1Optional, value2Optional) { + return false + } + } + return false + case .double: + guard let value1Optional = value1 as? Double?, let value2Optional = value2 as? Double? else { + return false + } + if !compare(value1Optional, value2Optional) { + return false + } + case .date: + guard let value1Optional = value1 as? Temporal.Date?, + let value2Optional = value2 as? Temporal.Date? else { + return false + } + if !compare(value1Optional, value2Optional) { + return false + } + case .dateTime: + guard let value1Optional = value1 as? Temporal.DateTime?, + let value2Optional = value2 as? Temporal.DateTime? else { + return false + } + if !compare(value1Optional, value2Optional) { + return false + } + case .time: + guard let value1Optional = value1 as? Temporal.Time?, + let value2Optional = value2 as? Temporal.Time? else { + return false + } + if !compare(value1Optional, value2Optional) { + return false + } + case .timestamp: + guard let value1Optional = value1 as? String?, let value2Optional = value2 as? String? else { + return false + } + if !compare(value1Optional, value2Optional) { + return false + } + case .bool: + guard let value1Optional = value1 as? Bool?, let value2Optional = value2 as? Bool? else { + return false + } + if !compare(value1Optional?.intValue, value2Optional?.intValue) { + return false + } + case .enum: + // swiftlint:disable syntactic_sugar + guard case .some(Optional.some(let value1Optional)) = value1, + case .some(Optional.some(let value2Optional)) = value2 else { + if value1 == nil && value2 == nil { + continue + } + return false + } + // swiftlint:enable syntactic_sugar + let enumValue1Optional = (value1Optional as? EnumPersistable)?.rawValue + let enumValue2Optional = (value2Optional as? EnumPersistable)?.rawValue + if !compare(enumValue1Optional, enumValue2Optional) { + return false + } + case .embedded, .embeddedCollection: + do { + if let encodable1 = value1 as? Encodable, + let encodable2 = value2 as? Encodable { + let json1 = try SQLiteModelValueConverter.toJSON(encodable1) + let json2 = try SQLiteModelValueConverter.toJSON(encodable2) + if !compare(json1, json2) { + return false + } + } + } catch { + continue + } + // only the first level of data is used for comparison of models + // and deeper levels(associated models/connections) are ignored + // e.g. The graphql request contains only the information needed in the graphql variables which is sent to + // the service. In such a case, the request model may have multiple levels of data while the response + // model will have just one. + case .model, .collection: + continue + } + } + return true + } + + private func compare(_ value1: T?, _ value2: T?) -> Bool { + switch (value1, value2) { + case(nil, nil): + return true + case(nil, .some): + return false + case (.some, nil): + return false + case (.some(let val1), .some(let val2)): + return val1 == val2 ? true : false + } + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/Support/MutationEvent+Extensions.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/Support/MutationEvent+Extensions.swift new file mode 100644 index 0000000000..0c6e2e309a --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/Support/MutationEvent+Extensions.swift @@ -0,0 +1,102 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Dispatch + +extension MutationEvent { + // Consecutive operations that modify a model results in a sequence of pending mutation events that + // have the current version of the model. The first mutation event has the correct version of the model, + // while the subsequent events will have lower versions if the first mutation event is successfully synced + // to the cloud. By reconciling the pending mutation events after syncing the first mutation event, + // we attempt to update the pending version to the latest version from the response. + // The before and after conditions for consecutive update scenarios are as below: + // - Save, then immediately update + // Queue Before - [(version: nil, inprocess: true, type: .create), + // (version: nil, inprocess: false, type: .update)] + // Response - [version: 1, type: .create] + // Queue After - [(version: 1, inprocess: false, type: .update)] + // - Save, then immediately delete + // Queue Before - [(version: nil, inprocess: true, type: .create), + // (version: nil, inprocess: false, type: .delete)] + // Response - [version: 1, type: .create] + // Queue After - [(version: 1, inprocess: false, type: .delete)] + // - Save, sync, then immediately update and delete + // Queue Before (After save, sync) + // - [(version: 1, inprocess: true, type: .update), (version: 1, inprocess: false, type: .delete)] + // Response - [version: 2, type: .update] + // Queue After - [(version: 2, inprocess: false, type: .delete)] + // + // For a given model `id`, checks the version of the head of pending mutation event queue + // against the API response version in `mutationSync` and saves it in the mutation event table if + // the response version is a newer one + static func reconcilePendingMutationEventsVersion(sent mutationEvent: MutationEvent, + received mutationSync: MutationSync, + storageAdapter: StorageEngineAdapter, + completion: @escaping DataStoreCallback) { + MutationEvent.pendingMutationEvents( + forMutationEvent: mutationEvent, + storageAdapter: storageAdapter + ) { queryResult in + switch queryResult { + case .failure(let dataStoreError): + completion(.failure(dataStoreError)) + case .success(let localMutationEvents): + guard let existingEvent = localMutationEvents.first else { + completion(.success(())) + return + } + + guard let reconciledEvent = reconcile(pendingMutationEvent: existingEvent, + with: mutationEvent, + responseMutationSync: mutationSync) else { + completion(.success(())) + return + } + + storageAdapter.save(reconciledEvent, condition: nil, eagerLoad: true) { result in + switch result { + case .failure(let dataStoreError): + completion(.failure(dataStoreError)) + case .success: + completion(.success(())) + } + } + } + } + } + + static func reconcile(pendingMutationEvent: MutationEvent, + with requestMutationEvent: MutationEvent, + responseMutationSync: MutationSync) -> MutationEvent? { + // return if version of the pending mutation event is not nil and + // is >= version contained in the response + if pendingMutationEvent.version != nil && + pendingMutationEvent.version! >= responseMutationSync.syncMetadata.version { + return nil + } + + do { + let responseModel = responseMutationSync.model.instance + let requestModel = try requestMutationEvent.decodeModel() + + // check if the data sent in the request is the same as the response + // if it is, update the pending mutation event version to the response version + guard let modelSchema = ModelRegistry.modelSchema(from: requestMutationEvent.modelName), + modelSchema.compare(responseModel, requestModel) else { + return nil + } + + var pendingMutationEvent = pendingMutationEvent + pendingMutationEvent.version = responseMutationSync.syncMetadata.version + return pendingMutationEvent + } catch { + Amplify.log.verbose("Error decoding models: \(error)") + return nil + } + } + +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/Support/MutationEvent+Query.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/Support/MutationEvent+Query.swift new file mode 100644 index 0000000000..cedc6d9909 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/Support/MutationEvent+Query.swift @@ -0,0 +1,97 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Dispatch + +extension MutationEvent { + static func pendingMutationEvents( + forModel model: Model, + storageAdapter: StorageEngineAdapter, + completion: @escaping DataStoreCallback<[MutationEvent]> + ) { + pendingMutationEvents( + forModels: [model], + storageAdapter: storageAdapter, + completion: completion + ) + } + + static func pendingMutationEvents( + forMutationEvent mutationEvent: MutationEvent, + storageAdapter: StorageEngineAdapter, + completion: @escaping DataStoreCallback<[MutationEvent]> + ) { + pendingMutationEvents( + forMutationEvents: [mutationEvent], + storageAdapter: storageAdapter, + completion: completion + ) + } + + static func pendingMutationEvents( + forMutationEvents mutationEvents: [MutationEvent], + storageAdapter: StorageEngineAdapter, + completion: @escaping DataStoreCallback<[MutationEvent]> + ) { + pendingMutationEvents( + for: mutationEvents.map { ($0.modelId, $0.modelName) }, + storageAdapter: storageAdapter, + completion: completion + ) + } + + static func pendingMutationEvents( + forModels models: [Model], + storageAdapter: StorageEngineAdapter, + completion: @escaping DataStoreCallback<[MutationEvent]> + ) { + pendingMutationEvents( + for: models.map { ($0.identifier, $0.modelName) }, + storageAdapter: storageAdapter, + completion: completion + ) + } + + private static func pendingMutationEvents(for modelIds: [(String, String)], + storageAdapter: StorageEngineAdapter, + completion: @escaping DataStoreCallback<[MutationEvent]>) { + Task { + let fields = MutationEvent.keys + let predicate = (fields.inProcess == false || fields.inProcess == nil) + let chunkedArrays = modelIds.chunked(into: SQLiteStorageEngineAdapter.maxNumberOfPredicates) + var queriedMutationEvents: [MutationEvent] = [] + for chunkedArray in chunkedArrays { + var queryPredicates: [QueryPredicateGroup] = [] + for (id, name) in chunkedArray { + let operation = fields.modelId == id && fields.modelName == name + queryPredicates.append(operation) + } + let groupedQueryPredicates = QueryPredicateGroup(type: .or, predicates: queryPredicates) + let final = QueryPredicateGroup(type: .and, predicates: [groupedQueryPredicates, predicate]) + let sort = QuerySortDescriptor(fieldName: fields.createdAt.stringValue, order: .ascending) + + do { + let mutationEvents = try await withCheckedThrowingContinuation { continuation in + storageAdapter.query(MutationEvent.self, + predicate: final, + sort: [sort], + paginationInput: nil, + eagerLoad: true) { result in + continuation.resume(with: result) + } + } + + queriedMutationEvents.append(contentsOf: mutationEvents) + } catch { + completion(.failure(causedBy: error)) + return + } + } + completion(.success(queriedMutationEvents)) + } + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/Support/SQLiteResultError.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/Support/SQLiteResultError.swift new file mode 100644 index 0000000000..79a3c895d4 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/Support/SQLiteResultError.swift @@ -0,0 +1,41 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import SQLite +import SQLite3 + +/// Checks for specific SQLLite error codes +/// See https://sqlite.org/rescode.html#primary_result_code_list +enum SQLiteResultError { + + /// Constraint Violation, such as foreign key constraint violation, occurs when trying to process a SQL statement + /// where the insert/update statement is performed for a child object and its parent does not exist. + /// See https://sqlite.org/rescode.html#constraint for more details + case constraintViolation(statement: Statement?) + + /// Represents a SQLite specific [error code](https://sqlite.org/rescode.html) + /// + /// - message: English-language text that describes the error + /// - code: SQLite [error code](https://sqlite.org/rescode.html#primary_result_code_list) + /// - statement: the statement which produced the error + case error(message: String, code: Int32, statement: Statement?) + + init?(from dataStoreError: DataStoreError) { + guard case let .invalidOperation(error) = dataStoreError, + let resultError = error as? Result, + case .error(let message, let code, let statement) = resultError else { + return nil + } + + if code == SQLITE_CONSTRAINT { + self = .constraintViolation(statement: statement) + return + } + + self = .error(message: message, code: code, statement: statement) + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/Support/StateMachine.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/Support/StateMachine.swift new file mode 100644 index 0000000000..ebd623b520 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/Support/StateMachine.swift @@ -0,0 +1,59 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Combine +import Foundation + +class StateMachine { + typealias Reducer = (State, Action) -> State + + private let queue = DispatchQueue(label: "com.amazonaws.Amplify.StateMachine<\(State.self), \(Action.self)>", + target: DispatchQueue.global()) + + private var reducer: Reducer + @Published var state: State + + /// Creates a new state machine that resolves state transition with the specified reducer. Interested parties can + /// monitor state transitions with the `$state` published property. + /// + /// Resolvers should exit quickly, and not perform unnecessary work. Side effects should be initated as a result of + /// receiving the new state published by `state`. + /// + /// - Parameter initialState: The state in which the StateMachine should begin. + /// - Parameter reducer: A pure function that evaluates incoming state, the action, and returns a new state. + init(initialState: State, resolver: @escaping Reducer) { + self.state = initialState + self.reducer = resolver + } + + /// Notifies the StateMachine of an action that should be evaluated. The action is resolved serially, and the new + /// state will be published to `state`, before the `notify` action returns. + func notify(action: Action) { + queue.sync { + log.verbose("Notifying: \(action)") + let newState = self.resolve(currentState: self.state, action: action) + self.state = newState + } + } + + /// Resolves `action` via `reducer`, updates `currentState` with the resolved State value. + private func resolve(currentState: State, action: Action) -> State { + let newState = reducer(currentState, action) + log.verbose("resolve(\(currentState), \(action)) -> \(newState)") + return newState + } + +} + +extension StateMachine: DefaultLogger { + public static var log: Logger { + Amplify.Logging.logger(forCategory: CategoryType.dataStore.displayName, forNamespace: String(describing: self)) + } + public var log: Logger { + Self.log + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/Support/Stopwatch.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/Support/Stopwatch.swift new file mode 100644 index 0000000000..61929efdd0 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/Support/Stopwatch.swift @@ -0,0 +1,60 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// A simple implementation of a stopwatch used for gathering metrics of elapsed time. +class Stopwatch { + let lock = NSLock() + var startTime: DispatchTime? + var lapStart: DispatchTime? + + /// Marks the beginning of the stopwatch. + /// If called multiple times, the latest call will overwrite the previous start values. + func start() { + lock.execute { + startTime = DispatchTime.now() + lapStart = startTime + } + } + + /// Returns the elapsed time since `start()` or the last `lap()` was called. + /// + /// - Returns: the elapsed time in seconds + func lap() -> Double { + lock.execute { + guard let lapStart = lapStart else { + return 0 + } + + let lapEnd = DispatchTime.now() + let lapTime = Double(lapEnd.uptimeNanoseconds - lapStart.uptimeNanoseconds) / 1_000_000_000.0 + self.lapStart = lapEnd + return lapTime + } + } + + /// Returns the total time from the initial `start()` call and resets the stopwatch. + /// Returns 0 if the stopwatch has never been started. + /// + /// - Returns: the total time in seconds that the stop watch has been running, or 0 + func stop() -> Double { + return lock.execute { + defer { + lapStart = nil + startTime = nil + } + + guard let startTime = startTime else { + return 0 + } + let endTime = DispatchTime.now() + let total = Double(endTime.uptimeNanoseconds - startTime.uptimeNanoseconds) / 1_000_000_000.0 + return total + } + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/API/AWSAPIAuthInformation.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/API/AWSAPIAuthInformation.swift new file mode 100644 index 0000000000..4d47261aef --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/API/AWSAPIAuthInformation.swift @@ -0,0 +1,20 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// API Plugin's Auth related information +public protocol AWSAPIAuthInformation { + + /// Returns the deafult auth type from the default endpoint. The endpoint may accept more than one auth mode, + /// however the default returned is the one configured on the endpoint configuration. The default endpoint is the + /// the one determined by the API plugin, which is the one returned when called without an `apiName`. + func defaultAuthType() throws -> AWSAuthorizationType + + /// Returns the default auth type on endpoint specified by `apiName`. + func defaultAuthType(for apiName: String?) throws -> AWSAuthorizationType +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/API/AppSyncErrorType.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/API/AppSyncErrorType.swift new file mode 100644 index 0000000000..edcf20ddef --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/API/AppSyncErrorType.swift @@ -0,0 +1,62 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Common AppSync error types +public enum AppSyncErrorType: Equatable { + + private static let conditionalCheckFailedErrorString = "ConditionalCheckFailedException" + private static let conflictUnhandledErrorString = "ConflictUnhandled" + private static let unauthorizedErrorString = "Unauthorized" + private static let operationDisabledErrorString = "OperationDisabled" + + /// Conflict detection finds a version mismatch and the conflict handler rejects the mutation. + /// See https://docs.aws.amazon.com/appsync/latest/devguide/conflict-detection-and-sync.html for more information + case conflictUnhandled + + case conditionalCheck + + case unauthorized + + /// This error is not for general use unless you have consulted directly with AWS. + /// When DataStore encounters this error, it will ignore it and continue running. + /// This error is subject to be **deprecated/removed** in the future. + case operationDisabled + + case unknown(String) + + public init(_ value: String) { + switch value { + case AppSyncErrorType.conditionalCheckFailedErrorString: + self = .conditionalCheck + case AppSyncErrorType.conflictUnhandledErrorString: + self = .conflictUnhandled + case _ where value.contains(AppSyncErrorType.unauthorizedErrorString): + self = .unauthorized + case AppSyncErrorType.operationDisabledErrorString: + self = .operationDisabled + default: + self = .unknown(value) + } + } + + public var rawValue: String { + switch self { + case .conditionalCheck: + return AppSyncErrorType.conditionalCheckFailedErrorString + case .conflictUnhandled: + return AppSyncErrorType.conflictUnhandledErrorString + case .unauthorized: + return AppSyncErrorType.unauthorizedErrorString + case .operationDisabled: + return AppSyncErrorType.operationDisabledErrorString + case .unknown(let value): + return value + } + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/AWSAPIPluginDataStoreOptions.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/AWSAPIPluginDataStoreOptions.swift new file mode 100644 index 0000000000..a5e9fd58d7 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/AWSAPIPluginDataStoreOptions.swift @@ -0,0 +1,27 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Plugin specific options type +/// +/// - Warning: Although this has `public` access, it is intended for internal use and should not be used directly +/// by host applications. The behavior of this may change without warning. +public struct AWSAPIPluginDataStoreOptions { + + /// authorization type + public let authType: AWSAuthorizationType? + + /// name of the model + public let modelName: String + + public init(authType: AWSAuthorizationType?, + modelName: String) { + self.authType = authType + self.modelName = modelName + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/AWSPluginOptions.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/AWSPluginOptions.swift new file mode 100644 index 0000000000..75a59683b6 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/AWSPluginOptions.swift @@ -0,0 +1,44 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Plugin specific options type +/// +/// - Warning: Although this has `public` access, it is intended for internal use and should not be used directly +/// by host applications. The behavior of this may change without warning. +/// +/// This method was used internally by DataStore to pass information to APIPlugin, it +/// has since been renamed to `AWSDataStorePluginOptions`. For customers +/// looking to use the runtime authType parameter, this is a feature that should result in +/// an options object on APIPlugin as something like `AWSAPIPluginOptions`, ie. +/// +///```swift +///public struct AWSAPIPluginOptions { +/// /// authorization type +/// public let authType: AWSAuthorizationType? +/// +/// public init(authType: AWSAuthorizationType?) { +/// self.authType = authType +/// } +///} +///``` +@available(*, deprecated, message: "Intended for internal use.") +public struct AWSPluginOptions { + + /// authorization type + public let authType: AWSAuthorizationType? + + /// name of the model + public let modelName: String? + + public init(authType: AWSAuthorizationType?, + modelName: String) { + self.authType = authType + self.modelName = modelName + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/AWSPluginsCore.h b/packages/amplify_datastore/ios/internal/AWSPluginsCore/AWSPluginsCore.h new file mode 100644 index 0000000000..e4655c22b8 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/AWSPluginsCore.h @@ -0,0 +1,18 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// Inc. or its affiliates. All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#import + +//! Project version number for AWSPluginsCore. +FOUNDATION_EXPORT double AWSPluginsCoreVersionNumber; + +//! Project version string for AWSPluginsCore. +FOUNDATION_EXPORT const unsigned char AWSPluginsCoreVersionString[]; + +// In this header, you should import all the public headers of your framework using statements like #import + + diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Auth/AWSAuthModeStrategy.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Auth/AWSAuthModeStrategy.swift new file mode 100644 index 0000000000..6f6b6e5aea --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Auth/AWSAuthModeStrategy.swift @@ -0,0 +1,256 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import Combine + +/// Represents different auth strategies supported by a client +/// interfacing with an AppSync backend +public enum AuthModeStrategyType { + /// Default authorization type read from API configuration + case `default` + + /// Uses schema metadata to create a prioritized list of potential authorization types + /// that could be used for a request. The client iterates through that list until one of the + /// avaialable types succeeds or all of them fail. + case multiAuth + + public func resolveStrategy() -> AuthModeStrategy { + switch self { + case .default: + return AWSDefaultAuthModeStrategy() + case .multiAuth: + return AWSMultiAuthModeStrategy() + } + } +} + +/// Methods for checking user current status +public protocol AuthModeStrategyDelegate: AnyObject { + func isUserLoggedIn() async -> Bool +} + +/// Represents an authorization strategy used by DataStore +public protocol AuthModeStrategy: AnyObject { + + var authDelegate: AuthModeStrategyDelegate? { get set } + + init() + + func authTypesFor(schema: ModelSchema, operation: ModelOperation) async -> AWSAuthorizationTypeIterator + + func authTypesFor(schema: ModelSchema, operations: [ModelOperation]) async -> AWSAuthorizationTypeIterator +} + +/// AuthorizationType iterator with an extra `count` property used +/// to predict the number of values +public protocol AuthorizationTypeIterator { + associatedtype AuthorizationType + + init(withValues: [AuthorizationType]) + + /// Total number of values + var count: Int { get } + + /// Whether iterator has next available `AuthorizationType` to return or not + var hasNext: Bool { get } + + /// Next available `AuthorizationType` or `nil` if exhausted + mutating func next() -> AuthorizationType? +} + +/// AuthorizationTypeIterator for values of type `AWSAuthorizationType` +public struct AWSAuthorizationTypeIterator: AuthorizationTypeIterator { + public typealias AuthorizationType = AWSAuthorizationType + + private var values: IndexingIterator<[AWSAuthorizationType]> + private var _count: Int + private var _position: Int + + public init(withValues values: [AWSAuthorizationType]) { + self.values = values.makeIterator() + self._count = values.count + self._position = 0 + } + + public var count: Int { + _count + } + + public var hasNext: Bool { + _position < _count + } + + public mutating func next() -> AWSAuthorizationType? { + if let value = values.next() { + _position += 1 + return value + } + + return nil + } +} + +extension AuthorizationTypeIterator { + public func publisher() -> AnyPublisher { + var it = self + return Deferred { + var authTypes = [AuthorizationType]() + while let authType = it.next() { + authTypes.append(authType) + } + return Publishers.MergeMany(authTypes.map { Just($0) }) + }.eraseToAnyPublisher() + } +} + +// MARK: - AWSDefaultAuthModeStrategy + +/// AWS default auth mode strategy. +/// +/// Returns an empty AWSAuthorizationTypeIterator, so we can just rely on the default authorization type +/// registered as interceptor for the API +public class AWSDefaultAuthModeStrategy: AuthModeStrategy { + public weak var authDelegate: AuthModeStrategyDelegate? + required public init() {} + + public func authTypesFor(schema: ModelSchema, + operation: ModelOperation) -> AWSAuthorizationTypeIterator { + return AWSAuthorizationTypeIterator(withValues: []) + } + + public func authTypesFor(schema: ModelSchema, + operations: [ModelOperation]) -> AWSAuthorizationTypeIterator { + return AWSAuthorizationTypeIterator(withValues: []) + } +} + +// MARK: - AWSMultiAuthModeStrategy + +/// Multi-auth strategy implementation based on schema metadata +public class AWSMultiAuthModeStrategy: AuthModeStrategy { + public weak var authDelegate: AuthModeStrategyDelegate? + + private typealias AuthPriority = Int + + required public init() {} + + private static func defaultAuthTypeFor(authStrategy: AuthStrategy) -> AWSAuthorizationType { + var defaultAuthType: AWSAuthorizationType + switch authStrategy { + case .owner: + defaultAuthType = .amazonCognitoUserPools + case .groups: + defaultAuthType = .amazonCognitoUserPools + case .private: + defaultAuthType = .amazonCognitoUserPools + case .public: + defaultAuthType = .apiKey + case .custom: + defaultAuthType = .function + } + return defaultAuthType + } + + /// Given an auth rule, returns the corresponding AWSAuthorizationType + /// - Parameter authRule: authorization rule + /// - Returns: returns corresponding AWSAuthorizationType or a default + private static func authTypeFor(authRule: AuthRule) -> AWSAuthorizationType { + if let authProvider = authRule.provider { + return authProvider.toAWSAuthorizationType() + } + + return defaultAuthTypeFor(authStrategy: authRule.allow) + } + + /// Given an auth rule strategy returns its corresponding priority. + /// Strategies are ordered from "most specific" to "least specific". + /// - Parameter authStrategy: auth rule strategy + /// - Returns: priority + private static func priorityOf(authStrategy: AuthStrategy) -> AuthPriority { + switch authStrategy { + case .custom: + return 0 + case .owner: + return 1 + case .groups: + return 2 + case .private: + return 3 + case .public: + return 4 + } + } + + /// Given an auth rule provider returns its corresponding priority. + /// Providers are ordered from "most specific" to "least specific". + /// - Parameter authRuleProvider: auth rule provider + /// - Returns: priority + private static func priorityOf(authRuleProvider provider: AuthRuleProvider) -> AuthPriority { + switch provider { + case .function: + return 0 + case .userPools: + return 1 + case .oidc: + return 2 + case .iam: + return 3 + case .apiKey: + return 4 + } + } + + /// A predicate used to sort Auth rules according to above priority rules + /// Use provider priority to sort if rules have the same strategy + private static let comparator = { (rule1: AuthRule, rule2: AuthRule) -> Bool in + if let providerRule1 = rule1.provider, + let providerRule2 = rule2.provider, rule1.allow == rule2.allow { + return priorityOf(authRuleProvider: providerRule1) < priorityOf(authRuleProvider: providerRule2) + } + return priorityOf(authStrategy: rule1.allow) < priorityOf(authStrategy: rule2.allow) + } + + /// Returns the proper authorization type for the provided schema according to a set of priority rules + /// - Parameters: + /// - schema: model schema + /// - operation: model operation + /// - Returns: an iterator for the applicable auth rules + public func authTypesFor(schema: ModelSchema, + operation: ModelOperation) async -> AWSAuthorizationTypeIterator { + return await authTypesFor(schema: schema, operations: [operation]) + } + + /// Returns the union of authorization types for the provided schema for the given list of operations + /// - Parameters: + /// - schema: model schema + /// - operations: model operations + /// - Returns: an iterator for the applicable auth rules + public func authTypesFor(schema: ModelSchema, + operations: [ModelOperation]) async -> AWSAuthorizationTypeIterator { + var sortedRules = operations + .flatMap { schema.authRules.filter(modelOperation: $0) } + .reduce(into: [AuthRule](), { array, rule in + if !array.contains(rule) { + array.append(rule) + } + }) + .sorted(by: AWSMultiAuthModeStrategy.comparator) + + // if there isn't a user signed in, returns only public or custom rules + if let authDelegate = authDelegate, await !authDelegate.isUserLoggedIn() { + sortedRules = sortedRules.filter { rule in + return rule.allow == .public || rule.allow == .custom + } + } + let applicableAuthTypes = sortedRules.map { + AWSMultiAuthModeStrategy.authTypeFor(authRule: $0) + } + return AWSAuthorizationTypeIterator(withValues: applicableAuthTypes) + } + +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Auth/AWSAuthService.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Auth/AWSAuthService.swift new file mode 100644 index 0000000000..604ae605b0 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Auth/AWSAuthService.swift @@ -0,0 +1,93 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public class AWSAuthService: AWSAuthServiceBehavior { + + public init() {} + + /// Retrieves the identity identifier for this authentication session from Cognito. + public func getIdentityID() async throws -> String { + let session = try await Amplify.Auth.fetchAuthSession() + guard let identityID = (session as? AuthCognitoIdentityProvider)?.getIdentityId() else { + let error = AuthError.unknown(" Did not receive a valid response from fetchAuthSession for identityId.") + throw error + } + return try identityID.get() + } + + // This algorithm was heavily based on the implementation here: + // swiftlint:disable:next line_length + // https://github.com/aws-amplify/aws-sdk-ios/blob/main/AWSAuthSDK/Sources/AWSMobileClient/AWSMobileClientExtensions.swift#L29 + public func getTokenClaims(tokenString: String) -> Result<[String: AnyObject], AuthError> { + let tokenSplit = tokenString.split(separator: ".") + guard tokenSplit.count > 2 else { + return .failure(.validation("", "Token is not valid base64 encoded string.", "", nil)) + } + + // Add ability to do URL decoding + // https://stackoverflow.com/questions/40915607/how-can-i-decode-jwt-json-web-token-token-in-swift + let claims = tokenSplit[1] + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + + let paddedLength = claims.count + (4 - (claims.count % 4)) % 4 + // JWT is not padded with =, pad it if necessary + let updatedClaims = claims.padding(toLength: paddedLength, withPad: "=", startingAt: 0) + let encodedData = Data(base64Encoded: updatedClaims, options: .ignoreUnknownCharacters) + + guard let claimsData = encodedData else { + return .failure( + .validation("", "Cannot get claims in `Data` form. Token is not valid base64 encoded string.", + "", nil)) + } + + let jsonObject: Any? + do { + jsonObject = try JSONSerialization.jsonObject(with: claimsData, options: []) + } catch { + return .failure( + .validation("", "Cannot get claims in `Data` form. Token is not valid JSON string.", + "", error)) + } + + guard let convertedDictionary = jsonObject as? [String: AnyObject] else { + return .failure( + .validation("", "Cannot get claims in `Data` form. Unable to convert to [String: AnyObject].", + "", nil)) + } + return .success(convertedDictionary) + } + + /// Retrieves the Cognito token from the AuthCognitoTokensProvider + public func getUserPoolAccessToken() async throws -> String { + let authSession = try await Amplify.Auth.fetchAuthSession() + guard let tokenResult = getTokenString(from: authSession) else { + let error = AuthError.unknown("Did not receive a valid response from fetchAuthSession for get token.") + throw error + } + switch tokenResult { + case .success(let token): + return token + case .failure(let error): + throw error + } + } + + private func getTokenString(from authSession: AuthSession) -> Result? { + if let result = (authSession as? AuthCognitoTokensProvider)?.getCognitoTokens() { + switch result { + case .success(let tokens): + return .success(tokens.accessToken) + case .failure(let error): + return .failure(error) + } + } + return nil + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Auth/AWSAuthServiceBehavior.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Auth/AWSAuthServiceBehavior.swift new file mode 100644 index 0000000000..6676eaeda2 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Auth/AWSAuthServiceBehavior.swift @@ -0,0 +1,19 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public protocol AWSAuthServiceBehavior: AnyObject { + + func getTokenClaims(tokenString: String) -> Result<[String: AnyObject], AuthError> + + /// Retrieves the identity identifier of for the Auth service + func getIdentityID() async throws -> String + + /// Retrieves the token from the Auth token provider + func getUserPoolAccessToken() async throws -> String +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Auth/AWSAuthSessionBehavior.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Auth/AWSAuthSessionBehavior.swift new file mode 100644 index 0000000000..feaa827020 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Auth/AWSAuthSessionBehavior.swift @@ -0,0 +1,58 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Defines the contract for an AuthSession that can vend AWS credentials and store a user ID +/// (`sub`) for the underlying OIDC-compliant authentication provider such as Cognito user pools. +/// Concrete types that use Cognito identity pools to obtain AWS credentials can also vend the +/// associated identityID. +/// +/// **The `isSignedIn` property** +/// +/// Types conforming to the `AuthSession` protocol support an `isSignedIn` flag. `isSignedIn` is true if a user has +/// successfully completed a `signIn` flow, and has not subsequently signed out. +public protocol AWSAuthSessionBehavior: AuthSession { + + /// The concrete type holding the OIDC tokens from the authentication provider. + /// Generally, this type will have at least methods for retrieving an identity token and an access token. + associatedtype Tokens + + /// The result of the most recent attempt to get AWS Credentials. There is no guarantee that the credentials + /// are not expired, but conforming types may have logic in place to automatically refresh the credentials. + /// The credentials may be fore either the unauthenticated or authenticated role, depending on the + /// configuration of the identity pool and the tokens used to retrieve the identity ID from Cognito. + /// + /// If the most recent attempt caused an error, the result will contain the details of the error. + var awsCredentialsResult: Result { get } + + // swiftlint:disable line_length + /// The result of the most recent attempt to get a + /// [Cognito identity pool identity ID](https://docs.aws.amazon.com/cognitoidentity/latest/APIReference/API_GetId.html#CognitoIdentity-GetId-response-IdentityId). + /// The identityID may represent either an unauthenticated or authenticated identity, + /// depending on the configuration of the identity pool and the tokens used to + /// retrieve the identity ID from Cognito. + /// + /// If the most recent attempt caused an error, the result will contain the details of the error. + var identityIdResult: Result { get } + // swiftlint:enable line_length + + /// The result of the most recent attempt to get the current user's `sub` (unique User ID). + /// Depending on the underlying implementation, the details of the user ID may vary, + /// but it is expected that this value is the `sub` claim of the OIDC identity and access tokens. + /// + /// If the most recent attempt caused an error, the result will contain the details of the error. + var userSubResult: Result { get } + + /// The result of the most recent attempt to get the current user's `sub` (unique User ID). + /// Depending on the underlying implementation, + /// the details of the tokens may vary, but it is expected that the type will have at least methods for + /// retrieving an identity token and an access token. + /// + /// If the most recent attempt caused an error, the result will contain the details of the error. + var oidcTokensResult: Result { get } +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Auth/AWSAuthorizationType.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Auth/AWSAuthorizationType.swift new file mode 100644 index 0000000000..16d65adfeb --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Auth/AWSAuthorizationType.swift @@ -0,0 +1,60 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +// swiftlint:disable line_length + +/// The types of authorization one can use while talking to an Amazon AppSync +/// GraphQL backend, or an Amazon API Gateway endpoint. +/// +/// - SeeAlso: [https://docs.aws.amazon.com/appsync/latest/devguide/security.html](AppSync Security) +public enum AWSAuthorizationType: String, AuthorizationMode { + + /// For public APIs + case none = "NONE" + + /// A hardcoded key which can provide throttling for an unauthenticated API. + /// - SeeAlso: [https://docs.aws.amazon.com/appsync/latest/devguide/security-authz.html#api-key-authorization](API Key Authorization) + case apiKey = "API_KEY" + + /// Use an IAM access/secret key credential pair to authorize access to an API. + /// - SeeAlso: [https://docs.aws.amazon.com/appsync/latest/devguide/security-authz.html#aws-iam-authorization](IAM Authorization) + /// - SeeAlso: [https://docs.aws.amazon.com/IAM/latest/UserGuide/introduction.html](IAM Introduction) + case awsIAM = "AWS_IAM" + + /// OpenID Connect is a simple identity layer on top of OAuth2.0. + /// - SeeAlso: [https://docs.aws.amazon.com/appsync/latest/devguide/security-authz.html#openid-connect-authorization](OpenID Connect Authorization) + /// - SeeAlso: [https://openid.net/specs/openid-connect-core-1_0.html](OpenID Connect Specification) + case openIDConnect = "OPENID_CONNECT" + + /// Control access to date by putting users into different permissions pools. + /// - SeeAlso: [https://docs.aws.amazon.com/appsync/latest/devguide/security-authz.html#amazon-cognito-user-pools-authorization](Amazon Cognito User Pools) + case amazonCognitoUserPools = "AMAZON_COGNITO_USER_POOLS" + + /// Control access by calling a lambda function, + case function = "AWS_LAMBDA" + +} + +// swiftlint:enable line_length + +extension AWSAuthorizationType: CaseIterable { } + +extension AWSAuthorizationType: Codable { } + +/// Indicates whether the authotization type requires the auth plugin to operate. +extension AWSAuthorizationType { + public var requiresAuthPlugin: Bool { + switch self { + case .none, .apiKey, .openIDConnect, .function: + return false + case .awsIAM, .amazonCognitoUserPools: + return true + } + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Auth/AuthAWSCredentialsProvider.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Auth/AuthAWSCredentialsProvider.swift new file mode 100644 index 0000000000..eba9f2ab64 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Auth/AuthAWSCredentialsProvider.swift @@ -0,0 +1,49 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public protocol AuthAWSCredentialsProvider { + /// Return the most recent Result of fetching the AWS Credentials + func getAWSCredentials() -> Result +} + +public extension AuthAWSCredentialsProvider where Self: AWSAuthSessionBehavior { + /// Return the most recent Result of fetching the AWS Credentials. If the temporary credentials are expired, returns + /// a `AuthError.sessionExpired` failure. + func getAWSCredentials() -> Result { + let result: Result + switch awsCredentialsResult { + case .failure(let error): result = .failure(error) + case .success(let tempCreds): + if tempCreds.expiration > Date() { + result = .success(tempCreds) + } else { + result = .failure(AuthError.sessionExpired("AWS Credentials are expired", "")) + } + } + return result + } +} + +public protocol AWSCredentialsProvider { + func fetchAWSCredentials() async throws -> AWSCredentials +} + +public protocol AWSTemporaryCredentials: AWSCredentials { + + var sessionToken: String { get } + + var expiration: Date { get } +} + +public protocol AWSCredentials { + + var accessKeyId: String { get } + + var secretAccessKey: String { get } +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Auth/AuthCognitoIdentityProvider.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Auth/AuthCognitoIdentityProvider.swift new file mode 100644 index 0000000000..e85a4c9bfc --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Auth/AuthCognitoIdentityProvider.swift @@ -0,0 +1,25 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + + +public protocol AuthCognitoIdentityProvider { + /// Return the most recent Result of fetching the AWS Cognito Identity Pools identity ID + func getIdentityId() -> Result + + /// Return the most recent Result of the current user’s sub claim (user ID) + func getUserSub() -> Result +} + +public extension AuthCognitoIdentityProvider where Self: AWSAuthSessionBehavior { + func getIdentityId() -> Result { + identityIdResult + } + + func getUserSub() -> Result { + userSubResult + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Auth/AuthCognitoTokensProvider.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Auth/AuthCognitoTokensProvider.swift new file mode 100644 index 0000000000..031506bfc0 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Auth/AuthCognitoTokensProvider.swift @@ -0,0 +1,22 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public protocol AuthCognitoTokensProvider { + func getCognitoTokens() -> Result +} + +public protocol AuthCognitoTokens { + + var idToken: String {get} + + var accessToken: String {get} + + var refreshToken: String {get} + +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Auth/AuthPluginBehavior/AuthInvalidateCredentialBehavior.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Auth/AuthPluginBehavior/AuthInvalidateCredentialBehavior.swift new file mode 100644 index 0000000000..6802f26d3a --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Auth/AuthPluginBehavior/AuthInvalidateCredentialBehavior.swift @@ -0,0 +1,13 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public protocol AuthInvalidateCredentialBehavior { + + func invalidateCachedTemporaryCredentials() +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Auth/Configuration/APIKeyConfiguration.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Auth/Configuration/APIKeyConfiguration.swift new file mode 100644 index 0000000000..40119958de --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Auth/Configuration/APIKeyConfiguration.swift @@ -0,0 +1,16 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public struct APIKeyConfiguration { + public let apiKey: String + + public init(apiKey: String) { + self.apiKey = apiKey + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Auth/Configuration/AWSAuthorizationConfiguration.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Auth/Configuration/AWSAuthorizationConfiguration.swift new file mode 100644 index 0000000000..239f73fe0b --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Auth/Configuration/AWSAuthorizationConfiguration.swift @@ -0,0 +1,73 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public enum AWSAuthorizationConfiguration { + case none + case apiKey(APIKeyConfiguration) + case awsIAM(AWSIAMConfiguration) + case openIDConnect(OIDCConfiguration) + case amazonCognitoUserPools(CognitoUserPoolsConfiguration) + case function(AWSLambdaAuthConfiguration) +} + +// MARK: - AWSAuthorizationConfiguration factory +extension AWSAuthorizationConfiguration { + private static func awsIAMAuthorizationConfiguration(region: String?) + throws -> AWSAuthorizationConfiguration { + guard let region = region else { + throw PluginError.pluginConfigurationError("Region is not set for IAM", + "Set the region") + } + return .awsIAM(AWSIAMConfiguration(region: region)) + } + + private static func apiKeyAuthorizationConfiguration(apiKey: String?) + throws -> AWSAuthorizationConfiguration { + + guard let apiKey = apiKey else { + throw PluginError.pluginConfigurationError( + "Could not get `ApiKey` from plugin configuration", + """ + The specified configuration does not have a string with the key `apiKey`. Review the \ + configuration and ensure it contains the expected values. + """ + ) + } + + let config = APIKeyConfiguration(apiKey: apiKey) + return .apiKey(config) + } + + /// Instantiates a new configuration conforming to AWSAuthorizationConfiguration + /// - Parameters: + /// - authType: authentication type + /// - region: AWS region + /// - apiKey: API key used when `authType` is `apiKey` + /// - Throws: if the region is not valid and `authType` is `iam` + /// or if `apiKey` is not valid and `authType` is `apiKey` + /// - Returns: an `AWSAuthorizationConfiguration` according to the provided `authType` + public static func makeConfiguration(authType: AWSAuthorizationType, + region: String?, + apiKey: String?) throws -> AWSAuthorizationConfiguration { + switch authType { + case .none: + return .none + case .apiKey: + return try apiKeyAuthorizationConfiguration(apiKey: apiKey) + case .awsIAM: + return try awsIAMAuthorizationConfiguration(region: region) + case .openIDConnect: + return .openIDConnect(OIDCConfiguration()) + case .amazonCognitoUserPools: + return .amazonCognitoUserPools(CognitoUserPoolsConfiguration()) + case .function: + return .function(AWSLambdaAuthConfiguration()) + } + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Auth/Configuration/AWSIAMConfiguration.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Auth/Configuration/AWSIAMConfiguration.swift new file mode 100644 index 0000000000..34c2cc1e79 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Auth/Configuration/AWSIAMConfiguration.swift @@ -0,0 +1,14 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +public struct AWSIAMConfiguration { + public let region: String + + public init(region: String) { + self.region = region + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Auth/Configuration/AWSLambdaAuthConfiguration.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Auth/Configuration/AWSLambdaAuthConfiguration.swift new file mode 100644 index 0000000000..ca43317a28 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Auth/Configuration/AWSLambdaAuthConfiguration.swift @@ -0,0 +1,13 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// AWS Lambda authorizer configuration +public struct AWSLambdaAuthConfiguration { + public init() {} +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Auth/Configuration/CognitoUserPoolsConfiguration.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Auth/Configuration/CognitoUserPoolsConfiguration.swift new file mode 100644 index 0000000000..120ca71867 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Auth/Configuration/CognitoUserPoolsConfiguration.swift @@ -0,0 +1,11 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +public struct CognitoUserPoolsConfiguration { + public init() { + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Auth/Configuration/OIDCConfiguration.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Auth/Configuration/OIDCConfiguration.swift new file mode 100644 index 0000000000..ce112be0da --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Auth/Configuration/OIDCConfiguration.swift @@ -0,0 +1,11 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +public struct OIDCConfiguration { + public init() { + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Auth/Provider/APIKeyProvider.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Auth/Provider/APIKeyProvider.swift new file mode 100644 index 0000000000..361be2d7b5 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Auth/Provider/APIKeyProvider.swift @@ -0,0 +1,25 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// For using API Key based authorization, this protocol needs to be implemented and passed to configuration object. +public protocol APIKeyProvider { + func getAPIKey() -> String +} + +public struct BasicAPIKeyProvider: APIKeyProvider { + private let apiKey: String + + public init(apiKey: String) { + self.apiKey = apiKey + } + + public func getAPIKey() -> String { + apiKey + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Keychain/KeychainStatus.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Keychain/KeychainStatus.swift new file mode 100644 index 0000000000..3b7ccee072 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Keychain/KeychainStatus.swift @@ -0,0 +1,57 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +enum KeychainStatus { + case success + case userCanceled + case duplicateItem + case itemNotFound + case missingEntitlement + case unexpectedError(OSStatus) +} + +extension KeychainStatus: CustomStringConvertible { + + init(status: OSStatus) { + switch status { + case 0: + self = .success + case -128: + self = .userCanceled + case -25299: + self = .duplicateItem + case -25300: + self = .itemNotFound + case -34018: + self = .missingEntitlement + default: + self = .unexpectedError(status) + } + } + + var description: String { + switch self { + case .success: + return "No error." + case .userCanceled: + return "User canceled the operation." + case .duplicateItem: + return "The specified item already exists in the keychain." + case .itemNotFound: + return "The specified item could not be found in the keychain." + case .missingEntitlement: + return """ + Internal error when a required entitlement isn't present, + client has neither application-identifier nor keychain-access-groups entitlements. + """ + case .unexpectedError(let status): + return "Unexpected error has occurred with status: \(status)." + } + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Keychain/KeychainStore.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Keychain/KeychainStore.swift new file mode 100644 index 0000000000..2d985b3d35 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Keychain/KeychainStore.swift @@ -0,0 +1,276 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import Security + +// swiftlint:disable identifier_name +public protocol KeychainStoreBehavior { + + @_spi(KeychainStore) + /// Get a string value from the Keychain based on the key. + /// This System Programming Interface (SPI) may have breaking changes in future updates. + /// - Parameter key: A String key use to look up the value in the Keychain + /// - Returns: A string value + func _getString(_ key: String) throws -> String + + @_spi(KeychainStore) + /// Get a data value from the Keychain based on the key. + /// This System Programming Interface (SPI) may have breaking changes in future updates. + /// - Parameter key: A String key use to look up the value in the Keychain + /// - Returns: A data value + func _getData(_ key: String) throws -> Data + + @_spi(KeychainStore) + /// Set a key-value pair in the Keychain. + /// This System Programming Interface (SPI) may have breaking changes in future updates. + /// - Parameters: + /// - value: A string value to store in Keychain + /// - key: A String key for the value to store in the Keychain + func _set(_ value: String, key: String) throws + + @_spi(KeychainStore) + /// Set a key-value pair in the Keychain. + /// This iSystem Programming Interface (SPI) may have breaking changes in future updates. + /// - Parameters: + /// - value: A data value to store in Keychain + /// - key: A String key for the value to store in the Keychain + func _set(_ value: Data, key: String) throws + + @_spi(KeychainStore) + /// Remove key-value pair from Keychain based on the provided key. + /// This System Programming Interface (SPI) may have breaking changes in future updates. + /// - Parameter key: A String key to delete the key-value pair + func _remove(_ key: String) throws + + @_spi(KeychainStore) + /// Removes all key-value pair in the Keychain. + /// This System Programming Interface (SPI) may have breaking changes in future updates. + func _removeAll() throws +} + +public struct KeychainStore: KeychainStoreBehavior { + + let attributes: KeychainStoreAttributes + + private init(attributes: KeychainStoreAttributes) { + self.attributes = attributes + } + + public init() { + guard let bundleIdentifier = Bundle.main.bundleIdentifier else { + fatalError("Unable to retrieve bundle identifier to initialize keychain") + } + self.init(service: bundleIdentifier) + } + + public init(service: String) { + self.init(service: service, accessGroup: nil) + } + + public init(service: String, accessGroup: String? = nil) { + var attributes = KeychainStoreAttributes(service: service) + attributes.accessGroup = accessGroup + self.init(attributes: attributes) + log.verbose("[KeychainStore] Initialized keychain with service=\(service), attributes=\(attributes), accessGroup=\(accessGroup ?? "")") + } + + @_spi(KeychainStore) + /// Get a string value from the Keychain based on the key. + /// This System Programming Interface (SPI) may have breaking changes in future updates. + /// - Parameter key: A String key use to look up the value in the Keychain + /// - Returns: A string value + public func _getString(_ key: String) throws -> String { + log.verbose("[KeychainStore] Started retrieving `String` from the store with key=\(key)") + let data = try _getData(key) + guard let string = String(data: data, encoding: .utf8) else { + log.error("[KeychainStore] Unable to create String from Data retrieved") + throw KeychainStoreError.conversionError("Unable to create String from Data retrieved") + } + log.verbose("[KeychainStore] Successfully retrieved string from the store") + return string + + } + + @_spi(KeychainStore) + /// Get a data value from the Keychain based on the key. + /// This System Programming Interface (SPI) may have breaking changes in future updates. + /// - Parameter key: A String key use to look up the value in the Keychain + /// - Returns: A data value + public func _getData(_ key: String) throws -> Data { + log.verbose("[KeychainStore] Started retrieving `Data` from the store with key=\(key)") + var query = attributes.defaultGetQuery() + + query[Constants.MatchLimit] = Constants.MatchLimitOne + query[Constants.ReturnData] = kCFBooleanTrue + + query[Constants.AttributeAccount] = key + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + switch status { + case errSecSuccess: + guard let data = result as? Data else { + log.error("[KeychainStore] The keychain item retrieved is not the correct type") + throw KeychainStoreError.unknown("The keychain item retrieved is not the correct type") + } + log.verbose("[KeychainStore] Successfully retrieved `Data` from the store with key=\(key)") + return data + case errSecItemNotFound: + log.verbose("[KeychainStore] No Keychain item found for key=\(key)") + throw KeychainStoreError.itemNotFound + default: + log.error("[KeychainStore] Error of status=\(status) occurred when attempting to retrieve a Keychain item for key=\(key)") + throw KeychainStoreError.securityError(status) + } + } + + @_spi(KeychainStore) + /// Set a key-value pair in the Keychain. + /// This System Programming Interface (SPI) may have breaking changes in future updates. + /// - Parameters: + /// - value: A string value to store in Keychain + /// - key: A String key for the value to store in the Keychain + public func _set(_ value: String, key: String) throws { + log.verbose("[KeychainStore] Started setting `String` for key=\(key)") + guard let data = value.data(using: .utf8, allowLossyConversion: false) else { + log.error("[KeychainStore] Unable to create Data from String retrieved for key=\(key)") + throw KeychainStoreError.conversionError("Unable to create Data from String retrieved") + } + try _set(data, key: key) + log.verbose("[KeychainStore] Successfully added `String` for key=\(key)") + } + + @_spi(KeychainStore) + /// Set a key-value pair in the Keychain. + /// This iSystem Programming Interface (SPI) may have breaking changes in future updates. + /// - Parameters: + /// - value: A data value to store in Keychain + /// - key: A String key for the value to store in the Keychain + public func _set(_ value: Data, key: String) throws { + log.verbose("[KeychainStore] Started setting `Data` for key=\(key)") + var getQuery = attributes.defaultGetQuery() + getQuery[Constants.AttributeAccount] = key + log.verbose("[KeychainStore] Initialized fetching to decide whether update or add") + let fetchStatus = SecItemCopyMatching(getQuery as CFDictionary, nil) + switch fetchStatus { + case errSecSuccess: + #if os(macOS) + log.verbose("[KeychainStore] Deleting item on MacOS to add an item.") + SecItemDelete(getQuery as CFDictionary) + fallthrough + #else + log.verbose("[KeychainStore] Found existing item, updating") + var attributesToUpdate = [String: Any]() + attributesToUpdate[Constants.ValueData] = value + + let updateStatus = SecItemUpdate(getQuery as CFDictionary, attributesToUpdate as CFDictionary) + if updateStatus != errSecSuccess { + log.error("[KeychainStore] Error updating item to keychain with status=\(updateStatus)") + throw KeychainStoreError.securityError(updateStatus) + } + log.verbose("[KeychainStore] Successfully updated `String` in keychain for key=\(key)") + #endif + case errSecItemNotFound: + log.verbose("[KeychainStore] Unable to find an existing item, creating new item") + var attributesToSet = attributes.defaultSetQuery() + attributesToSet[Constants.AttributeAccount] = key + attributesToSet[Constants.ValueData] = value + + let addStatus = SecItemAdd(attributesToSet as CFDictionary, nil) + if addStatus != errSecSuccess { + log.error("[KeychainStore] Error adding item to keychain with status=\(addStatus)") + throw KeychainStoreError.securityError(addStatus) + } + log.verbose("[KeychainStore] Successfully added `String` in keychain for key=\(key)") + default: + log.error("[KeychainStore] Error occurred while retrieving data from keychain when deciding to update or add with status=\(fetchStatus)") + throw KeychainStoreError.securityError(fetchStatus) + } + } + + @_spi(KeychainStore) + /// Remove key-value pair from Keychain based on the provided key. + /// This System Programming Interface (SPI) may have breaking changes in future updates. + /// - Parameter key: A String key to delete the key-value pair + public func _remove(_ key: String) throws { + log.verbose("[KeychainStore] Starting to remove item from keychain with key=\(key)") + var query = attributes.defaultGetQuery() + query[Constants.AttributeAccount] = key + + let status = SecItemDelete(query as CFDictionary) + if status != errSecSuccess && status != errSecItemNotFound { + log.error("[KeychainStore] Error removing itms from keychain with status=\(status)") + throw KeychainStoreError.securityError(status) + } + log.verbose("[KeychainStore] Successfully removed item from keychain") + } + + @_spi(KeychainStore) + /// Removes all key-value pair in the Keychain. + /// This System Programming Interface (SPI) may have breaking changes in future updates. + public func _removeAll() throws { + log.verbose("[KeychainStore] Starting to remove all items from keychain") + var query = attributes.defaultGetQuery() +#if !os(iOS) && !os(watchOS) && !os(tvOS) + query[Constants.MatchLimit] = Constants.MatchLimitAll +#endif + + let status = SecItemDelete(query as CFDictionary) + if status != errSecSuccess && status != errSecItemNotFound { + log.error("[KeychainStore] Error removing all items from keychain with status=\(status)") + throw KeychainStoreError.securityError(status) + } + log.verbose("[KeychainStore] Successfully removed all items from keychain") + } + +} + +extension KeychainStore { + struct Constants { + /** Class Key Constant */ + static let Class = String(kSecClass) + static let ClassGenericPassword = String(kSecClassGenericPassword) + + /** Attribute Key Constants */ + static let AttributeAccessGroup = String(kSecAttrAccessGroup) + static let AttributeAccount = String(kSecAttrAccount) + static let AttributeService = String(kSecAttrService) + static let AttributeGeneric = String(kSecAttrGeneric) + static let AttributeLabel = String(kSecAttrLabel) + static let AttributeComment = String(kSecAttrComment) + static let AttributeAccessible = String(kSecAttrAccessible) + + /** Attribute Accessible Constants */ + static let AttributeAccessibleAfterFirstUnlockThisDeviceOnly = String(kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly) + + /** Search Constants */ + static let MatchLimit = String(kSecMatchLimit) + static let MatchLimitOne = kSecMatchLimitOne + static let MatchLimitAll = kSecMatchLimitAll + + /** Return Type Key Constants */ + static let ReturnData = String(kSecReturnData) + static let ReturnAttributes = String(kSecReturnAttributes) + + /** Value Type Key Constants */ + static let ValueData = String(kSecValueData) + + /** Indicates whether to treat macOS keychain items like iOS keychain items without setting kSecAttrSynchronizable */ + static let UseDataProtectionKeyChain = String(kSecUseDataProtectionKeychain) + } +} +// swiftlint:enable identifier_name + +extension KeychainStore: DefaultLogger { + public static var log: Logger { + Amplify.Logging.logger(forNamespace: String(describing: self)) + } + + public nonisolated var log: Logger { Self.log } +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Keychain/KeychainStoreAttributes.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Keychain/KeychainStoreAttributes.swift new file mode 100644 index 0000000000..a638b2879b --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Keychain/KeychainStoreAttributes.swift @@ -0,0 +1,39 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +struct KeychainStoreAttributes { + + var itemClass: String = KeychainStore.Constants.ClassGenericPassword + var service: String + var accessGroup: String? + +} + +extension KeychainStoreAttributes { + + func defaultGetQuery() -> [String: Any] { + var query: [String: Any] = [ + KeychainStore.Constants.Class: itemClass, + KeychainStore.Constants.AttributeService: service, + KeychainStore.Constants.UseDataProtectionKeyChain: kCFBooleanTrue + ] + + if let accessGroup = accessGroup { + query[KeychainStore.Constants.AttributeAccessGroup] = accessGroup + } + return query + } + + func defaultSetQuery() -> [String: Any] { + var query: [String: Any] = defaultGetQuery() + query[KeychainStore.Constants.AttributeAccessible] = KeychainStore.Constants.AttributeAccessibleAfterFirstUnlockThisDeviceOnly + query[KeychainStore.Constants.UseDataProtectionKeyChain] = kCFBooleanTrue + return query + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Keychain/KeychainStoreError.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Keychain/KeychainStoreError.swift new file mode 100644 index 0000000000..68940d2712 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Keychain/KeychainStoreError.swift @@ -0,0 +1,124 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import Security + +public enum KeychainStoreError { + + /// Caused by a configuration + case configuration(message: String) + + /// Caused by an unknown reason + case unknown(ErrorDescription, Error? = nil) + + /// Caused by trying to convert String to Data or vice-versa + case conversionError(ErrorDescription, Error? = nil) + + /// Caused by trying encoding/decoding + case codingError(ErrorDescription, Error? = nil) + + /// Unable to find the keychain item + case itemNotFound + + /// Caused trying to perform a keychain operation, examples, missing entitlements, missing required attributes, etc + case securityError(OSStatus) +} + +extension KeychainStoreError: AmplifyError { + + public init( + errorDescription: ErrorDescription = "An unknown error occurred", + recoverySuggestion: RecoverySuggestion = "(Ignored)", + error: Error + ) { + if let error = error as? Self { + self = error + } else if error.isOperationCancelledError { + self = .unknown("Operation cancelled", error) + } else { + self = .unknown(errorDescription, error) + } + } + + /// Error Description + public var errorDescription: ErrorDescription { + switch self { + case .conversionError(let errorDescription, _), .codingError(let errorDescription, _): + return errorDescription + case .securityError(let status): + let keychainStatus = KeychainStatus(status: status) + return keychainStatus.description + case .unknown(let errorDescription, _): + return "Unexpected error occurred with message: \(errorDescription)" + case .itemNotFound: + return "Unable to find the keychain item" + case .configuration(let message): + return message + } + } + + /// Recovery Suggestion + public var recoverySuggestion: RecoverySuggestion { + switch self { + case .itemNotFound: + // If a keychain item is not found, there is no recovery suggestion to suggest + return "" + case .securityError(let status): + let keychainStatus = KeychainStatus(status: status) +#if os(macOS) + // If its Missing entitlement error on macOS + guard case .missingEntitlement = keychainStatus else { + return AmplifyErrorMessages.shouldNotHappenReportBugToAWS() + } + return """ + To use Auth in a macOS project, you'll need to enable the Keychain Sharing capability. + This capability is required because Auth uses the Data Protection Keychain on macOS as + a platform best practice. See TN3137: macOS keychain APIs and implementations for more + information on how Keychain works on macOS and the Keychain Sharing entitlement. + For more information on adding capabilities to your application, see Xcode Capabilities. + """ +#else + return AmplifyErrorMessages.shouldNotHappenReportBugToAWS() +#endif + case .unknown, .conversionError, .codingError, .configuration: + return AmplifyErrorMessages.shouldNotHappenReportBugToAWS() + } + } + + /// Underlying Error + public var underlyingError: Error? { + switch self { + case .conversionError(_, let error), .codingError(_, let error), .unknown(_, let error): + return error + default: + return nil + } + } + +} + +extension KeychainStoreError: Equatable { + public static func == (lhs: KeychainStoreError, rhs: KeychainStoreError) -> Bool { + switch (lhs, rhs) { + case (.configuration, .configuration): + return true + case (.unknown, .unknown): + return true + case (.conversionError, .conversionError): + return true + case (.codingError, codingError): + return true + case (.itemNotFound, .itemNotFound): + return true + case (.securityError, .securityError): + return true + default: + return false + } + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/AnyModel/AnyModel+Codable.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/AnyModel/AnyModel+Codable.swift new file mode 100644 index 0000000000..9a151fba6b --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/AnyModel/AnyModel+Codable.swift @@ -0,0 +1,30 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Custom implementation of Codable for AnyModel stores the instance as its JSON string representation and uses the +/// ModelRegistry utilities to deserialize it +public extension AnyModel { + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + + id = try values.decode(String.self, forKey: .id) + modelName = try values.decode(String.self, forKey: .modelName) + + let instanceJSON = try values.decode(String.self, forKey: .instance) + instance = try ModelRegistry.decode(modelName: modelName, from: instanceJSON) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(modelName, forKey: .modelName) + + try container.encode(instance.toJSON(), forKey: .instance) + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/AnyModel/AnyModel+Schema.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/AnyModel/AnyModel+Schema.swift new file mode 100644 index 0000000000..e8c4995a1b --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/AnyModel/AnyModel+Schema.swift @@ -0,0 +1,13 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + + +public extension AnyModel { + var schema: ModelSchema { + instance.schema + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/AnyModel/AnyModel+Subscript.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/AnyModel/AnyModel+Subscript.swift new file mode 100644 index 0000000000..044b8c4b04 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/AnyModel/AnyModel+Subscript.swift @@ -0,0 +1,25 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// Implement dynamic access to properties of a `Model`. +/// +/// ```swift +/// let id = model["id"] +/// ``` +extension AnyModel { + + public subscript(_ key: String) -> Any? { + let mirror = Mirror(reflecting: instance) + let property = mirror.children.first { $0.label == key } + return property?.value + } + + public subscript(_ key: CodingKey) -> Any? { + return self[key.stringValue] + } + +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/AnyModel/AnyModel.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/AnyModel/AnyModel.swift new file mode 100644 index 0000000000..c50792885a --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/AnyModel/AnyModel.swift @@ -0,0 +1,60 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public struct AnyModel: Model { + public let id: String + public let instance: Model + + /// Overrides the convenience property with the model name of the instance, so that AnyModel can still be decoded + /// into the instances's original type. + public let modelName: String + + public init(_ instance: Model) { + self.id = instance.identifier + self.instance = instance + self.modelName = instance.modelName + } + + /// Delegates the identifier resolution to the wrapped model istance. + public func identifier(schema: ModelSchema) -> ModelIdentifierProtocol { + instance.identifier(schema: schema) + } + + /// Delegates the identifier resolution to the wrapped model istance. + public var identifier: String { + instance.identifier + } +} + +extension AnyModel { + // MARK: - CodingKeys + + public enum CodingKeys: String, ModelKey { + case id + case instance + case modelName + } + + public static let keys = CodingKeys.self + + // MARK: - ModelSchema + + public static let schema = defineSchema { definition in + let anyModel = AnyModel.keys + + definition.attributes(.isSystem, + .primaryKey(fields: [anyModel.id])) + + definition.fields( + .field(anyModel.id, is: .required, ofType: .string), + .field(anyModel.instance, is: .required, ofType: .string), + .field(anyModel.modelName, is: .required, ofType: .string) + ) + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/AnyModel/Model+AnyModel.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/AnyModel/Model+AnyModel.swift new file mode 100644 index 0000000000..096d183cef --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/AnyModel/Model+AnyModel.swift @@ -0,0 +1,13 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + + +public extension Model { + func eraseToAnyModel() throws -> AnyModel { + AnyModel(self) + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/Decorator/AuthRuleDecorator.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/Decorator/AuthRuleDecorator.swift new file mode 100644 index 0000000000..72328e8340 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/Decorator/AuthRuleDecorator.swift @@ -0,0 +1,224 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public typealias IdentityClaimsDictionary = [String: AnyObject] + +public enum AuthRuleDecoratorInput { + case subscription(GraphQLSubscriptionType, IdentityClaimsDictionary?) + case mutation + case query +} + +// Tracking issue: https://github.com/aws-amplify/amplify-cli/issues/4182 Once this issue is resolved, the behavior and +// the interface is expected to change. Subscription operations should not need to take in any owner fields in the +// document input, similar to how the mutations operate. For now, the provisioned backend requires owner field for +// the subscription operation corresponding to the operation defined on the auth rule. For example, +// @auth(rules: [ { allow: owner, operations: [create, delete] } ]) +// contains create and delete, therefore the onCreate and onDelete subscriptions require the owner field, but not the +// onUpdate subscription. + +/// Decorate the document with auth related fields. For `owner` strategy, fields include: +/// * add the value of `ownerField` to the model selection set, defaults "owner" when `ownerField` is not specified +/// * owner field value for subscription document inputs for the corresponding auth rule `operation` +public struct AuthRuleDecorator: ModelBasedGraphQLDocumentDecorator { + + private let input: AuthRuleDecoratorInput + private let authType: AWSAuthorizationType? + + /// Initializes a new AuthRuleDecorator + /// - Parameters: + /// - authRuleDecoratorInput: decorator input + /// - authType: authentication type, if provided will be used to filter the auth rules based on the provider field. + /// Only use when multi-auth is enabled. + public init(_ authRuleDecoratorInput: AuthRuleDecoratorInput, + authType: AWSAuthorizationType? = nil) { + self.input = authRuleDecoratorInput + self.authType = authType + } + + public func decorate(_ document: SingleDirectiveGraphQLDocument, + modelType: Model.Type) -> SingleDirectiveGraphQLDocument { + decorate(document, modelSchema: modelType.schema) + } + + public func decorate(_ document: SingleDirectiveGraphQLDocument, + modelSchema: ModelSchema) -> SingleDirectiveGraphQLDocument { + let authRules = modelSchema.authRules + .filterBy(authType: authType) + .filterBy(ownerFieldType: .string, modelSchema: modelSchema) + guard !authRules.isEmpty else { + return document + } + var decorateDocument = document + + let readRestrictingStaticGroups = authRules.groupClaimsToReadRestrictingStaticGroups() + authRules.forEach { authRule in + decorateDocument = decorateAuthStrategy(document: decorateDocument, + authRule: authRule, + readRestrictingStaticGroups: readRestrictingStaticGroups) + } + return decorateDocument + } + + private func decorateAuthStrategy(document: SingleDirectiveGraphQLDocument, + authRule: AuthRule, + readRestrictingStaticGroups: [String: Set]) -> SingleDirectiveGraphQLDocument { + guard authRule.allow == .owner, + var selectionSet = document.selectionSet else { + return document + } + + let ownerField = authRule.getOwnerFieldOrDefault() + selectionSet = appendOwnerFieldToSelectionSetIfNeeded(selectionSet: selectionSet, ownerField: ownerField) + + if case let .subscription(_, claims) = input, + let tokenClaims = claims, + authRule.isReadRestrictingOwner() && + isNotInReadRestrictingStaticGroup(jwtTokenClaims: tokenClaims, + readRestrictingStaticGroups: readRestrictingStaticGroups) { + var inputs = document.inputs + let identityClaimValue = resolveIdentityClaimValue(identityClaim: authRule.identityClaimOrDefault(), + claims: tokenClaims) + if let identityClaimValue = identityClaimValue { + inputs[ownerField] = GraphQLDocumentInput(type: "String!", value: .scalar(identityClaimValue)) + } + return document.copy(inputs: inputs, selectionSet: selectionSet) + } + + // TODO: Subscriptions always require an `owner` field. + // We're sending an invalid owner value to receive a proper response from AppSync, + // when there's no authenticated user. + // We should be instead failing early and don't send the request. + // See: https://github.com/aws-amplify/amplify-ios/issues/1291 + if case let .subscription(_, claims) = input, authRule.isReadRestrictingOwner(), claims == nil { + var inputs = document.inputs + inputs[ownerField] = GraphQLDocumentInput(type: "String!", value: .scalar("")) + return document.copy(inputs: inputs, selectionSet: selectionSet) + } + + return document.copy(selectionSet: selectionSet) + } + + private func isNotInReadRestrictingStaticGroup(jwtTokenClaims: IdentityClaimsDictionary, + readRestrictingStaticGroups: [String: Set]) -> Bool { + for (groupClaim, readRestrictingStaticGroupsPerClaim) in readRestrictingStaticGroups { + let groupsFromClaim = groupsFrom(jwtTokenClaims: jwtTokenClaims, groupClaim: groupClaim) + let doesNotBelongToGroupsFromClaim = readRestrictingStaticGroupsPerClaim.isEmpty || + readRestrictingStaticGroupsPerClaim.isDisjoint(with: groupsFromClaim) + if doesNotBelongToGroupsFromClaim { + continue + } else { + return false + } + } + return true + } + + private func groupsFrom(jwtTokenClaims: IdentityClaimsDictionary, + groupClaim: String) -> Set { + var groupSet = Set() + if let groups = (jwtTokenClaims[groupClaim] as? NSArray) as Array? { + for group in groups { + if let groupString = group as? String { + groupSet.insert(groupString) + } + } + } + return groupSet + } + + private func resolveIdentityClaimValue(identityClaim: String, claims: IdentityClaimsDictionary) -> String? { + guard let identityValue = claims[identityClaim] as? String else { + log.error(""" + Attempted to subscribe to a model with owner based authorization without \(identityClaim) + which was specified (or defaulted to) as the identity claim. + """) + return nil + } + return identityValue + } + + /// First finds the first `model` SelectionSet. Then, only append it when the `ownerField` does not exist. + private func appendOwnerFieldToSelectionSetIfNeeded(selectionSet: SelectionSet, + ownerField: String) -> SelectionSet { + var selectionSetModel = selectionSet + while selectionSetModel.value.fieldType != .model { + selectionSetModel.children.forEach { selectionSet in + if selectionSet.value.fieldType == .model { + selectionSetModel = selectionSet + } + } + + } + + let containersOwnerField = selectionSetModel.children.contains { (field) -> Bool in + if let fieldName = field.value.name, fieldName == ownerField { + return true + } + return false + } + if !containersOwnerField { + let child = SelectionSet(value: .init(name: ownerField, fieldType: .value)) + selectionSetModel.children.append(child) + } + + return selectionSet + } +} + +private extension AuthRule { + func ownerField(inSchema schema: ModelSchema) -> ModelField? { + guard let fieldName = self.ownerField else { + return nil + } + return schema.field(withName: fieldName) + } +} + +private extension AuthRules { + func filterBy(authType: AWSAuthorizationType?) -> AuthRules { + guard let authType = authType else { + return self + } + + return filter { + guard let provider = $0.provider else { + // if an authType is available but not a provider + // means DataStore is using multi-auth with an outdated + // version of models (prior to codegen v2.26.0). + return true + } + return authType == provider.toAWSAuthorizationType() + } + } + + func filterBy(ownerFieldType: ModelFieldType, + modelSchema: ModelSchema) -> AuthRules { + return filter { + guard let modelField = $0.ownerField(inSchema: modelSchema) else { + // if we couldn't find the owner field means it has been implicitly + // declared in the model schema, therefore has the correct type "string" + return true + } + if case .string = modelField.type { + return true + } + return false + } + } +} + +extension AuthRuleDecorator: DefaultLogger { + public static var log: Logger { + Amplify.Logging.logger(forCategory: CategoryType.auth.displayName, forNamespace: String(describing: self)) + } + public var log: Logger { + Self.log + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/Decorator/ConflictResolutionDecorator.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/Decorator/ConflictResolutionDecorator.swift new file mode 100644 index 0000000000..094eacf778 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/Decorator/ConflictResolutionDecorator.swift @@ -0,0 +1,115 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Adds conflict resolution information onto the document based on the operation type (query or mutation) +/// All selection sets are decorated with conflict resolution fields and inputs are added based on the values that it +/// was instantiated with. If `version` is passed, the input with key "input" will contain "_version" with the `version` +/// value. If `lastSync` is passed, the input will contain new key "lastSync" with the `lastSync` value. +public struct ConflictResolutionDecorator: ModelBasedGraphQLDocumentDecorator { + + private let version: Int? + private let lastSync: Int64? + private let graphQLType: GraphQLOperationType + private var primaryKeysOnly: Bool + + public init(version: Int? = nil, + lastSync: Int64? = nil, + graphQLType: GraphQLOperationType, + primaryKeysOnly: Bool = true) { + self.version = version + self.lastSync = lastSync + self.graphQLType = graphQLType + self.primaryKeysOnly = primaryKeysOnly + } + + public func decorate(_ document: SingleDirectiveGraphQLDocument, + modelType: Model.Type) -> SingleDirectiveGraphQLDocument { + decorate(document, modelSchema: modelType.schema) + } + + public func decorate(_ document: SingleDirectiveGraphQLDocument, + modelSchema: ModelSchema) -> SingleDirectiveGraphQLDocument { + var primaryKeysOnly = primaryKeysOnly + if primaryKeysOnly && ModelRegistry.modelType(from: modelSchema.name)?.rootPath == nil { + primaryKeysOnly = false + } + var inputs = document.inputs + + if let version = version, + case .mutation = document.operationType, + var input = inputs["input"], + case var .object(value) = input.value { + + value["_version"] = version + input.value = .object(value) + inputs["input"] = input + } + + if let lastSync = lastSync, case .query = document.operationType { + inputs["lastSync"] = GraphQLDocumentInput(type: "AWSTimestamp", value: .scalar(lastSync)) + } + + if let selectionSet = document.selectionSet { + addConflictResolution(selectionSet: selectionSet, primaryKeysOnly: primaryKeysOnly) + return document.copy(inputs: inputs, selectionSet: selectionSet) + } + + return document.copy(inputs: inputs) + } + + enum SyncMetadataFields { + case full + case deletedFieldOnly + } + /// Append the correct conflict resolution fields for `model` and `pagination` selection sets. + private func addConflictResolution(selectionSet: SelectionSet, + primaryKeysOnly: Bool, + includeSyncMetadataFields: SyncMetadataFields = .full) { + var includeSyncMetadataFields = includeSyncMetadataFields + switch selectionSet.value.fieldType { + case .value, .embedded: + break + case .model, .collection: + switch includeSyncMetadataFields { + case .full: + selectionSet.addChild(settingParentOf: .init(value: .init(name: "_version", fieldType: .value))) + selectionSet.addChild(settingParentOf: .init(value: .init(name: "_deleted", fieldType: .value))) + selectionSet.addChild(settingParentOf: .init(value: .init(name: "_lastChangedAt", fieldType: .value))) + includeSyncMetadataFields = .deletedFieldOnly + case .deletedFieldOnly: + selectionSet.addChild(settingParentOf: .init(value: .init(name: "_deleted", fieldType: .value))) + } + case .pagination: + selectionSet.addChild(settingParentOf: .init(value: .init(name: "startedAt", fieldType: .value))) + } + + if !primaryKeysOnly || graphQLType == .mutation { + // Continue to add version fields for all levels, for backwards compatibility + // Reduce the selection set only when the type is "subscription" and "query" + // (specifically for syncQuery). Selection set for mutation should not be reduced + // because it needs to be the full selection set to send mutation events to older iOS clients, + // which do not have the reduced subscription selection set. + // subscriptions and sync query is to receive data, so it can be reduced to allow decoding to the + // LazyReference type. + selectionSet.children.forEach { child in + addConflictResolution(selectionSet: child, + primaryKeysOnly: primaryKeysOnly, + includeSyncMetadataFields: .full) + } + } else { + // Only add all the sync metadata fields once. Once this was done once, `includeSyncMetadataFields` + // should be set to `.deletedFieldOnly` and passed down to the recursive call stack. + selectionSet.children.forEach { child in + addConflictResolution(selectionSet: child, + primaryKeysOnly: primaryKeysOnly, + includeSyncMetadataFields: includeSyncMetadataFields) + } + } + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/Decorator/DirectiveNameDecorator.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/Decorator/DirectiveNameDecorator.swift new file mode 100644 index 0000000000..ef93809a95 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/Decorator/DirectiveNameDecorator.swift @@ -0,0 +1,59 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Replaces the directive name of the GraphQL document based on Amplify GraphQL operation types such as "get", "list", +/// "sync", "create", "update", "delete", "onCreate", "onUpdate", and "onDelete". The GraphQL name is constructed based +/// on the data from the Model schema and the operation type. +public struct DirectiveNameDecorator: ModelBasedGraphQLDocumentDecorator { + + private let queryType: GraphQLQueryType? + private let mutationType: GraphQLMutationType? + private let subscriptionType: GraphQLSubscriptionType? + + public init(type: GraphQLQueryType) { + self.queryType = type + self.mutationType = nil + self.subscriptionType = nil + } + + public init(type: GraphQLMutationType) { + self.queryType = nil + self.mutationType = type + self.subscriptionType = nil + } + + public init(type: GraphQLSubscriptionType) { + self.queryType = nil + self.mutationType = nil + self.subscriptionType = type + } + + public func decorate(_ document: SingleDirectiveGraphQLDocument, + modelType: Model.Type) -> SingleDirectiveGraphQLDocument { + decorate(document, modelSchema: modelType.schema) + } + + public func decorate(_ document: SingleDirectiveGraphQLDocument, + modelSchema: ModelSchema) -> SingleDirectiveGraphQLDocument { + + if let queryType = queryType { + return document.copy(name: modelSchema.graphQLName(queryType: queryType)) + } + + if let mutationType = mutationType { + return document.copy(name: modelSchema.graphQLName(mutationType: mutationType)) + } + + if let subscriptionType = subscriptionType { + return document.copy(name: modelSchema.graphQLName(subscriptionType: subscriptionType)) + } + + return document + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/Decorator/FilterDecorator.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/Decorator/FilterDecorator.swift new file mode 100644 index 0000000000..a8f81574b6 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/Decorator/FilterDecorator.swift @@ -0,0 +1,43 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Decorates a GraphQL mutation with a "condition" input or a GraphQL query with a "filter" input. +/// The value is a `GraphQLFilter` object +public struct FilterDecorator: ModelBasedGraphQLDocumentDecorator { + + private let filter: GraphQLFilter + + public init(filter: GraphQLFilter) { + self.filter = filter + } + + public func decorate(_ document: SingleDirectiveGraphQLDocument, + modelType: Model.Type) -> SingleDirectiveGraphQLDocument { + decorate(document, modelSchema: modelType.schema) + } + + public func decorate(_ document: SingleDirectiveGraphQLDocument, + modelSchema: ModelSchema) -> SingleDirectiveGraphQLDocument { + guard !filter.isEmpty else { + return document.copy(inputs: document.inputs) + } + + var inputs = document.inputs + let modelName = modelSchema.name + if case .mutation = document.operationType { + inputs["condition"] = GraphQLDocumentInput(type: "Model\(modelName)ConditionInput", + value: .object(filter)) + } else if case .query = document.operationType { + inputs["filter"] = GraphQLDocumentInput(type: "Model\(modelName)FilterInput", + value: .object(filter)) + } + + return document.copy(inputs: inputs) + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/Decorator/IncludeAssociationDecorator.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/Decorator/IncludeAssociationDecorator.swift new file mode 100644 index 0000000000..5bff109da7 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/Decorator/IncludeAssociationDecorator.swift @@ -0,0 +1,92 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Decorates a GraphQL query or mutation with nested/associated properties that should +/// be included in the final selection set. +public struct IncludeAssociationDecorator: ModelBasedGraphQLDocumentDecorator { + + let includedAssociations: [PropertyContainerPath] + + init(_ includedAssociations: [PropertyContainerPath] = []) { + self.includedAssociations = includedAssociations + } + + public func decorate(_ document: SingleDirectiveGraphQLDocument, + modelType: Model.Type) -> SingleDirectiveGraphQLDocument { + return decorate(document, modelSchema: modelType.schema) + } + + public func decorate(_ document: SingleDirectiveGraphQLDocument, + modelSchema: ModelSchema) -> SingleDirectiveGraphQLDocument { + if includedAssociations.isEmpty { + return document + } + guard let selectionSet = document.selectionSet else { + return document + } + + includedAssociations.forEach { association in + // we don't include the root reference because it refers to the root model + // fields in the selection set, only the nested/included ones are needed + if let associationSelectionSet = association.asSelectionSet(includeRoot: false) { + selectionSet.merge(with: associationSelectionSet) + } + } + + return document.copy(selectionSet: selectionSet) + } + +} + +extension PropertyContainerPath { + /// Build GraphQL Selection Set based on Model Associations + /// `PropertyContainerPath` is a tree path with leaf node pointer that + /// represents the associations from bottom to the top. + /// + /// - Returns: A Optional represents GraphQL Selection Set from top to bottom. + func asSelectionSet(includeRoot: Bool = true) -> SelectionSet? { + func getSelectionSet(node: PropertyContainerPath) -> SelectionSet { + let metadata = node.getMetadata() + let modelName = node.getModelType().modelName + + guard let schema = ModelRegistry.modelSchema(from: modelName) else { + fatalError("Schema for model \(modelName) could not be found.") + } + let fieldType: SelectionSetFieldType = metadata.isCollection ? .collection : .model + + let selectionSet = SelectionSet(value: .init(name: metadata.name, fieldType: fieldType)) + selectionSet.withModelFields(schema.graphQLFields, primaryKeysOnly: true) + return selectionSet + } + + func shouldProcessNode(node: PropertyContainerPath) -> Bool { + includeRoot || node.getMetadata().name != "root" + } + + func nodesInPath(node: PropertyContainerPath) -> [PropertyContainerPath] { + var parent: PropertyContainerPath? = node + var path = [PropertyContainerPath]() + while let currentNode = parent, shouldProcessNode(node: currentNode) { + path.append(currentNode) + parent = currentNode.getMetadata().parent as? PropertyContainerPath + } + return path + } + + let selectionSets = nodesInPath(node: self).map(getSelectionSet(node:)) + return selectionSets.dropFirst().reduce(selectionSets.first) { partialResult, selectionSet in + guard let partialResult = partialResult else { + return selectionSet + } + selectionSet.replaceChild(partialResult) + return selectionSet + } + } + +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/Decorator/ModelBasedGraphQLDocumentDecorator.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/Decorator/ModelBasedGraphQLDocumentDecorator.swift new file mode 100644 index 0000000000..1c9a5ee6e1 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/Decorator/ModelBasedGraphQLDocumentDecorator.swift @@ -0,0 +1,20 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public protocol ModelBasedGraphQLDocumentDecorator { + + @available(*, deprecated, message: """ + Decorating using Model.Type is deprecated, instead use modelSchema method. + """) + func decorate(_ document: SingleDirectiveGraphQLDocument, + modelType: Model.Type) -> SingleDirectiveGraphQLDocument + + func decorate(_ document: SingleDirectiveGraphQLDocument, + modelSchema: ModelSchema) -> SingleDirectiveGraphQLDocument +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/Decorator/ModelDecorator.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/Decorator/ModelDecorator.swift new file mode 100644 index 0000000000..47938bb3d1 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/Decorator/ModelDecorator.swift @@ -0,0 +1,51 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Decorate the GraphQL document with the data from an instance of the model. This is added as a single parameter +/// called "input" that can be referenced by other decorators to append additional document inputs. This decorator +/// constructs the input's type using the document name. +public struct ModelDecorator: ModelBasedGraphQLDocumentDecorator { + + private let model: Model + private let mutationType: GraphQLMutationType + + public init(model: Model, mutationType: GraphQLMutationType) { + self.model = model + self.mutationType = mutationType + } + + public func decorate(_ document: SingleDirectiveGraphQLDocument, + modelType: Model.Type) -> SingleDirectiveGraphQLDocument { + decorate(document, modelSchema: modelType.schema) + } + + public func decorate(_ document: SingleDirectiveGraphQLDocument, + modelSchema: ModelSchema) -> SingleDirectiveGraphQLDocument { + var inputs = document.inputs + var graphQLInput = model.graphQLInputForMutation(modelSchema, mutationType: mutationType) + + if !modelSchema.authRules.isEmpty { + modelSchema.authRules.forEach { authRule in + if authRule.allow == .owner { + let ownerField = authRule.getOwnerFieldOrDefault() + graphQLInput = graphQLInput.filter { (field, value) -> Bool in + if field == ownerField, value == nil { + return false + } + return true + } + } + } + } + + inputs["input"] = GraphQLDocumentInput(type: "\(document.name.pascalCased())Input!", + value: .object(graphQLInput)) + return document.copy(inputs: inputs) + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/Decorator/ModelIdDecorator.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/Decorator/ModelIdDecorator.swift new file mode 100644 index 0000000000..b203aabfc3 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/Decorator/ModelIdDecorator.swift @@ -0,0 +1,115 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Decorate the GraphQLDocument with the value of `ModelIdentifier` for a "delete" mutation or "get" query. +public struct ModelIdDecorator: ModelBasedGraphQLDocumentDecorator { + /// Array of model fields and their stringified value + private var identifierFields = [(name: String, value: GraphQLDocumentValueRepresentable, type: String)]() + + public init(model: Model, schema: ModelSchema) { + + var firstField = true + self.identifierFields = model.identifier(schema: schema).fields.compactMap { fieldName, _ in + guard let value = model.graphQLInputForPrimaryKey(modelFieldName: fieldName, + modelSchema: schema) else { + return nil + } + if firstField { + firstField = false + return (name: fieldName, value: value, type: "ID!") + } else { + return (name: fieldName, value: value, type: "String!") + } + } + } + + public init(identifierFields: [(name: String, value: Persistable)]) { + var firstField = true + identifierFields.forEach { name, value in + self.identifierFields.append((name: name, value: Self.convert(persistable: value), type: firstField == true ? "ID!" : "String!")) + firstField = false + } + } + + public init(identifiers: [LazyReferenceIdentifier]) { + var firstField = true + identifiers.forEach({ identifier in + self.identifierFields.append((name: identifier.name, value: identifier.value, type: firstField == true ? "ID!": "String!")) + firstField = false + }) + } + + @available(*, deprecated, message: "Use init(model:schema:)") + public init(model: Model) { + self.init(model: model, schema: model.schema) + } + + @available(*, deprecated, message: "Use init(model:schema:)") + public init(id: Model.Identifier, fields: [String: String]? = nil) { + let identifier = (name: ModelIdentifierFormat.Default.name, value: id, type: "ID!") + var identifierFields = [identifier] + + if let fields = fields { + identifierFields.append(contentsOf: fields.map { key, value in + (name: key, value: value, type: "String!") + }) + } + self.identifierFields = identifierFields + } + + public func decorate(_ document: SingleDirectiveGraphQLDocument, + modelType: Model.Type) -> SingleDirectiveGraphQLDocument { + decorate(document, modelSchema: modelType.schema) + } + + public func decorate(_ document: SingleDirectiveGraphQLDocument, + modelSchema: ModelSchema) -> SingleDirectiveGraphQLDocument { + var inputs = document.inputs + + if case .mutation = document.operationType { + var inputMap = [String: String]() + for (name, value, _) in identifierFields { + inputMap[name] = value.graphQLDocumentValue + } + inputs["input"] = GraphQLDocumentInput(type: "\(document.name.pascalCased())Input!", + value: .object(inputMap)) + + } else if case .query = document.operationType { + for (name, value, type) in identifierFields { + inputs[name] = GraphQLDocumentInput( + type: type, + value: identifierFields.count > 1 ? .inline(value) : .scalar(value) + ) + } + } + + return document.copy(inputs: inputs) + } +} + +fileprivate extension ModelIdDecorator { + private static func convert(persistable: Persistable) -> GraphQLDocumentValueRepresentable { + switch persistable { + case let data as Double: + return data + case let data as Int: + return data + case let data as Bool: + return data + case let data as Temporal.DateTime: + return data.iso8601String + case let data as Temporal.Date: + return data.iso8601String + case let data as Temporal.Time: + return data.iso8601String + default: + return "\(persistable)" + } + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/Decorator/PaginationDecorator.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/Decorator/PaginationDecorator.swift new file mode 100644 index 0000000000..dca545e8cf --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/Decorator/PaginationDecorator.swift @@ -0,0 +1,58 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Decorate the document input with "limit" and "nextToken". Also paginates the selection set with pagination fields. +public struct PaginationDecorator: ModelBasedGraphQLDocumentDecorator { + + private let limit: Int? + private let nextToken: String? + + public init(limit: Int? = nil, nextToken: String? = nil) { + self.limit = limit + self.nextToken = nextToken + } + + public func decorate(_ document: SingleDirectiveGraphQLDocument, + modelType: Model.Type) -> SingleDirectiveGraphQLDocument { + decorate(document, modelSchema: modelType.schema) + } + + public func decorate(_ document: SingleDirectiveGraphQLDocument, + modelSchema: ModelSchema) -> SingleDirectiveGraphQLDocument { + var inputs = document.inputs + + if let limit = limit { + inputs["limit"] = GraphQLDocumentInput(type: "Int", value: .scalar(limit)) + } else { + inputs["limit"] = GraphQLDocumentInput(type: "Int", value: .scalar(1_000)) + } + + if let nextToken = nextToken { + inputs["nextToken"] = GraphQLDocumentInput(type: "String", value: .scalar(nextToken)) + } + + if let selectionSet = document.selectionSet { + + return document.copy(inputs: inputs, + selectionSet: withPagination(selectionSet: selectionSet)) + } + + return document.copy(inputs: inputs) + } + + /// Wrap the selectionSet with a pagination selection set, + func withPagination(selectionSet: SelectionSet) -> SelectionSet { + let paginatedNode = SelectionSetField(fieldType: .pagination) + let newRoot = SelectionSet(value: paginatedNode) + selectionSet.value.name = "items" + newRoot.addChild(settingParentOf: selectionSet) + newRoot.addChild(settingParentOf: SelectionSet(value: SelectionSetField(name: "nextToken", fieldType: .value))) + return newRoot + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/GraphQLDocument/GraphQLMutation.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/GraphQLDocument/GraphQLMutation.swift new file mode 100644 index 0000000000..bd106d9445 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/GraphQLDocument/GraphQLMutation.swift @@ -0,0 +1,41 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// A concrete implementation of `SingleDirectiveGraphQLDocument` that represents a mutation operation. +public struct GraphQLMutation: SingleDirectiveGraphQLDocument { + + public init(operationType: GraphQLOperationType, + name: String, + inputs: [GraphQLParameterName: GraphQLDocumentInput], + selectionSet: SelectionSet?) { + self.operationType = operationType + self.name = name + self.inputs = inputs + self.selectionSet = selectionSet + } + + @available(*, deprecated, message: """ + Init with modelType is deprecated, use init with modelSchema instead. + """) + public init(modelType: Model.Type, primaryKeysOnly: Bool) { + self.init(modelSchema: modelType.schema, primaryKeysOnly: primaryKeysOnly) + } + + public init(modelSchema: ModelSchema, primaryKeysOnly: Bool) { + self.selectionSet = SelectionSet(fields: modelSchema.graphQLFields, primaryKeysOnly: primaryKeysOnly) + } + + public var name: String = "" + + public var operationType: GraphQLOperationType = .mutation + + public var inputs: [GraphQLParameterName: GraphQLDocumentInput] = [:] + + public var selectionSet: SelectionSet? +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/GraphQLDocument/GraphQLQuery.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/GraphQLDocument/GraphQLQuery.swift new file mode 100644 index 0000000000..a29ecf5bcf --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/GraphQLDocument/GraphQLQuery.swift @@ -0,0 +1,41 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// A concrete implementation of `SingleDirectiveGraphQLDocument` that represents a query operation. +public struct GraphQLQuery: SingleDirectiveGraphQLDocument { + + public init(operationType: GraphQLOperationType, + name: String, + inputs: [GraphQLParameterName: GraphQLDocumentInput], + selectionSet: SelectionSet?) { + self.operationType = operationType + self.name = name + self.inputs = inputs + self.selectionSet = selectionSet + } + + @available(*, deprecated, message: """ + Init with modelType is deprecated, use init with modelSchema instead. + """) + public init(modelType: Model.Type, primaryKeysOnly: Bool) { + self.init(modelSchema: modelType.schema, primaryKeysOnly: primaryKeysOnly) + } + + public init(modelSchema: ModelSchema, primaryKeysOnly: Bool) { + self.selectionSet = SelectionSet(fields: modelSchema.graphQLFields, primaryKeysOnly: primaryKeysOnly) + } + + public var name: String = "" + + public var operationType: GraphQLOperationType = .query + + public var inputs: [GraphQLParameterName: GraphQLDocumentInput] = [:] + + public var selectionSet: SelectionSet? +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/GraphQLDocument/GraphQLSubscription.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/GraphQLDocument/GraphQLSubscription.swift new file mode 100644 index 0000000000..c73c44c600 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/GraphQLDocument/GraphQLSubscription.swift @@ -0,0 +1,41 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// A concrete implementation of `SingleDirectiveGraphQLDocument` that represents a subscription operation. +public struct GraphQLSubscription: SingleDirectiveGraphQLDocument { + + public init(operationType: GraphQLOperationType, + name: String, + inputs: [GraphQLParameterName: GraphQLDocumentInput], + selectionSet: SelectionSet?) { + self.operationType = operationType + self.name = name + self.inputs = inputs + self.selectionSet = selectionSet + } + +@available(*, deprecated, message: """ + Init with modelType is deprecated, use init with modelSchema instead. + """) + public init(modelType: Model.Type, primaryKeysOnly: Bool) { + self.init(modelSchema: modelType.schema, primaryKeysOnly: primaryKeysOnly) + } + + public init(modelSchema: ModelSchema, primaryKeysOnly: Bool) { + self.selectionSet = SelectionSet(fields: modelSchema.graphQLFields, primaryKeysOnly: primaryKeysOnly) + } + + public var operationType: GraphQLOperationType = .subscription + + public var name: String = "" + + public var inputs: [GraphQLParameterName: GraphQLDocumentInput] = [:] + + public var selectionSet: SelectionSet? +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/GraphQLDocument/ModelBasedGraphQLDocumentBuilder.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/GraphQLDocument/ModelBasedGraphQLDocumentBuilder.swift new file mode 100644 index 0000000000..aac2b0a838 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/GraphQLDocument/ModelBasedGraphQLDocumentBuilder.swift @@ -0,0 +1,61 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Helps construct a `SingleDirectiveGraphQLDocument`. Collects instances of the decorators and applies the changes +/// on the document. +public struct ModelBasedGraphQLDocumentBuilder { + private var decorators = [ModelBasedGraphQLDocumentDecorator]() + private var document: SingleDirectiveGraphQLDocument + private let modelSchema: ModelSchema + + public init(modelName: String, operationType: GraphQLOperationType, primaryKeysOnly: Bool = true) { + guard let modelSchema = ModelRegistry.modelSchema(from: modelName) else { + preconditionFailure("Missing ModelSchema in ModelRegistry for model name: \(modelName)") + } + + self.init(modelSchema: modelSchema, operationType: operationType, primaryKeysOnly: primaryKeysOnly) + } + + @available(*, deprecated, message: """ + Init with modelType is deprecated, use init with modelSchema instead. + """) + public init(modelType: Model.Type, operationType: GraphQLOperationType, primaryKeysOnly: Bool = true) { + self.init(modelSchema: modelType.schema, operationType: operationType, primaryKeysOnly: primaryKeysOnly) + } + + public init(modelSchema: ModelSchema, operationType: GraphQLOperationType, primaryKeysOnly: Bool = true) { + self.modelSchema = modelSchema + var primaryKeysOnly = primaryKeysOnly + if primaryKeysOnly && ModelRegistry.modelType(from: modelSchema.name)?.rootPath == nil { + primaryKeysOnly = false + } + + switch operationType { + case .query: + self.document = GraphQLQuery(modelSchema: modelSchema, primaryKeysOnly: primaryKeysOnly) + case .mutation: + self.document = GraphQLMutation(modelSchema: modelSchema, primaryKeysOnly: primaryKeysOnly) + case .subscription: + self.document = GraphQLSubscription(modelSchema: modelSchema, primaryKeysOnly: primaryKeysOnly) + } + } + + public mutating func add(decorator: ModelBasedGraphQLDocumentDecorator) { + decorators.append(decorator) + } + + public mutating func build() -> SingleDirectiveGraphQLDocument { + + let decoratedDocument = decorators.reduce(document) { doc, decorator in + decorator.decorate(doc, modelSchema: self.modelSchema) + } + + return decoratedDocument + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/GraphQLDocument/SingleDirectiveGraphQLDocument.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/GraphQLDocument/SingleDirectiveGraphQLDocument.swift new file mode 100644 index 0000000000..2d44515e27 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/GraphQLDocument/SingleDirectiveGraphQLDocument.swift @@ -0,0 +1,114 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public typealias GraphQLParameterName = String + +/// Represents a single directive GraphQL document. Concrete types that conform to this protocol must +/// define a valid GraphQL operation document. +/// +/// This type aims to provide a representation of a simple GraphQL document with its components that can be easily +/// decorated to extend the document. The components can then derive the standardized form of a GraphQL document +/// containing the query string and variables. +public protocol SingleDirectiveGraphQLDocument { + /// The `GraphQLOperationType` a concrete implementation represents the + /// GraphQL operation of the document + var operationType: GraphQLOperationType { get set } + + /// The name of the document. This is useful to inspect the response, since it will + /// contain the name of the document as the key to the response value. + var name: String { get set } + + /// Input parameters and its values on the GraphQL document + var inputs: [GraphQLParameterName: GraphQLDocumentInput] { get set } + + /// The selection set of the document, used to specify the response data returned by the service. + var selectionSet: SelectionSet? { get set } + + /// Simple constructor to be implemented by the concrete types, used by the `copy` method. + init(operationType: GraphQLOperationType, + name: String, + inputs: [GraphQLParameterName: GraphQLDocumentInput], + selectionSet: SelectionSet?) +} + +// Provides default implementation +extension SingleDirectiveGraphQLDocument { + + /// Method to create a deep copy of the document, useful for `ModelBasedGraphQLDocumentDecorator` decorators + /// when decorating a document and returning a new document. + public func copy(operationType: GraphQLOperationType? = nil, + name: String? = nil, + inputs: [GraphQLParameterName: GraphQLDocumentInput]? = nil, + selectionSet: SelectionSet? = nil) -> Self { + + return Self.init(operationType: operationType ?? self.operationType, + name: name ?? self.name, + inputs: inputs ?? self.inputs, + selectionSet: selectionSet ?? self.selectionSet) + } + + /// Returns nil when there are no `inputs`. Otherwise, consolidates the `inputs` + /// into a single object that can be used for the GraphQL request. + public var variables: [String: Any]? { + if inputs.isEmpty { + return nil + } + + var variables = [String: Any]() + inputs.forEach { input in + switch input.value.value { + case .object(let values): + variables.updateValue(values, forKey: input.key) + case .scalar(let value): + variables.updateValue(value, forKey: input.key) + case .inline: + break + } + + } + + return variables + } + + /// Provides default construction of the graphQL document based on the components of the document. + public var stringValue: String { + + let selectionSetString = selectionSet?.stringValue(indentSize: 2) ?? "" + + guard !inputs.isEmpty else { + return """ + \(operationType.rawValue) \(name.pascalCased()) { + \(name) { + \(selectionSetString) + } + } + """ + } + + let sortedInputs = inputs.sorted { $0.0 < $1.0 } + let variableInputs = sortedInputs.filter { !$0.value.value.isInline() } + let inlineInputs = sortedInputs.filter { $0.value.value.isInline() } + let variableInputTypes = variableInputs.map { "$\($0.key): \($0.value.type)" }.joined(separator: ", ") + + var inputParameters = variableInputs.map { ($0.key, "$\($0.key)") } + for input in inlineInputs { + if case .inline(let document) = input.value.value { + inputParameters.append((input.key, document.graphQLInlineValue)) + } + } + + return """ + \(operationType.rawValue) \(name.pascalCased())\(variableInputTypes.isEmpty ? "" : "(\(variableInputTypes))") { + \(name)(\(inputParameters.map({ "\($0.0): \($0.1)"}).joined(separator: ", "))) { + \(selectionSetString) + } + } + """ + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/GraphQLRequest/GraphQLRequest+AnyModelWithSync.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/GraphQLRequest/GraphQLRequest+AnyModelWithSync.swift new file mode 100644 index 0000000000..301134b1d1 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/GraphQLRequest/GraphQLRequest+AnyModelWithSync.swift @@ -0,0 +1,291 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public typealias SyncQueryResult = PaginatedList +public typealias MutationSyncResult = MutationSync + +/// TODO document this and change it to work in a way that these functions are not +/// publicly exposed to developers +protocol ModelSyncGraphQLRequestFactory { + + static func query(modelName: String, + byId id: String, + authType: AWSAuthorizationType?) -> GraphQLRequest + + static func createMutation(of model: Model, + modelSchema: ModelSchema, + version: Int?, + authType: AWSAuthorizationType?) -> GraphQLRequest + + static func updateMutation(of model: Model, + modelSchema: ModelSchema, + where filter: GraphQLFilter?, + version: Int?, + authType: AWSAuthorizationType?) -> GraphQLRequest + + static func deleteMutation(of model: Model, + modelSchema: ModelSchema, + where filter: GraphQLFilter?, + version: Int?, + authType: AWSAuthorizationType?) -> GraphQLRequest + + static func subscription(to modelSchema: ModelSchema, + subscriptionType: GraphQLSubscriptionType, + authType: AWSAuthorizationType?) -> GraphQLRequest + + static func subscription(to modelSchema: ModelSchema, + subscriptionType: GraphQLSubscriptionType, + claims: IdentityClaimsDictionary, + authType: AWSAuthorizationType?) -> GraphQLRequest + + static func syncQuery(modelSchema: ModelSchema, + where predicate: QueryPredicate?, + limit: Int?, + nextToken: String?, + lastSync: Int64?, + authType: AWSAuthorizationType?) -> GraphQLRequest + +} + +/// Extension methods that are useful for `DataStore`. The methods consist of conflict resolution related fields such +/// as `version` and `lastSync` and returns a model that has been erased to `AnyModel`. +extension GraphQLRequest: ModelSyncGraphQLRequestFactory { + + public static func query(modelName: String, + byId id: String, + authType: AWSAuthorizationType? = nil) -> GraphQLRequest { + var documentBuilder = ModelBasedGraphQLDocumentBuilder(modelName: modelName, + operationType: .query, + primaryKeysOnly: false) + documentBuilder.add(decorator: DirectiveNameDecorator(type: .get)) + documentBuilder.add(decorator: ModelIdDecorator(id: id)) + documentBuilder.add(decorator: ConflictResolutionDecorator(graphQLType: .query)) + documentBuilder.add(decorator: AuthRuleDecorator(.query, authType: authType)) + let document = documentBuilder.build() + + let awsPluginOptions = AWSAPIPluginDataStoreOptions(authType: authType, modelName: modelName) + let requestOptions = GraphQLRequest.Options(pluginOptions: awsPluginOptions) + + return GraphQLRequest(document: document.stringValue, + variables: document.variables, + responseType: MutationSyncResult?.self, + decodePath: document.name, + options: requestOptions) + } + + public static func createMutation(of model: Model, + version: Int? = nil, + authType: AWSAuthorizationType? = nil) -> GraphQLRequest { + createMutation(of: model, modelSchema: model.schema, version: version, authType: authType) + } + + public static func updateMutation(of model: Model, + where filter: GraphQLFilter? = nil, + version: Int? = nil, + authType: AWSAuthorizationType? = nil) -> GraphQLRequest { + updateMutation(of: model, modelSchema: model.schema, where: filter, version: version, authType: authType) + } + + public static func subscription(to modelType: Model.Type, + subscriptionType: GraphQLSubscriptionType, + authType: AWSAuthorizationType? = nil) -> GraphQLRequest { + subscription(to: modelType.schema, subscriptionType: subscriptionType, authType: authType) + } + + public static func subscription(to modelType: Model.Type, + subscriptionType: GraphQLSubscriptionType, + claims: IdentityClaimsDictionary, + authType: AWSAuthorizationType? = nil) -> GraphQLRequest { + subscription(to: modelType.schema, subscriptionType: subscriptionType, claims: claims, authType: authType) + } + + public static func syncQuery(modelType: Model.Type, + where predicate: QueryPredicate? = nil, + limit: Int? = nil, + nextToken: String? = nil, + lastSync: Int64? = nil, + authType: AWSAuthorizationType? = nil) -> GraphQLRequest { + syncQuery(modelSchema: modelType.schema, + where: predicate, + limit: limit, + nextToken: nextToken, + lastSync: lastSync, + authType: authType) + } + + public static func createMutation(of model: Model, + modelSchema: ModelSchema, + version: Int? = nil, + authType: AWSAuthorizationType? = nil) -> GraphQLRequest { + createOrUpdateMutation(of: model, modelSchema: modelSchema, type: .create, version: version, authType: authType) + } + + public static func updateMutation(of model: Model, + modelSchema: ModelSchema, + where filter: GraphQLFilter? = nil, + version: Int? = nil, + authType: AWSAuthorizationType? = nil) -> GraphQLRequest { + createOrUpdateMutation(of: model, + modelSchema: modelSchema, + where: filter, + type: .update, + version: version, + authType: authType) + } + + public static func deleteMutation(of model: Model, + modelSchema: ModelSchema, + where filter: GraphQLFilter? = nil, + version: Int? = nil, + authType: AWSAuthorizationType? = nil) -> GraphQLRequest { + var documentBuilder = ModelBasedGraphQLDocumentBuilder(modelName: modelSchema.name, + operationType: .mutation, + primaryKeysOnly: false) + documentBuilder.add(decorator: DirectiveNameDecorator(type: .delete)) + documentBuilder.add(decorator: ModelIdDecorator(model: model, + schema: modelSchema)) + if let filter = filter { + documentBuilder.add(decorator: FilterDecorator(filter: filter)) + } + documentBuilder.add(decorator: ConflictResolutionDecorator(version: version, graphQLType: .mutation, primaryKeysOnly: false)) + documentBuilder.add(decorator: AuthRuleDecorator(.mutation, authType: authType)) + let document = documentBuilder.build() + + let awsPluginOptions = AWSAPIPluginDataStoreOptions(authType: authType, modelName: modelSchema.name) + let requestOptions = GraphQLRequest.Options(pluginOptions: awsPluginOptions) + + return GraphQLRequest(document: document.stringValue, + variables: document.variables, + responseType: MutationSyncResult.self, + decodePath: document.name, + options: requestOptions) + } + + public static func subscription(to modelSchema: ModelSchema, + subscriptionType: GraphQLSubscriptionType, + authType: AWSAuthorizationType? = nil) -> GraphQLRequest { + + var documentBuilder = ModelBasedGraphQLDocumentBuilder(modelSchema: modelSchema, + operationType: .subscription, + primaryKeysOnly: true) + documentBuilder.add(decorator: DirectiveNameDecorator(type: subscriptionType)) + documentBuilder.add(decorator: ConflictResolutionDecorator(graphQLType: .subscription, primaryKeysOnly: true)) + documentBuilder.add(decorator: AuthRuleDecorator(.subscription(subscriptionType, nil), authType: authType)) + let document = documentBuilder.build() + + let awsPluginOptions = AWSAPIPluginDataStoreOptions(authType: authType, modelName: modelSchema.name) + let requestOptions = GraphQLRequest.Options(pluginOptions: awsPluginOptions) + return GraphQLRequest(document: document.stringValue, + variables: document.variables, + responseType: MutationSyncResult.self, + decodePath: document.name, + options: requestOptions) + } + + public static func subscription(to modelSchema: ModelSchema, + subscriptionType: GraphQLSubscriptionType, + claims: IdentityClaimsDictionary, + authType: AWSAuthorizationType? = nil) -> GraphQLRequest { + + var documentBuilder = ModelBasedGraphQLDocumentBuilder(modelSchema: modelSchema, + operationType: .subscription, + primaryKeysOnly: true) + documentBuilder.add(decorator: DirectiveNameDecorator(type: subscriptionType)) + documentBuilder.add(decorator: ConflictResolutionDecorator(graphQLType: .subscription, primaryKeysOnly: true)) + documentBuilder.add(decorator: AuthRuleDecorator(.subscription(subscriptionType, claims), authType: authType)) + let document = documentBuilder.build() + + let awsPluginOptions = AWSAPIPluginDataStoreOptions( + authType: authType, + modelName: modelSchema.name + ) + let requestOptions = GraphQLRequest.Options(pluginOptions: awsPluginOptions) + return GraphQLRequest(document: document.stringValue, + variables: document.variables, + responseType: MutationSyncResult.self, + decodePath: document.name, + options: requestOptions) + } + + public static func syncQuery(modelSchema: ModelSchema, + where predicate: QueryPredicate? = nil, + limit: Int? = nil, + nextToken: String? = nil, + lastSync: Int64? = nil, + authType: AWSAuthorizationType? = nil) -> GraphQLRequest { + var documentBuilder = ModelBasedGraphQLDocumentBuilder(modelSchema: modelSchema, + operationType: .query, + primaryKeysOnly: true) + documentBuilder.add(decorator: DirectiveNameDecorator(type: .sync)) + if let predicate = optimizePredicate(predicate) { + documentBuilder.add(decorator: FilterDecorator(filter: predicate.graphQLFilter(for: modelSchema))) + } + documentBuilder.add(decorator: PaginationDecorator(limit: limit, nextToken: nextToken)) + documentBuilder.add(decorator: ConflictResolutionDecorator(lastSync: lastSync, graphQLType: .query, primaryKeysOnly: true)) + documentBuilder.add(decorator: AuthRuleDecorator(.query, authType: authType)) + let document = documentBuilder.build() + + let awsPluginOptions = AWSAPIPluginDataStoreOptions(authType: authType, modelName: modelSchema.name) + let requestOptions = GraphQLRequest.Options(pluginOptions: awsPluginOptions) + + return GraphQLRequest(document: document.stringValue, + variables: document.variables, + responseType: SyncQueryResult.self, + decodePath: document.name, + options: requestOptions) + } + + // MARK: Private methods + + private static func createOrUpdateMutation(of model: Model, + modelSchema: ModelSchema, + where filter: GraphQLFilter? = nil, + type: GraphQLMutationType, + version: Int? = nil, + authType: AWSAuthorizationType? = nil) -> GraphQLRequest { + var documentBuilder = ModelBasedGraphQLDocumentBuilder(modelName: modelSchema.name, + operationType: .mutation, + primaryKeysOnly: false) + documentBuilder.add(decorator: DirectiveNameDecorator(type: type)) + documentBuilder.add(decorator: ModelDecorator(model: model, mutationType: type)) + if let filter = filter { + documentBuilder.add(decorator: FilterDecorator(filter: filter)) + } + documentBuilder.add(decorator: ConflictResolutionDecorator(version: version, graphQLType: .mutation, primaryKeysOnly: false)) + documentBuilder.add(decorator: AuthRuleDecorator(.mutation, authType: authType)) + let document = documentBuilder.build() + + let awsPluginOptions = AWSAPIPluginDataStoreOptions(authType: authType, modelName: modelSchema.name) + let requestOptions = GraphQLRequest.Options(pluginOptions: awsPluginOptions) + + return GraphQLRequest(document: document.stringValue, + variables: document.variables, + responseType: MutationSyncResult.self, + decodePath: document.name, + options: requestOptions) + } + + /// This function tries to optimize provided `QueryPredicate` to perform a DynamoDB query instead of a scan. + /// Wrapping the predicate with a group AND enables AppSync to perform the optimization. + /// If the provided predicate is already a QueryPredicateGroup, this is not needed. + /// If the provided group is of type AND, the optimization will occur. + /// If the top level group is OR or NOT, the optimization is not possible anyway. + private static func optimizePredicate(_ predicate: QueryPredicate?) -> QueryPredicate? { + guard let predicate = predicate else { + return nil + } + if predicate as? QueryPredicateGroup != nil { + return predicate + } else if let predicate = predicate as? QueryPredicateConstant, + predicate == .all { + return predicate + } + return QueryPredicateGroup(type: .and, predicates: [predicate]) + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/GraphQLRequest/GraphQLRequest+Model.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/GraphQLRequest/GraphQLRequest+Model.swift new file mode 100644 index 0000000000..ccc8148f6d --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/GraphQLRequest/GraphQLRequest+Model.swift @@ -0,0 +1,381 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public typealias IncludedAssociations = (ModelPath) -> [PropertyContainerPath] + +// MARK: - Protocol + +/// Protocol that represents the integration between `GraphQLRequest` and `Model`. +/// +/// The methods defined here are used to build a valid `GraphQLRequest` from types +/// conforming to `Model`. +protocol ModelGraphQLRequestFactory { + + // MARK: Query + + /// Creates a `GraphQLRequest` that represents a query that expects multiple values as a result. + /// The request will be created with the correct document based on the `ModelSchema` and + /// variables based on the the predicate. + /// + /// - Parameters: + /// - modelType: the metatype of the model + /// - predicate: an optional predicate containing the criteria for the query + /// - includes: the closure to determine which associations should be included in the selection set + /// - limit: the maximum number of results to be retrieved. The result list may be less than the `limit` + /// - Returns: a valid `GraphQLRequest` instance + /// + /// - seealso: `GraphQLQuery`, `GraphQLQueryType.list` + static func list(_ modelType: M.Type, + where predicate: QueryPredicate?, + includes: IncludedAssociations, + limit: Int?, + authMode: AWSAuthorizationType?) -> GraphQLRequest> + + /// Creates a `GraphQLRequest` that represents a query that expects a single value as a result. + /// The request will be created with the correct correct document based on the `ModelSchema` and + /// variables based on given `id`. + /// + /// - Parameters: + /// - modelType: the metatype of the model + /// - id: the model identifier + /// - includes: the closure to determine which associations should be included in the selection set + /// - Returns: a valid `GraphQLRequest` instance + /// + /// - seealso: `GraphQLQuery`, `GraphQLQueryType.get` + static func get(_ modelType: M.Type, + byId id: String, + includes: IncludedAssociations, + authMode: AWSAuthorizationType?) -> GraphQLRequest + + static func get(_ modelType: M.Type, + byIdentifier id: String, + includes: IncludedAssociations, + authMode: AWSAuthorizationType?) -> GraphQLRequest + where M: ModelIdentifiable, M.IdentifierFormat == ModelIdentifierFormat.Default + + static func get(_ modelType: M.Type, + byIdentifier id: ModelIdentifier, + includes: IncludedAssociations, + authMode: AWSAuthorizationType?) -> GraphQLRequest + where M: ModelIdentifiable + + // MARK: Mutation + + /// Creates a `GraphQLRequest` that represents a mutation of a given `type` for a `model` instance. + /// + /// - Parameters: + /// - model: the model instance populated with values + /// - modelSchema: the model schema of the model + /// - predicate: a predicate passed as the condition to apply the mutation + /// - type: the mutation type, either `.create`, `.update`, or `.delete` + /// - Returns: a valid `GraphQLRequest` instance + static func mutation(of model: M, + modelSchema: ModelSchema, + where predicate: QueryPredicate?, + includes: IncludedAssociations, + type: GraphQLMutationType, + authMode: AWSAuthorizationType?) -> GraphQLRequest + + /// Creates a `GraphQLRequest` that represents a create mutation + /// for a given `model` instance. + /// + /// - Parameters: + /// - model: the model instance populated with values + /// - Returns: a valid `GraphQLRequest` instance + /// - seealso: `GraphQLRequest.mutation(of:where:type:)` + static func create(_ model: M, + includes: IncludedAssociations, + authMode: AWSAuthorizationType?) -> GraphQLRequest + + /// Creates a `GraphQLRequest` that represents an update mutation + /// for a given `model` instance. + /// + /// - Parameters: + /// - model: the model instance populated with values + /// - predicate: a predicate passed as the condition to apply the mutation + /// - Returns: a valid `GraphQLRequest` instance + /// - seealso: `GraphQLRequest.mutation(of:where:type:)` + static func update(_ model: M, + where predicate: QueryPredicate?, + includes: IncludedAssociations, + authMode: AWSAuthorizationType?) -> GraphQLRequest + + /// Creates a `GraphQLRequest` that represents a delete mutation + /// for a given `model` instance. + /// + /// - Parameters: + /// - model: the model instance populated with values + /// - predicate: a predicate passed as the condition to apply the mutation + /// - Returns: a valid `GraphQLRequest` instance + /// - seealso: `GraphQLRequest.mutation(of:where:type:)` + static func delete(_ model: M, + where predicate: QueryPredicate?, + includes: IncludedAssociations, + authMode: AWSAuthorizationType?) -> GraphQLRequest + + // MARK: Subscription + + /// Creates a `GraphQLRequest` that represents a subscription of a given `type` for a `model` type. + /// The request will be created with the correct document based on the `ModelSchema`. + /// + /// - Parameters: + /// - modelType: the metatype of the model + /// - type: the subscription type, either `.onCreate`, `.onUpdate` or `.onDelete` + /// - includes: the closure to determine which associations should be included in the selection set + /// - Returns: a valid `GraphQLRequest` instance + /// + /// - seealso: `GraphQLSubscription`, `GraphQLSubscriptionType` + static func subscription(of: M.Type, + type: GraphQLSubscriptionType, + includes: IncludedAssociations, + authMode: AWSAuthorizationType?) -> GraphQLRequest +} + +// MARK: - Extension + +/// Extension that provides an integration layer between `Model`, +/// `GraphQLDocument` and `GraphQLRequest` by conforming to `ModelGraphQLRequestFactory`. +/// +/// This is particularly useful when using the GraphQL API to interact +/// with static types that conform to the `Model` protocol. +extension GraphQLRequest: ModelGraphQLRequestFactory { + private static func modelSchema(for model: M) -> ModelSchema { + let modelType = ModelRegistry.modelType(from: model.modelName) ?? Swift.type(of: model) + return modelType.schema + } + + public static func create( + _ model: M, + includes: IncludedAssociations = { _ in [] }, + authMode: AWSAuthorizationType? = nil) -> GraphQLRequest { + return create( + model, + modelSchema: modelSchema(for: model), + includes: includes, + authMode: authMode) + } + + public static func update(_ model: M, + where predicate: QueryPredicate? = nil, + includes: IncludedAssociations = { _ in [] }, + authMode: AWSAuthorizationType? = nil) -> GraphQLRequest { + return update( + model, + modelSchema: modelSchema(for: model), + where: predicate, + includes: includes, + authMode: authMode) + } + + public static func delete(_ model: M, + where predicate: QueryPredicate? = nil, + includes: IncludedAssociations = { _ in [] }, + authMode: AWSAuthorizationType? = nil) -> GraphQLRequest { + return delete( + model, + modelSchema: modelSchema(for: model), + where: predicate, + includes: includes, + authMode: authMode) + } + + public static func create(_ model: M, + modelSchema: ModelSchema, + includes: IncludedAssociations = { _ in [] }, + authMode: AWSAuthorizationType? = nil) -> GraphQLRequest { + return mutation(of: model, + modelSchema: modelSchema, + includes: includes, + type: .create, + authMode: authMode) + } + + public static func update(_ model: M, + modelSchema: ModelSchema, + where predicate: QueryPredicate? = nil, + includes: IncludedAssociations = { _ in [] }, + authMode: AWSAuthorizationType? = nil) -> GraphQLRequest { + return mutation(of: model, + modelSchema: modelSchema, + where: predicate, + includes: includes, + type: .update, + authMode: authMode) + } + + public static func delete(_ model: M, + modelSchema: ModelSchema, + where predicate: QueryPredicate? = nil, + includes: IncludedAssociations = { _ in [] }, + authMode: AWSAuthorizationType? = nil) -> GraphQLRequest { + return mutation(of: model, + modelSchema: modelSchema, + where: predicate, + includes: includes, + type: .delete, + authMode: authMode) + } + + public static func mutation(of model: M, + where predicate: QueryPredicate? = nil, + includes: IncludedAssociations = { _ in [] }, + type: GraphQLMutationType, + authMode: AWSAuthorizationType? = nil) -> GraphQLRequest { + mutation(of: model, + modelSchema: model.schema, + where: predicate, + includes: includes, + type: type, + authMode: authMode) + } + + public static func mutation(of model: M, + modelSchema: ModelSchema, + where predicate: QueryPredicate? = nil, + includes: IncludedAssociations = { _ in [] }, + type: GraphQLMutationType, + authMode: AWSAuthorizationType? = nil) -> GraphQLRequest { + var documentBuilder = ModelBasedGraphQLDocumentBuilder(modelSchema: modelSchema, + operationType: .mutation) + documentBuilder.add(decorator: DirectiveNameDecorator(type: type)) + + if let modelPath = M.rootPath as? ModelPath { + let associations = includes(modelPath) + documentBuilder.add(decorator: IncludeAssociationDecorator(associations)) + } + + switch type { + case .create: + documentBuilder.add(decorator: ModelDecorator(model: model, mutationType: type)) + case .delete: + documentBuilder.add(decorator: ModelIdDecorator(model: model, + schema: modelSchema)) + if let predicate = predicate { + documentBuilder.add(decorator: FilterDecorator(filter: predicate.graphQLFilter(for: modelSchema))) + } + case .update: + documentBuilder.add(decorator: ModelDecorator(model: model, mutationType: type)) + if let predicate = predicate { + documentBuilder.add(decorator: FilterDecorator(filter: predicate.graphQLFilter(for: modelSchema))) + } + } + + let document = documentBuilder.build() + return GraphQLRequest(document: document.stringValue, + variables: document.variables, + responseType: M.self, + decodePath: document.name, + authMode: authMode) + } + + public static func get(_ modelType: M.Type, + byId id: String, + includes: IncludedAssociations = { _ in [] }, + authMode: AWSAuthorizationType? = nil) -> GraphQLRequest { + var documentBuilder = ModelBasedGraphQLDocumentBuilder(modelSchema: modelType.schema, + operationType: .query) + documentBuilder.add(decorator: DirectiveNameDecorator(type: .get)) + + if let modelPath = modelType.rootPath as? ModelPath { + let associations = includes(modelPath) + documentBuilder.add(decorator: IncludeAssociationDecorator(associations)) + } + + documentBuilder.add(decorator: ModelIdDecorator(id: id)) + let document = documentBuilder.build() + + return GraphQLRequest(document: document.stringValue, + variables: document.variables, + responseType: M?.self, + decodePath: document.name, + authMode: authMode) + } + + public static func get(_ modelType: M.Type, + byIdentifier id: String, + includes: IncludedAssociations = { _ in [] }, + authMode: AWSAuthorizationType? = nil) -> GraphQLRequest + where M: ModelIdentifiable, M.IdentifierFormat == ModelIdentifierFormat.Default { + return .get(modelType, byId: id, includes: includes, authMode: authMode) + } + + public static func get(_ modelType: M.Type, + byIdentifier id: ModelIdentifier, + includes: IncludedAssociations = { _ in [] }, + authMode: AWSAuthorizationType? = nil) -> GraphQLRequest + where M: ModelIdentifiable { + var documentBuilder = ModelBasedGraphQLDocumentBuilder(modelSchema: modelType.schema, + operationType: .query) + documentBuilder.add(decorator: DirectiveNameDecorator(type: .get)) + + if let modelPath = modelType.rootPath as? ModelPath { + let associations = includes(modelPath) + documentBuilder.add(decorator: IncludeAssociationDecorator(associations)) + } + documentBuilder.add(decorator: ModelIdDecorator(identifierFields: id.fields)) + let document = documentBuilder.build() + + return GraphQLRequest(document: document.stringValue, + variables: document.variables, + responseType: M?.self, + decodePath: document.name, + authMode: authMode) + } + + public static func list(_ modelType: M.Type, + where predicate: QueryPredicate? = nil, + includes: IncludedAssociations = { _ in [] }, + limit: Int? = nil, + authMode: AWSAuthorizationType? = nil) -> GraphQLRequest> { + let primaryKeysOnly = (M.rootPath != nil) ? true : false + var documentBuilder = ModelBasedGraphQLDocumentBuilder(modelSchema: modelType.schema, + operationType: .query) + documentBuilder.add(decorator: DirectiveNameDecorator(type: .list)) + + if let modelPath = modelType.rootPath as? ModelPath { + let associations = includes(modelPath) + documentBuilder.add(decorator: IncludeAssociationDecorator(associations)) + } + + if let predicate = predicate { + documentBuilder.add(decorator: FilterDecorator(filter: predicate.graphQLFilter(for: modelType.schema))) + } + + documentBuilder.add(decorator: PaginationDecorator(limit: limit)) + let document = documentBuilder.build() + + return GraphQLRequest>(document: document.stringValue, + variables: document.variables, + responseType: List.self, + decodePath: document.name, + authMode: authMode) + } + + public static func subscription(of modelType: M.Type, + type: GraphQLSubscriptionType, + includes: IncludedAssociations = { _ in [] }, + authMode: AWSAuthorizationType? = nil) -> GraphQLRequest { + var documentBuilder = ModelBasedGraphQLDocumentBuilder(modelSchema: modelType.schema, + operationType: .subscription) + documentBuilder.add(decorator: DirectiveNameDecorator(type: type)) + + if let modelPath = modelType.rootPath as? ModelPath { + let associations = includes(modelPath) + documentBuilder.add(decorator: IncludeAssociationDecorator(associations)) + } + + let document = documentBuilder.build() + + return GraphQLRequest(document: document.stringValue, + variables: document.variables, + responseType: modelType, + decodePath: document.name, + authMode: authMode) + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/Support/AuthRule+Extension.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/Support/AuthRule+Extension.swift new file mode 100644 index 0000000000..fa7611bb4b --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/Support/AuthRule+Extension.swift @@ -0,0 +1,112 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + + +extension AuthRuleProvider { + + /// Returns corresponding `AWSAuthorizationType` for each `AuthRuleProvider` + /// - Returns: AWS authorization type + public func toAWSAuthorizationType() -> AWSAuthorizationType { + var authType: AWSAuthorizationType + switch self { + case .apiKey: + authType = .apiKey + case .oidc: + authType = .openIDConnect + case .iam: + authType = .awsIAM + case .userPools: + authType = .amazonCognitoUserPools + case .function: + authType = .function + } + return authType + } +} + +extension AuthRule { + func getOwnerFieldOrDefault() -> String { + guard let ownerField = ownerField else { + return "owner" + } + return ownerField + } + + func isReadRestrictingStaticGroup() -> Bool { + return allow == .groups && + !groups.isEmpty && + getModelOperationsOrDefault().contains(.read) + } + + func isReadRestrictingOwner() -> Bool { + return allow == .owner && + getModelOperationsOrDefault().contains(.read) + } + + func getModelOperationsOrDefault() -> [ModelOperation] { + return operations.isEmpty ? [.create, .update, .delete, .read] : operations + } + + public func identityClaimOrDefault() -> String { + guard let identityClaim = self.identityClaim else { + return "username" + } + if identityClaim == "cognito:username" { + return "username" + } + return identityClaim + } +} + +extension Array where Element == AuthRule { + + // This function returns a map of all of the read restricting static groups defined for your app's schema + // Example 1: Single group with implicit read restriction + // {allow: groups, groups: ["Admins"]} + // Returns: + // { + // "cognito:groups" : ["Admins"] + // } + // + // Example 2: Multiple groups with only one having read restriction + // {allow: groups, groups: ["Admins"], operations: [read, update, delete], groupClaim: "https://app1.com/claims/groups"} + // {allow: groups, groups: ["Users"], operations: [create]} + // Returns: + // { + // "https://app1.com/claims/groups" : ["Admins"] + // } + // + // Example 3: Multiple groups with multiple group claims + // {allow: groups, provider: oidc, groups: ["Admins"], groupClaim: "https://app1.com/claims/groups"} + // {allow: groups, provider: oidc, groups: ["Moderators", "Editors"], groupClaim: "https://app2.com/claims/groups"} + // Returns: + // { + // "https://app1.com/claims/groups" : ["Admins"], + // "https://app2.com/claims/groups" : ["Moderators", "Editors"] + // } + // + func groupClaimsToReadRestrictingStaticGroups() -> [String: Set] { + var readRestrictingStaticGroupsMap = [String: Set]() + let readRestrictingGroupRules = filter { $0.isReadRestrictingStaticGroup() } + for groupRule in readRestrictingGroupRules { + let groupClaim = groupRule.groupClaim ?? "cognito:groups" + groupRule.groups.forEach { group in + if var existingSet = readRestrictingStaticGroupsMap[groupClaim] { + existingSet.insert(group) + readRestrictingStaticGroupsMap[groupClaim] = existingSet + } else { + readRestrictingStaticGroupsMap[groupClaim] = [group] + } + } + } + return readRestrictingStaticGroupsMap + } + + func readRestrictingOwnerRules() -> [AuthRule] { + return filter { $0.isReadRestrictingOwner() } + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/Support/GraphQLDocumentInput.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/Support/GraphQLDocumentInput.swift new file mode 100644 index 0000000000..4baef5af73 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/Support/GraphQLDocumentInput.swift @@ -0,0 +1,21 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Contains the `type` of the GraphQL document input parameter as a string value and `GraphQLDocumentInputValue` +public struct GraphQLDocumentInput { + + public var type: String + + public var value: GraphQLDocumentInputValue + + public init(type: String, value: GraphQLDocumentInputValue) { + self.type = type + self.value = value + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/Support/GraphQLDocumentInputValue.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/Support/GraphQLDocumentInputValue.swift new file mode 100644 index 0000000000..ea33ad9334 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/Support/GraphQLDocumentInputValue.swift @@ -0,0 +1,88 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// A container to hold either an object or a value, useful for storing document inputs and allowing manipulation at +/// the first level of the object +public enum GraphQLDocumentInputValue { + case inline(GraphQLDocumentValueRepresentable) + case scalar(GraphQLDocumentValueRepresentable) + case object([String: Any?]) + + public func isInline() -> Bool { + if case .inline = self { + return true + } + return false + } +} + +public protocol GraphQLDocumentValueRepresentable { + var graphQLDocumentValue: String { get } + var graphQLInlineValue: String { get } +} + +extension Int: GraphQLDocumentValueRepresentable { + public var graphQLDocumentValue: String { + return "\(self)" + } + + public var graphQLInlineValue: String { + return "\(self)" + } +} + +extension Int64: GraphQLDocumentValueRepresentable { + public var graphQLDocumentValue: String { + return "\(self)" + } + + public var graphQLInlineValue: String { + return "\(self)" + } +} + +extension String: GraphQLDocumentValueRepresentable { + public var graphQLDocumentValue: String { + return self + } + + public var graphQLInlineValue: String { + return "\"\(self)\"" + } +} + +extension Bool: GraphQLDocumentValueRepresentable { + public var graphQLDocumentValue: String { + return "\(self)" + } + + public var graphQLInlineValue: String { + return "\(self)" + } +} + +extension Decimal: GraphQLDocumentValueRepresentable { + public var graphQLDocumentValue: String { + return "\(self)" + } + + public var graphQLInlineValue: String { + return "\(self)" + } +} + +extension Double: GraphQLDocumentValueRepresentable { + public var graphQLDocumentValue: String { + return "\(self)" + } + + public var graphQLInlineValue: String { + return "\(self)" + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/Support/Model+GraphQL.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/Support/Model+GraphQL.swift new file mode 100644 index 0000000000..f86a47cd7a --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/Support/Model+GraphQL.swift @@ -0,0 +1,246 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +typealias GraphQLInput = [String: Any?] + +/// Extension that adds GraphQL specific utilities to concret types of `Model`. +extension Model { + + /// Returns an array of model fields sorted by predefined rules (see ModelSchema+sortedFields) + /// and filtered according the following criteria: + /// - fields are not read-only + /// - fields exist on the model + private func fieldsForMutation(_ modelSchema: ModelSchema) -> [(ModelField, Any?)] { + modelSchema.sortedFields.compactMap { field in + guard !field.isReadOnly, + let fieldValue = getFieldValue(for: field.name, + modelSchema: modelSchema) else { + return nil + } + return (field, fieldValue) + } + } + + /// Returns the input used for mutations + /// - Parameter modelSchema: model's schema + /// - Returns: A key-value map of the GraphQL mutation input + func graphQLInputForMutation(_ modelSchema: ModelSchema, mutationType: GraphQLMutationType) -> GraphQLInput { + var input: GraphQLInput = [:] + + // filter existing non-readonly fields + let fields = fieldsForMutation(modelSchema) + + for (modelField, modelFieldValue) in fields { + let name = modelField.name + + guard let value = modelFieldValue else { + // Scenario: When there is no model field value, set `nil` for removal of values or deassociation. + // 1. It is unnessessary to set `nil` values for create mutations. + if mutationType == .create { + continue + } + // 2. On update/delete mutations, set the current model's associated model's primary key fields (which + // are the targetNames) to `nil`. on the current model. + if case .model = modelField.type { // add it for "belongs-to" and "has-one" associations. + let fieldNames = getFieldNameForAssociatedModels(modelField: modelField) + for fieldName in fieldNames { + // Only set to `nil` if it has not been set already. For hasOne relationships, where the + // target name of the associated model is explicitly on this model as a field property, we + // cannot guarantee which field is processed first, thus if there is a value for the explicit + // field and was already set, don't overwrite it. + if input[fieldName] == nil { + // we always set it to `nil` to account for the update mutation use cases where the caller + // may be de-associating the model from the associated model, which is why the `nil` is + // required in input variables to persist the removal the association. + input.updateValue(nil, forKey: fieldName) + } + } + } else if case .collection = modelField.type { // skip all "has-many" + continue + } else { + // 3. Set field values to `nil` for removal of values. + input.updateValue(nil, forKey: name) + } + + continue + } + + switch modelField.type { + case .collection: + // we don't currently support this use case + continue + case .date, .dateTime, .time: + if let date = value as? TemporalSpec { + input[name] = date.iso8601String + } else { + input[name] = value + } + case .enum: + input[name] = (value as? EnumPersistable)?.rawValue + case .model(let associateModelName): + // get the associated model target names and their values + let associatedModelIds = associatedModelIdentifierFields(fromModelValue: value, + field: modelField, + associatedModelName: associateModelName, + mutationType: mutationType) + for (fieldName, fieldValue) in associatedModelIds { + input.updateValue(fieldValue, forKey: fieldName) + } + case .embedded, .embeddedCollection: + if let encodable = value as? Encodable { + let jsonEncoder = JSONEncoder(dateEncodingStrategy: ModelDateFormatting.encodingStrategy) + do { + let data = try jsonEncoder.encode(encodable.eraseToAnyEncodable()) + input[name] = try JSONSerialization.jsonObject(with: data) + } catch { + return Fatal.preconditionFailure("Could not turn into json object from \(value)") + } + } + case .string, .int, .double, .timestamp, .bool: + input[name] = value + } + } + return input + } + + /// Retrieve the custom primary key's value used for the GraphQL input. + /// Only a subset of data types are applicable as custom indexes such as + /// `date`, `dateTime`, `time`, `enum`, `string`, `double`, and `int`. + func graphQLInputForPrimaryKey(modelFieldName: ModelFieldName, + modelSchema: ModelSchema) -> String? { + + guard let modelField = modelSchema.field(withName: modelFieldName) else { + return nil + } + + let fieldValueOptional = getFieldValue(for: modelField.name, modelSchema: modelSchema) + + guard let fieldValue = fieldValueOptional else { + return nil + } + + // swiftlint:disable:next syntactic_sugar + guard case .some(Optional.some(let value)) = fieldValue else { + return nil + } + + switch modelField.type { + case .date, .dateTime, .time: + if let date = value as? TemporalSpec { + return date.iso8601String + } else { + return nil + } + case .enum: + return (value as? EnumPersistable)?.rawValue + case .model, .embedded, .embeddedCollection: + return nil + case .string, .double, .int: + return String(describing: value) + default: + return nil + } + } + + /// Given a model value, its schema and a model field with associations returns + /// an array of key-value pairs of the associated model identifiers and their values. + /// - Parameters: + /// - value: model value + /// - field: model field + /// - modelSchema: model schema + /// - Returns: an array of key-value pairs where `key` is the field name + /// and `value` its value in the associated model + private func associatedModelIdentifierFields(fromModelValue value: Any, + field: ModelField, + associatedModelName: String, + mutationType: GraphQLMutationType) -> [(String, Persistable?)] { + guard let associateModelSchema = ModelRegistry.modelSchema(from: associatedModelName) else { + preconditionFailure("Associated model \(associatedModelName) not found.") + } + + let fieldNames = getFieldNameForAssociatedModels(modelField: field) + var values = getModelIdentifierValues(from: value, modelSchema: associateModelSchema) + if values.count != fieldNames.count { + values = [Persistable?](repeating: nil, count: fieldNames.count) + } + + let associatedModelIdentifiers = zip(fieldNames, values).map { ($0.0, $0.1)} + if mutationType != .update { + return associatedModelIdentifiers.compactMap { key, value in + value.map { (key, $0) } + } + } else { + return associatedModelIdentifiers + } + } + + /// Given a model and its schema, returns the values of its identifier (primary key). + /// The return value is an array as models can have a composite identifier. + /// - Parameters: + /// - value: model value + /// - modelSchema: model's schema + /// - Returns: array of values of its primary key + private func getModelIdentifierValues( + from value: Any, + modelSchema: ModelSchema + ) -> [Persistable?] { + if let modelValue = value as? Model { + return modelValue.identifier(schema: modelSchema).values + } else if let optionalModel = value as? Model?, + let modelValue = optionalModel { + return modelValue.identifier(schema: modelSchema).values + } else if let lazyModel = value as? (any _LazyReferenceValue) { + switch lazyModel._state { + case .notLoaded(let identifiers): + if let identifiers = identifiers { + return identifiers.map { identifier in + return identifier.value + } + } + case .loaded(let model): + if let model = model { + return model.identifier(schema: modelSchema).values + } + } + } else if let value = value as? [String: JSONValue] { + var primaryKeyValues = [Persistable]() + for field in modelSchema.primaryKey.fields { + if case .string(let primaryKeyValue) = value[field.name] { + primaryKeyValues.append(primaryKeyValue) + } + } + return primaryKeyValues + } + return [] + } + + /// Retrieves the GraphQL field name that associates the current model with the target model. + /// By default, this is the current model + the associated Model + "Id", For example "comment" + "Post" + "Id" + /// This information is also stored in the schema as `targetName` which is codegenerated to be the same as the + /// default or an explicit field specified by the developer. + private func getFieldNameForAssociatedModels(modelField: ModelField) -> [String] { + let defaultFieldName = modelName.camelCased() + modelField.name.pascalCased() + "Id" + if case let .belongsTo(_, targetNames) = modelField.association, !targetNames.isEmpty { + return targetNames + } else if case let .hasOne(_, targetNames) = modelField.association, + !targetNames.isEmpty { + return targetNames + } + + return [defaultFieldName] + } + + private func getFieldValue(for modelFieldName: String, modelSchema: ModelSchema) -> Any?? { + if let jsonModel = self as? JSONValueHolder { + return jsonModel.jsonValue(for: modelFieldName, modelSchema: modelSchema) ?? nil + } else { + return self[modelFieldName] ?? self["_\(modelFieldName)"] ?? nil + } + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/Support/ModelField+GraphQL.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/Support/ModelField+GraphQL.swift new file mode 100644 index 0000000000..698c57059c --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/Support/ModelField+GraphQL.swift @@ -0,0 +1,20 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Extension that adds GraphQL specific utilities to `ModelField`. +extension ModelField { + + /// The GraphQL name of the field. + var graphQLName: String { + if isAssociationOwner, case let .belongsTo(_, targetNames) = association { + return targetNames.first ?? name.pascalCased() + "Id" + } + return name + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/Support/ModelSchema+GraphQL.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/Support/ModelSchema+GraphQL.swift new file mode 100644 index 0000000000..813e95bfa9 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/Support/ModelSchema+GraphQL.swift @@ -0,0 +1,56 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Extension that adds GraphQL specific utilities to `ModelSchema`. +extension ModelSchema { + + /// The GraphQL directive name translated from a GraphQL query operation and model schema data + func graphQLName(queryType: GraphQLQueryType) -> String { + let graphQLName: String + switch queryType { + case .list: + if let listPluralName = listPluralName { + graphQLName = queryType.rawValue + listPluralName + } else if let pluralName = pluralName { + graphQLName = queryType.rawValue + pluralName + } else { + graphQLName = (queryType.rawValue + name).pluralize() + } + case .sync: + if let syncPluralName = syncPluralName { + graphQLName = queryType.rawValue + syncPluralName + } else if let pluralName = pluralName { + graphQLName = queryType.rawValue + pluralName + } else { + graphQLName = (queryType.rawValue + name).pluralize() + } + case .get: + graphQLName = queryType.rawValue + name + } + + return graphQLName + } + + /// The GraphQL directive name translated from a GraphQL subsription operation and model schema name + func graphQLName(subscriptionType: GraphQLSubscriptionType) -> String { + subscriptionType.rawValue + name + } + + /// The GraphQL directive name translated from a GraphQL mutation operation and model schema name + func graphQLName(mutationType: GraphQLMutationType) -> String { + mutationType.rawValue + name + } + + /// The list of fields formatted for GraphQL usage. + var graphQLFields: [ModelField] { + sortedFields.filter { field in + !field.hasAssociation || field._isBelongsToOrHasOne + } + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/Support/QueryPredicate+GraphQL.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/Support/QueryPredicate+GraphQL.swift new file mode 100644 index 0000000000..2e7803e489 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/Support/QueryPredicate+GraphQL.swift @@ -0,0 +1,242 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public typealias GraphQLFilter = [String: Any] + +protocol GraphQLFilterConvertible { + func graphQLFilter(for modelSchema: ModelSchema?) -> GraphQLFilter +} + +// Convert QueryPredicate to GraphQLFilter JSON, and GraphQLFilter JSON to GraphQLFilter +public struct GraphQLFilterConverter { + + /// Serialize the translated GraphQL query variable object to JSON string. + /// - Warning: Although this has `public` access, it is intended for internal use and should not be used directly + /// by host applications. The behavior of this may change without warning. + public static func toJSON(_ queryPredicate: QueryPredicate, + modelSchema: ModelSchema, + options: JSONSerialization.WritingOptions = []) throws -> String { + let graphQLFilterData = + try JSONSerialization.data(withJSONObject: queryPredicate.graphQLFilter(for: modelSchema), + options: options) + + guard let serializedString = String(data: graphQLFilterData, encoding: .utf8) else { + return Fatal.preconditionFailure(""" + Could not initialize String from the GraphQL representation of QueryPredicate: + \(String(describing: graphQLFilterData)) + """) + } + + return serializedString + } + + @available(*, deprecated, message: """ + Use `toJSON(_:modelSchema:options)` instead. See https://github.com/aws-amplify/amplify-ios/pull/965 for more details. + """) + /// Serialize the translated GraphQL query variable object to JSON string. + public static func toJSON(_ queryPredicate: QueryPredicate, + options: JSONSerialization.WritingOptions = []) throws -> String { + let graphQLFilterData = try JSONSerialization.data(withJSONObject: queryPredicate.graphQLFilter, + options: options) + + guard let serializedString = String(data: graphQLFilterData, encoding: .utf8) else { + return Fatal.preconditionFailure(""" + Could not initialize String from the GraphQL representation of QueryPredicate: + \(String(describing: graphQLFilterData)) + """) + } + + return serializedString + } + + /// Deserialize the JSON string converted with `GraphQLFilterConverter.toJSON()` to `GraphQLFilter` + public static func fromJSON(_ value: String) throws -> GraphQLFilter { + let data = Data(value.utf8) + guard let filter = try JSONSerialization.jsonObject(with: data) as? GraphQLFilter else { + return Fatal.preconditionFailure("Could not serialize to GraphQLFilter from: \(self))") + } + + return filter + } +} + +/// Extension to translate a `QueryPredicate` into a GraphQL query variables object +extension QueryPredicate { + + /// - Warning: Although this has `public` access, it is intended for internal use and should not be used directly + /// by host applications. The behavior of this may change without warning. + public func graphQLFilter(for modelSchema: ModelSchema?) -> GraphQLFilter { + if let operation = self as? QueryPredicateOperation { + return operation.graphQLFilter(for: modelSchema) + } else if let group = self as? QueryPredicateGroup { + return group.graphQLFilter(for: modelSchema) + } else if let constant = self as? QueryPredicateConstant { + return constant.graphQLFilter(for: modelSchema) + } + + return Fatal.preconditionFailure( + "Could not find QueryPredicateOperation or QueryPredicateGroup for \(String(describing: self))") + } + + @available(*, deprecated, message: """ + Use `graphQLFilter(for:)` instead. See https://github.com/aws-amplify/amplify-ios/pull/965 for more details. + """) + public var graphQLFilter: GraphQLFilter { + if let operation = self as? QueryPredicateOperation { + return operation.graphQLFilter(for: nil) + } else if let group = self as? QueryPredicateGroup { + return group.graphQLFilter(for: nil) + } + + return Fatal.preconditionFailure( + "Could not find QueryPredicateOperation or QueryPredicateGroup for \(String(describing: self))") + } +} + +extension QueryPredicateConstant: GraphQLFilterConvertible { + func graphQLFilter(for modelSchema: ModelSchema?) -> GraphQLFilter { + if self == .all { + return [:] + } + return Fatal.preconditionFailure("Could not find QueryPredicateConstant \(self)") + } +} + +extension QueryPredicateOperation: GraphQLFilterConvertible { + + func graphQLFilter(for modelSchema: ModelSchema?) -> GraphQLFilter { + let filterValue = [self.operator.graphQLOperator: self.operator.value] + guard let modelSchema = modelSchema else { + return [field: filterValue] + } + return [columnName(modelSchema): filterValue] + } + + func columnName(_ modelSchema: ModelSchema) -> String { + guard let modelField = modelSchema.field(withName: field) else { + return field + } + let defaultFieldName = modelSchema.name.camelCased() + field.pascalCased() + "Id" + switch modelField.association { + case .belongsTo(_, let targetNames): + guard targetNames.count == 1 else { + preconditionFailure("QueryPredicate not supported on associated field with composite key: \(field)") + } + let targetName = targetNames.first ?? defaultFieldName + return targetName + case .hasOne(_, let targetNames): + guard targetNames.count == 1 else { + preconditionFailure("QueryPredicate not supported on associated field with composite key: \(field)") + } + let targetName = targetNames.first ?? defaultFieldName + return targetName + default: + return field + } + } +} + +extension QueryPredicateGroup: GraphQLFilterConvertible { + + func graphQLFilter(for modelSchema: ModelSchema?) -> GraphQLFilter { + let logicalOperator = type.rawValue + switch type { + case .and, .or: + var graphQLPredicateOperation = [logicalOperator: [Any]()] + predicates.forEach { predicate in + graphQLPredicateOperation[logicalOperator]?.append(predicate.graphQLFilter(for: modelSchema)) + } + return graphQLPredicateOperation + case .not: + if let predicate = predicates.first { + return [logicalOperator: predicate.graphQLFilter(for: modelSchema)] + } else { + return Fatal.preconditionFailure("Missing predicate for \(String(describing: self)) with type: \(type)") + } + } + } +} + +extension QueryOperator { + var graphQLOperator: String { + switch self { + case .notEqual: + return "ne" + case .equals: + return "eq" + case .lessOrEqual: + return "le" + case .lessThan: + return "lt" + case .greaterOrEqual: + return "ge" + case .greaterThan: + return "gt" + case .contains: + return "contains" + case .between: + return "between" + case .beginsWith: + return "beginsWith" + case .notContains: + return "notContains" + } + } + + var value: Any? { + switch self { + case .notEqual(let value), + .equals(let value): + if let value = value { + return value.graphQLValue() + } + + return nil + case .lessOrEqual(let value), + .lessThan(let value), + .greaterOrEqual(let value), + .greaterThan(let value): + return value.graphQLValue() + case .contains(let value): + return value + case .between(let start, let end): + return [start.graphQLValue(), end.graphQLValue()] + case .beginsWith(let value): + return value + case .notContains(let value): + return value + } + } +} + +extension Persistable { + internal func graphQLValue() -> Any { + switch self { + case is Bool: + return self + case let double as Double: + return Decimal(double) + case is Int: + return self + case is String: + return self + case let temporalDate as Temporal.Date: + return temporalDate.iso8601String + case let temporalDateTime as Temporal.DateTime: + return temporalDateTime.iso8601String + case let temporalTime as Temporal.Time: + return temporalTime.iso8601String + default: + return Fatal.preconditionFailure(""" + Value \(String(describing: self)) of type \(String(describing: type(of: self))) \ + is not a compatible type. + """) + } + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/Support/SelectionSet.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/Support/SelectionSet.swift new file mode 100644 index 0000000000..f78e0e131c --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/Support/SelectionSet.swift @@ -0,0 +1,200 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public typealias SelectionSet = Tree + +public enum SelectionSetFieldType { + case pagination + case model + case embedded + case value + case collection +} + +public class SelectionSetField { + + static var typename: SelectionSetField { + .init(name: "__typename", fieldType: .value) + } + + var name: String? + var fieldType: SelectionSetFieldType + + public init(name: String? = nil, fieldType: SelectionSetFieldType) { + self.name = name + self.fieldType = fieldType + } + +} + +extension SelectionSet { + + /// Construct a `SelectionSet` with model fields + convenience init(fields: [ModelField], primaryKeysOnly: Bool = false) { + self.init(value: SelectionSetField(fieldType: .model)) + withModelFields(fields, primaryKeysOnly: primaryKeysOnly) + } + + func withModelFields(_ fields: [ModelField], recursive: Bool = true, primaryKeysOnly: Bool) { + fields.forEach { field in + if field.isEmbeddedType, let embeddedTypeSchema = field.embeddedTypeSchema { + let child = SelectionSet(value: .init(name: field.name, fieldType: .embedded)) + child.withEmbeddableFields(embeddedTypeSchema.sortedFields) + self.addChild(settingParentOf: child) + } else if field._isBelongsToOrHasOne, + let associatedModelName = field.associatedModelName, + let schema = ModelRegistry.modelSchema(from: associatedModelName) { + if recursive { + var recursive = recursive + if field._isBelongsToOrHasOne { + recursive = false + } + + let child = SelectionSet(value: .init(name: field.name, fieldType: .model)) + if primaryKeysOnly { + child.withModelFields(schema.primaryKey.fields, recursive: recursive, primaryKeysOnly: primaryKeysOnly) + } else { + child.withModelFields(schema.graphQLFields, recursive: recursive, primaryKeysOnly: primaryKeysOnly) + } + + self.addChild(settingParentOf: child) + } + } else { + self.addChild(settingParentOf: .init(value: .init(name: field.graphQLName, fieldType: .value))) + } + } + + addChild(settingParentOf: .init(value: .typename)) + } + + func withEmbeddableFields(_ fields: [ModelField]) { + fields.forEach { field in + if field.isEmbeddedType, let embeddedTypeSchema = field.embeddedTypeSchema { + let child = SelectionSet(value: .init(name: field.name, fieldType: .embedded)) + child.withEmbeddableFields(embeddedTypeSchema.sortedFields) + self.addChild(settingParentOf: child) + } else { + self.addChild(settingParentOf: .init(value: .init(name: field.name, fieldType: .value))) + } + } + addChild(settingParentOf: .init(value: .typename)) + } + + /// Generate the string value of the `SelectionSet` used in the GraphQL query document + /// + /// This method operates on `SelectionSet` with the root node containing a nil `value.name` and expects all inner + /// nodes to contain a value. It will generate a string with a nested and indented structure like: + /// ``` + /// items { + /// foo + /// bar + /// modelName { + /// foo + /// bar + /// } + /// } + /// nextToken + /// startAt + /// ``` + func stringValue(indentSize: Int = 0) -> String { + var result = [String]() + let indentValue = " " + let indent = indentSize == 0 ? "" : String(repeating: indentValue, count: indentSize) + + switch value.fieldType { + case .model, .pagination, .embedded: + if let name = value.name { + result.append(indent + name + " {") + children.forEach { innerSelectionSetField in + result.append(innerSelectionSetField.stringValue(indentSize: indentSize + 1)) + } + result.append(indent + "}") + } else { + children.forEach { innerSelectionSetField in + result.append(innerSelectionSetField.stringValue(indentSize: indentSize)) + } + } + case .collection: + let doubleIndent = String(repeating: indentValue, count: indentSize + 1) + result.append(indent + (value.name ?? "") + " {") + result.append(doubleIndent + "items {") + children.forEach { innerSelectionSetField in + result.append(innerSelectionSetField.stringValue(indentSize: indentSize + 2)) + } + result.append(doubleIndent + "}") + result.append(indent + "}") + case .value: + guard let name = value.name else { + return "" + } + result.append(indent + name) + } + + return result.joined(separator: "\n") + } + + /// Find a child in the tree matching its `value.name`. + /// + /// - Parameters: + /// - byName name: the name to match the child node of type `SelectionSetField` + /// + /// - Returns: the matched `SelectionSet` or `nil` if there's no child with the specified name. + func findChild(byName name: String) -> SelectionSet? { + return children.first { $0.value.name == name } + } + + /// Replaces or adds a new child to the selection set tree. When a child node exists + /// with a matching `name` property of the `SelectionSetField` the node will be replaced + /// while retaining its position in the children list. Otherwise the call is + /// delegated to `addChild(settingParentOf:)`. + /// + /// - Parameters: + /// - child: the child node to be replaced. + func replaceChild(_ child: SelectionSet) { + if let index = children.firstIndex(where: { $0.value.name == child.value.name }) { + children.insert(child, at: index) + children.remove(at: index + 1) + child.parent = self + } else { + addChild(settingParentOf: child) + } + } + + /// Merges a subtree into the this `SelectionSet`. The subtree position will be determined + /// by the value of the node's `name`. When an existing node is found the algorithm will + /// merge its children to ensure no values are lost or incorrectly overwritten. + /// + /// - Parameters: + /// - with selectionSet: the subtree to be merged into the current tree. + /// + /// - Seealso: + /// - `find(byName:)` + /// - `replaceChild(_)` + func merge(with selectionSet: SelectionSet) { + let name = selectionSet.value.name ?? "" + if let existingField = findChild(byName: name) { + var replaceFields: [SelectionSet] = [] + selectionSet.children.forEach { child in + if child.value.fieldType != .value, let childName = child.value.name { + if existingField.findChild(byName: childName) != nil { + existingField.merge(with: child) + } else { + replaceFields.append(child) + } + } else { + replaceFields.append(child) + } + } + replaceFields.forEach(existingField.replaceChild) + } else { + addChild(settingParentOf: selectionSet) + } + } + +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Resources/PrivacyInfo.xcprivacy b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Resources/PrivacyInfo.xcprivacy new file mode 100644 index 0000000000..1a3690cfcb --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Resources/PrivacyInfo.xcprivacy @@ -0,0 +1,10 @@ + + + + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Sync/ModelSync/ModelSyncMetadata+Schema.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Sync/ModelSync/ModelSyncMetadata+Schema.swift new file mode 100644 index 0000000000..67463b72b6 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Sync/ModelSync/ModelSyncMetadata+Schema.swift @@ -0,0 +1,34 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension ModelSyncMetadata { + + // MARK: - CodingKeys + + public enum CodingKeys: String, ModelKey { + case id + case lastSync + case syncPredicate + } + + public static let keys = CodingKeys.self + + // MARK: - ModelSchema + + public static let schema = defineSchema { definition in + + definition.attributes(.isSystem) + + definition.fields( + .id(), + .field(keys.lastSync, is: .optional, ofType: .int), + .field(keys.syncPredicate, is: .optional, ofType: .string) + ) + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Sync/ModelSync/ModelSyncMetadata.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Sync/ModelSync/ModelSyncMetadata.swift new file mode 100644 index 0000000000..4e9ff0ad0f --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Sync/ModelSync/ModelSyncMetadata.swift @@ -0,0 +1,26 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + + +public struct ModelSyncMetadata: Model { + /// The id of the ModelSyncMetada record is the name of the model being synced + public let id: String + + /// The timestamp (in Unix seconds) at which the last sync was started, as reported by the service + public var lastSync: Int64? + + /// The sync predicate for this model, extracted out from the sync expression. + public var syncPredicate: String? + + public init(id: String, + lastSync: Int64? = nil, + syncPredicate: String? = nil) { + self.id = id + self.lastSync = lastSync + self.syncPredicate = syncPredicate + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Sync/MutationSync/MutationSync.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Sync/MutationSync/MutationSync.swift new file mode 100644 index 0000000000..71905e59b0 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Sync/MutationSync/MutationSync.swift @@ -0,0 +1,92 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Tuple-like type that holds a `Model` instance and its `MutationSyncMetadata`. +/// THe sync metadata constains information about the model state in the backend. +public struct MutationSync: Decodable { + + public let model: ModelType + public let syncMetadata: MutationSyncMetadata + + public init(model: ModelType, syncMetadata: MutationSyncMetadata) { + self.model = model + self.syncMetadata = syncMetadata + } + + /// Custom decoder initializar that decodes a single JSON object into the `MutationSync`. + /// The fields (`_deleted`, `_lastChangedAt` and `_version`) are decoded into `MutationSyncMetadata` + /// and the remaining fields are decoded into the given `ModelType`. + /// - Throws: + /// - `DataStoreError.decodingError` in case the `ModelType` can't be resolved or in case the + /// field from `MutationSyncMetadata` are missing. + public init(from decoder: Decoder) throws { + let modelType = ModelType.self + let json = try JSONValue(from: decoder) + + var resolvedModelName = modelType.modelName + + // in case of `AnyModel`, decode the underlying type and erase to `AnyModel` + if modelType == AnyModel.self { + guard case let .string(modelName) = json["__typename"] else { + throw DataStoreError.decodingError( + "The key `__typename` was not found", + "Check if the parsed JSON contains the expected `__typename`") + } + guard let actualModelType = ModelRegistry.modelType(from: modelName) else { + throw DataStoreError.decodingError( + "Model named `\(modelName)` could not be resolved.", + "Make sure `\(modelName)` was registered using `ModelRegistry.register`") + } + let model = try actualModelType.init(from: decoder) + guard let anyModel = try model.eraseToAnyModel() as? ModelType else { + throw DataStoreError.decodingError( + "Could not erase `\(modelName)` to `AnyModel`", + """ + Please take a look at https://github.com/aws-amplify/amplify-ios/issues + to see if there are any existing issues that match your scenario, + and file an issue with the details of the bug if there isn't. + """) + } + self.model = anyModel + resolvedModelName = modelName + } else { + self.model = try modelType.init(from: decoder) + } + + guard case let .boolean(deleted) = json.value(at: "_deleted", withDefault: .boolean(false)), + case let .number(lastChangedAt) = json["_lastChangedAt"], + case let .number(version) = json["_version"] else { + + // TODO query name could be useful for the message, but re-creating it here is not great + let queryName = modelType.schema.syncPluralName ?? modelType.schema.pluralName ?? modelType.modelName + throw DataStoreError.decodingError( + """ + Error decoding the the sync metadata from the delta sync query result. + """, + """ + The sync metadata should contain fields named `_deleted`, `_lastChangedAt` and `_version`. + Check your sync`\(queryName)` query and make sure it returns the correct set of sync fields. + """ + ) + } + + // get the schema of the resolved model + guard let modelSchema = ModelRegistry.modelSchema(from: resolvedModelName) else { + throw DataStoreError.invalidModelName(resolvedModelName) + } + + let modelIdentifier = model.identifier(schema: modelSchema).stringValue + + self.syncMetadata = MutationSyncMetadata(modelId: modelIdentifier, + modelName: resolvedModelName, + deleted: deleted, + lastChangedAt: Int64(lastChangedAt), + version: Int(version)) + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Sync/MutationSync/MutationSyncMetadata+Schema.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Sync/MutationSync/MutationSyncMetadata+Schema.swift new file mode 100644 index 0000000000..babec30562 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Sync/MutationSync/MutationSyncMetadata+Schema.swift @@ -0,0 +1,42 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension MutationSyncMetadata { + + // MARK: - CodingKeys + + public enum CodingKeys: String, ModelKey { + case id + case deleted + case lastChangedAt + case version + } + + public static let keys = CodingKeys.self + + // MARK: - ModelSchema + + public static let schema = defineSchema { definition in + let sync = MutationSyncMetadata.keys + + definition.attributes(.isSystem) + + definition.fields( + .id(), + .field(sync.deleted, is: .required, ofType: .bool), + .field(sync.lastChangedAt, is: .required, ofType: .int), + .field(sync.version, is: .required, ofType: .int) + ) + } +} + +extension MutationSyncMetadata: ModelIdentifiable { + public typealias IdentifierProtocol = ModelIdentifier + public typealias IdentifierFormat = ModelIdentifierFormat.Default +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Sync/MutationSync/MutationSyncMetadata.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Sync/MutationSync/MutationSyncMetadata.swift new file mode 100644 index 0000000000..d42b942bd5 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Sync/MutationSync/MutationSyncMetadata.swift @@ -0,0 +1,49 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + + +public struct MutationSyncMetadata: Model { + /// Alias of MutationSyncMetadata's identifier, which has the format of `{modelName}|{modelId}` + public typealias MutationSyncIdentifier = String + public typealias ModelId = String + + public let id: MutationSyncIdentifier + public var deleted: Bool + public var lastChangedAt: Int64 + public var version: Int + + static let deliminator = "|" + + public var modelId: String { + id.components(separatedBy: MutationSyncMetadata.deliminator).last ?? "" + } + public var modelName: String { + id.components(separatedBy: MutationSyncMetadata.deliminator).first ?? "" + } + + @available(*, deprecated, message: """ + The format of the `id` has changed to support unique ids across mutiple model types. + Use init(modelId:modelName:deleted:lastChangedAt) to pass in the `modelName`. + """) + public init(id: MutationSyncIdentifier, deleted: Bool, lastChangedAt: Int64, version: Int) { + self.id = id + self.deleted = deleted + self.lastChangedAt = lastChangedAt + self.version = version + } + + public init(modelId: ModelId, modelName: String, deleted: Bool, lastChangedAt: Int64, version: Int) { + self.id = Self.identifier(modelName: modelName, modelId: modelId) + self.deleted = deleted + self.lastChangedAt = lastChangedAt + self.version = version + } + + public static func identifier(modelName: String, modelId: String) -> MutationSyncIdentifier { + "\(modelName)\(deliminator)\(modelId)" + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Sync/PaginatedList.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Sync/PaginatedList.swift new file mode 100644 index 0000000000..51add89aa0 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Sync/PaginatedList.swift @@ -0,0 +1,45 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public struct PaginatedList: Decodable { + public let items: [MutationSync] + public let nextToken: String? + public let startedAt: Int64? + + enum CodingKeys: CodingKey { + case items + case nextToken + case startedAt + } + + public init(items: [MutationSync], nextToken: String?, startedAt: Int64?) { + self.items = items + self.nextToken = nextToken + self.startedAt = startedAt + } + + public init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + let optimisticDecodedResults = try values.decode([OptimisticDecoded>].self, forKey: .items) + items = optimisticDecodedResults.compactMap { try? $0.result.get() } + nextToken = try values.decode(String?.self, forKey: .nextToken) + startedAt = try values.decode(Int64?.self, forKey: .startedAt) + } +} + + +fileprivate struct OptimisticDecoded: Decodable { + let result: Result + + init(from decoder: Decoder) throws { + result = Result(catching: { + try T(from: decoder) + }) + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/WebSocket/AmplifyNetworkMonitor.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/WebSocket/AmplifyNetworkMonitor.swift new file mode 100644 index 0000000000..23eb1ec4e2 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/WebSocket/AmplifyNetworkMonitor.swift @@ -0,0 +1,51 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + + +import Network +import Combine + +@_spi(WebSocket) +public final class AmplifyNetworkMonitor { + + public enum State { + case none + case online + case offline + } + + private let monitor: NWPathMonitor + + private let subject = PassthroughSubject() + + public var publisher: AnyPublisher<(State, State), Never> { + subject.scan((.none, .none)) { previous, next in + (previous.1, next) + }.eraseToAnyPublisher() + } + + public init(on interface: NWInterface.InterfaceType? = nil) { + monitor = interface.map(NWPathMonitor.init(requiredInterfaceType:)) ?? NWPathMonitor() + monitor.pathUpdateHandler = { [weak self] path in + self?.subject.send(path.status == .satisfied ? .online : .offline) + } + + monitor.start(queue: DispatchQueue( + label: "com.amazonaws.amplify.ios.network.websocket.monitor", + qos: .userInitiated + )) + } + + public func updateState(_ nextState: State) { + subject.send(nextState) + } + + deinit { + subject.send(completion: .finished) + monitor.cancel() + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/WebSocket/README.md b/packages/amplify_datastore/ios/internal/AWSPluginsCore/WebSocket/README.md new file mode 100644 index 0000000000..f0202b4565 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/WebSocket/README.md @@ -0,0 +1,11 @@ +# WebSocketClient + +This is an internal implementation of WebSocketClient. It uses Apple's URLSessionWebSocketTask under the hood. + + +The primary objective of this module is to offer convenient APIs for reading from and writing to WebSocket connections. +Moreover, it supervises WebSocket connections by monitoring changes in network reachability and retrying in cases of transient internal server failures. + +## RetryWithJitter + +This component implements a general full jitter retry strategy as outlined in [an AWS blog post](https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/). diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/WebSocket/RetryWithJitter.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/WebSocket/RetryWithJitter.swift new file mode 100644 index 0000000000..9da51cb03f --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/WebSocket/RetryWithJitter.swift @@ -0,0 +1,72 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + + +import Foundation + +@_spi(WebSocket) +public actor RetryWithJitter { + public enum Error: Swift.Error { + case maxRetryExceeded([Swift.Error]) + } + let base: UInt + let max: UInt + var retryCount: UInt = 0 + + init(base: UInt = 25, max: UInt = 6400) { + self.base = base + self.max = max + } + + // using FullJitter backoff strategy + // ref: https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/ + // Returns: retry backoff time interval in millisecond + func next() -> UInt { + let expo = min(max, powerOf2(count: retryCount) * base) + retryCount += 1 + return UInt.random(in: 0..( + maxRetryCount: UInt = 8, + shouldRetryOnError: (Swift.Error) -> Bool = { _ in true }, + _ operation: @escaping () async throws -> Output + ) async throws -> Output { + let retryWithJitter = RetryWithJitter() + func recursive(retryCount: UInt, cause: [Swift.Error]) async -> Result { + if retryCount == maxRetryCount { + return .failure(RetryWithJitter.Error.maxRetryExceeded(cause)) + } + + let backoffInterval = retryCount == 0 ? 0 : await retryWithJitter.next() + do { + try await Task.sleep(nanoseconds: UInt64(backoffInterval) * 1_000_000) + return .success(try await operation()) + } catch { + print("[RetryWithJitter] operation failed with error \(error), retrying(\(retryCount))") + if shouldRetryOnError(error) { + return await recursive(retryCount: retryCount + 1, cause: cause + [error]) + } else { + return .failure(error) + } + } + } + return try await recursive(retryCount: 0, cause: []).get() + } +} + +fileprivate func powerOf2(count: UInt) -> UInt { + count == 0 + ? 1 + : 2 * powerOf2(count: count - 1) +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/WebSocket/WebSocketClient.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/WebSocket/WebSocketClient.swift new file mode 100644 index 0000000000..9aafcec2ff --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/WebSocket/WebSocketClient.swift @@ -0,0 +1,372 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + + +import Foundation +import Combine + +/** + WebSocketClient wraps URLSessionWebSocketTask and offers + an abstraction of the data stream in the form of WebSocketEvent. + */ +@_spi(WebSocket) +public final actor WebSocketClient: NSObject { + public enum Error: Swift.Error { + case connectionLost + case connectionCancelled + } + + /// WebSocket server endpoint + private let url: URL + /// Additional Header for WebSocket handshake http request + private let handshakeHttpHeaders: [String: String] + /// Interceptor for appending additional info before makeing the connection + private var interceptor: WebSocketInterceptor? + /// Internal wriable WebSocketEvent data stream + private let subject = PassthroughSubject() + + private let retryWithJitter = RetryWithJitter() + + /// Network monitor provide notification of device network status + private let networkMonitor: WebSocketNetworkMonitorProtocol + + /// Cancellables bind with client life cycle + private var cancelables = Set() + /// The underlying URLSessionWebSocketTask + private var connection: URLSessionWebSocketTask? { + willSet { + self.connection?.cancel(with: .goingAway, reason: nil) + } + } + + /// A flag indicating whether to automatically update the connection upon network status updates + private var autoConnectOnNetworkStatusChange: Bool + /// A flag indicating whether to automatically retry on connection failure + private var autoRetryOnConnectionFailure: Bool + /// Data stream for downstream subscribers to engage with + public var publisher: AnyPublisher { + self.subject.eraseToAnyPublisher() + } + + public var isConnected: Bool { + self.connection?.state == .running + } + + /** + Creates a WebSocketClient. + + - Parameters: + - url: WebSocket server endpoint + - protocols: WebSocket subprotocols, for header `Sec-WebSocket-Protocol` + - interceptor: An optional interceptor for additional info before establishing the connection + - networkMonitor: Provides network status notifications + */ + public init( + url: URL, + handshakeHttpHeaders: [String: String] = [:], + interceptor: WebSocketInterceptor? = nil, + networkMonitor: WebSocketNetworkMonitorProtocol = AmplifyNetworkMonitor() + ) { + self.url = Self.useWebSocketProtocolScheme(url: url) + self.handshakeHttpHeaders = handshakeHttpHeaders + self.interceptor = interceptor + self.autoConnectOnNetworkStatusChange = false + self.autoRetryOnConnectionFailure = false + self.networkMonitor = networkMonitor + super.init() + /** + The network monitor and retries should have a longer lifespan compared to the connection itself. + This ensures that when the network goes offline or the connection drops, + the network monitor can initiate a reconnection once the network is back online. + */ + Task { await self.startNetworkMonitor() } + Task { await self.retryOnConnectionFailure() } + } + + deinit { + self.subject.send(completion: .finished) + self.autoConnectOnNetworkStatusChange = false + self.autoRetryOnConnectionFailure = false + cancelables = Set() + } + + /** + Connect to WebSocket server. + - Parameters: + - autoConnectOnNetworkStatusChange: + A flag indicating whether this connection should be automatically updated when the network status changes. + - autoRetryOnConnectionFailure: + A flag indicating whether this connection should attampt to retry upon failure. + */ + public func connect( + autoConnectOnNetworkStatusChange: Bool = false, + autoRetryOnConnectionFailure: Bool = false + ) async { + guard self.connection?.state != .running else { + log.debug("[WebSocketClient] WebSocket is already in connecting state") + return + } + + log.debug("[WebSocketClient] WebSocket about to connect") + self.autoConnectOnNetworkStatusChange = autoConnectOnNetworkStatusChange + self.autoRetryOnConnectionFailure = autoRetryOnConnectionFailure + + await self.createConnectionAndRead() + } + + /** + Disconnect from WebSocket server. + + This will halt all automatic processes and attempt to gracefully close the connection. + */ + public func disconnect() { + guard self.connection?.state == .running else { + log.debug("[WebSocketClient] client should be in connected state to trigger disconnect") + return + } + + self.autoConnectOnNetworkStatusChange = false + self.autoRetryOnConnectionFailure = false + self.connection?.cancel(with: .goingAway, reason: nil) + } + + /** + Write text data to WebSocket server. + - Parameters: + - message: text message in String + */ + public func write(message: String) async throws { + log.debug("[WebSocketClient] WebSocket write message string: \(message)") + try await self.connection?.send(.string(message)) + } + + /** + Write binary data to WebSocket server. + - Parameters: + - message: binary message in Data + */ + public func write(message: Data) async throws { + log.debug("[WebSocketClient] WebSocket write message data: \(message)") + try await self.connection?.send(.data(message)) + } + + private func createWebSocketConnection() async -> URLSessionWebSocketTask { + let decoratedURL = (await self.interceptor?.interceptConnection(url: self.url)) ?? self.url + var urlRequest = URLRequest(url: decoratedURL) + self.handshakeHttpHeaders.forEach { urlRequest.setValue($0.value, forHTTPHeaderField: $0.key) } + + let urlSession = URLSession(configuration: .default, delegate: self, delegateQueue: nil) + return urlSession.webSocketTask(with: urlRequest) + } + + private func createConnectionAndRead() async { + log.debug("[WebSocketClient] Creating new connection and starting read") + self.connection = await createWebSocketConnection() + + // Perform reading from a WebSocket in a separate task recursively to avoid blocking the execution. + Task { await self.startReadMessage() } + + self.connection?.resume() + } + + /** + Recusively read WebSocket data frames and publish to data stream. + */ + private func startReadMessage() async { + guard let connection = self.connection else { + log.debug("[WebSocketClient] WebSocket connection doesn't exist") + return + } + + if connection.state == .canceling || connection.state == .completed { + log.debug("[WebSocketClient] WebSocket connection state is \(connection.state). Failed to read websocket message") + return + } + + do { + let message = try await connection.receive() + log.debug("[WebSocketClient] WebSocket received message: \(String(describing: message))") + switch message { + case .data(let data): + subject.send(.data(data)) + case .string(let string): + subject.send(.string(string)) + @unknown default: + break + } + } catch { + if connection.state == .running { + subject.send(.error(error)) + } else { + log.debug("[WebSocketClient] read message failed with connection state \(connection.state), error \(error)") + } + } + + await self.startReadMessage() + } +} + +// MARK: - URLSession delegate +extension WebSocketClient: URLSessionWebSocketDelegate { + nonisolated public func urlSession( + _ session: URLSession, + webSocketTask: URLSessionWebSocketTask, + didOpenWithProtocol protocol: String? + ) { + log.debug("[WebSocketClient] Websocket connected") + self.subject.send(.connected) + } + + nonisolated public func urlSession( + _ session: URLSession, + webSocketTask: URLSessionWebSocketTask, + didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, + reason: Data? + ) { + log.debug("[WebSocketClient] Websocket disconnected") + self.subject.send(.disconnected(closeCode, reason.flatMap { String(data: $0, encoding: .utf8) })) + } + + nonisolated public func urlSession( + _ session: URLSession, + task: URLSessionTask, + didCompleteWithError error: Swift.Error? + ) { + guard let error else { + log.debug("[WebSocketClient] URLSession didComplete") + return + } + + log.debug("[WebSocketClient] URLSession didCompleteWithError: \(error))") + + let nsError = error as NSError + switch (nsError.domain, nsError.code) { + case (NSURLErrorDomain.self, NSURLErrorNetworkConnectionLost), // connection lost + (NSPOSIXErrorDomain.self, Int(ECONNABORTED)): // background to foreground + self.subject.send(.error(WebSocketClient.Error.connectionLost)) + Task { [weak self] in + await self?.networkMonitor.updateState(.offline) + } + case (NSURLErrorDomain.self, NSURLErrorCancelled): + log.debug("Skipping NSURLErrorCancelled error") + self.subject.send(.error(WebSocketClient.Error.connectionCancelled)) + default: + self.subject.send(.error(error)) + } + } +} + +// MARK: - network reachability +extension WebSocketClient { + /// Monitor network status. Disconnect or reconnect when the network drops or comes back online. + private func startNetworkMonitor() { + networkMonitor.publisher.sink(receiveValue: { [weak self] stateChange in + Task { [weak self] in + await self?.onNetworkStateChange(stateChange) + } + }) + .store(in: &cancelables) + } + + private func onNetworkStateChange( + _ stateChange: (AmplifyNetworkMonitor.State, AmplifyNetworkMonitor.State) + ) async { + guard self.autoConnectOnNetworkStatusChange == true else { + return + } + + switch stateChange { + case (.online, .offline): + log.debug("[WebSocketClient] NetworkMonitor - Device went offline") + self.connection?.cancel(with: .invalid, reason: nil) + self.subject.send(.disconnected(.invalid, nil)) + case (.offline, .online): + log.debug("[WebSocketClient] NetworkMonitor - Device back online") + await self.createConnectionAndRead() + default: + break + } + } +} + +// MARK: - auto retry on connection failure +extension WebSocketClient { + private func retryOnConnectionFailure() { + subject.map { event -> URLSessionWebSocketTask.CloseCode? in + guard case .disconnected(let closeCode, _) = event else { + return nil + } + return closeCode + } + .compactMap { $0 } + .sink(receiveCompletion: { _ in }) { [weak self] closeCode in + Task { [weak self] in await self?.retryOnCloseCode(closeCode) } + } + .store(in: &cancelables) + + self.resetRetryCountOnConnected() + } + + private func resetRetryCountOnConnected() { + subject.filter { + if case .connected = $0 { + return true + } + return false + } + .sink(receiveCompletion: { _ in }) { [weak self] _ in + Task { [weak self] in + await self?.retryWithJitter.reset() + } + } + .store(in: &cancelables) + } + + private func retryOnCloseCode(_ closeCode: URLSessionWebSocketTask.CloseCode) async { + guard self.autoRetryOnConnectionFailure == true else { + return + } + + switch closeCode { + case .internalServerError: + let delayInMs = await retryWithJitter.next() + Task { [weak self] in + try await Task.sleep(nanoseconds: UInt64(delayInMs) * 1_000_000) + await self?.createConnectionAndRead() + } + default: break + } + + } +} + +extension WebSocketClient { + static func useWebSocketProtocolScheme(url: URL) -> URL { + guard var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return url + } + urlComponents.scheme = urlComponents.scheme == "http" ? "ws" : "wss" + return urlComponents.url ?? url + } +} + +extension WebSocketClient: DefaultLogger { + public static var log: Logger { + Amplify.Logging.logger(forNamespace: String(describing: self)) + } + + public nonisolated var log: Logger { Self.log } +} + +extension WebSocketClient: Resettable { + public func reset() async { + self.subject.send(completion: .finished) + self.autoConnectOnNetworkStatusChange = false + self.autoRetryOnConnectionFailure = false + cancelables = Set() + } +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/WebSocket/WebSocketEvent.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/WebSocket/WebSocketEvent.swift new file mode 100644 index 0000000000..35c101dd6e --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/WebSocket/WebSocketEvent.swift @@ -0,0 +1,18 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + + +import Foundation + +@_spi(WebSocket) +public enum WebSocketEvent { + case connected + case disconnected(URLSessionWebSocketTask.CloseCode, String?) + case data(Data) + case string(String) + case error(Error) +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/WebSocket/WebSocketInterceptor.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/WebSocket/WebSocketInterceptor.swift new file mode 100644 index 0000000000..a53ec3b950 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/WebSocket/WebSocketInterceptor.swift @@ -0,0 +1,14 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + + +import Foundation + +@_spi(WebSocket) +public protocol WebSocketInterceptor { + func interceptConnection(url: URL) async -> URL +} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/WebSocket/WebSocketNetworkMonitorProtocol.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/WebSocket/WebSocketNetworkMonitorProtocol.swift new file mode 100644 index 0000000000..3966e7ab9d --- /dev/null +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/WebSocket/WebSocketNetworkMonitorProtocol.swift @@ -0,0 +1,18 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + + +import Foundation +import Combine + +@_spi(WebSocket) +public protocol WebSocketNetworkMonitorProtocol { + var publisher: AnyPublisher<(AmplifyNetworkMonitor.State, AmplifyNetworkMonitor.State), Never> { get } + func updateState(_ nextState: AmplifyNetworkMonitor.State) async +} + +extension AmplifyNetworkMonitor: WebSocketNetworkMonitorProtocol { } diff --git a/packages/amplify_datastore/ios/internal/Amplify/Amplify.swift b/packages/amplify_datastore/ios/internal/Amplify/Amplify.swift new file mode 100644 index 0000000000..fdc88954a3 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Amplify.swift @@ -0,0 +1,121 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// At its core, the Amplify class is simply a router that provides clients top-level access to categories and +/// configuration methods. It provides convenient access to default plugins via the top-level category properties, +/// but clients can access specific plugins by invoking `getPlugin` on a category and issuing methods directly to +/// that plugin. +/// +/// - Warning: It is a serious error to invoke any of the category APIs (like `Analytics.record()` or +/// `API.mutate()`) without first registering plugins via `Amplify.add(plugin:)` and configuring Amplify via +/// `Amplify.configure()`. Such access will cause a preconditionFailure. +/// +/// There are two exceptions to this. The `Logging` and `Hub` categories are configured with a default plugin that is +/// available at initialization. +/// +/// - Tag: Amplify +public class Amplify { + + /// If `true`, `configure()` has already been invoked, and subsequent calls to `configure` will throw a + /// ConfigurationError.amplifyAlreadyConfigured error. + /// + /// - Tag: Amplify.isConfigured + static var isConfigured = false + + // Storage for the categories themselves, which will be instantiated during configuration, and cleared during reset. + // It is not supported to mutate these category properties. They are `var` to support the `reset()` method for + // ease of testing. + + /// - Tag: Amplify.Analytics + public static internal(set) var Analytics = AnalyticsCategory() + + /// - Tag: Amplify.API + public static internal(set) var API: APICategory = APICategory() + + /// - Tag: Amplify.Auth + public static internal(set) var Auth = AuthCategory() + + /// - Tag: Amplify.DataStore + public static internal(set) var DataStore = DataStoreCategory() + + /// - Tag: Amplify.Geo + public static internal(set) var Geo = GeoCategory() + + /// - Tag: Amplify.Hub + public static internal(set) var Hub = HubCategory() + + /// - Tag: Amplify.Notifications + public static internal(set) var Notifications = NotificationsCategory() + + /// - Tag: Amplify.Predictions + public static internal(set) var Predictions = PredictionsCategory() + + /// - Tag: Amplify.Storage + public static internal(set) var Storage = StorageCategory() + + /// Special case category. We protect this with an AtomicValue because it is used by reset() + /// methods during setup & teardown of tests + /// + /// - Tag: Amplify.Logging + public static internal(set) var Logging: LoggingCategory { + get { + loggingAtomic.get() + } + set { + loggingAtomic.set(newValue) + } + } + private static let loggingAtomic = AtomicValue(initialValue: LoggingCategory()) + + /// Adds `plugin` to the category + /// + /// See: [Category.removePlugin(for:)](x-source-tag://Category.removePlugin) + /// + /// - Parameter plugin: The plugin to add + /// - Tag: Amplify.add_plugin + public static func add(plugin: P) throws { + log.debug("Adding plugin: \(plugin))") + switch plugin { + case let plugin as AnalyticsCategoryPlugin: + try Analytics.add(plugin: plugin) + case let plugin as APICategoryPlugin: + try API.add(plugin: plugin) + case let plugin as AuthCategoryPlugin: + try Auth.add(plugin: plugin) + case let plugin as DataStoreCategoryPlugin: + try DataStore.add(plugin: plugin) + case let plugin as GeoCategoryPlugin: + try Geo.add(plugin: plugin) + case let plugin as HubCategoryPlugin: + try Hub.add(plugin: plugin) + case let plugin as LoggingCategoryPlugin: + try Logging.add(plugin: plugin) + case let plugin as PredictionsCategoryPlugin: + try Predictions.add(plugin: plugin) + case let plugin as PushNotificationsCategoryPlugin: + try Notifications.Push.add(plugin: plugin) + case let plugin as StorageCategoryPlugin: + try Storage.add(plugin: plugin) + default: + throw PluginError.pluginConfigurationError( + "Plugin category does not exist.", + "Verify that the library version is correct and supports the plugin's category.") + } + } +} + +extension Amplify: DefaultLogger { + public static var log: Logger { + Amplify.Logging.logger(forCategory: String(describing: self)) + } + + public var log: Logger { + Self.log + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/API/APICategory+HubPayloadEventName.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/APICategory+HubPayloadEventName.swift new file mode 100644 index 0000000000..91fe571602 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/APICategory+HubPayloadEventName.swift @@ -0,0 +1,13 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// HubPayload EventName +public extension HubPayload.EventName { + + /// API hub events + struct API { } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/API/APICategory.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/APICategory.swift new file mode 100644 index 0000000000..4725180daa --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/APICategory.swift @@ -0,0 +1,107 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// The API category provides a solution for making HTTP requests to REST and GraphQL endpoints. +final public class APICategory: Category { + /// The category type for API + public var categoryType: CategoryType { + .api + } + + var plugins = [PluginKey: APICategoryPlugin]() + + /// Returns the plugin added to the category, if only one plugin is added. Accessing this property if no plugins + /// are added, or if more than one plugin is added, will cause a preconditionFailure. + var plugin: APICategoryPlugin { + guard isConfigured else { + return Fatal.preconditionFailure( + """ + \(categoryType.displayName) category is not configured. Call Amplify.configure() before using \ + any methods on the category. + """ + ) + } + + guard !plugins.isEmpty else { + return Fatal.preconditionFailure("No plugins added to \(categoryType.displayName) category.") + } + + guard plugins.count == 1 else { + return Fatal.preconditionFailure( + """ + More than 1 plugin added to \(categoryType.displayName) category. \ + You must invoke operations on this category by getting the plugin you want, as in: + #"Amplify.\(categoryType.displayName).getPlugin(for: "ThePluginKey").foo() + """ + ) + } + + return plugins.first!.value + } + + /// `true` if this category has been configured with `Amplify.configure()`. + /// + /// - Warning: This property is intended for use by plugin developers. + public var isConfigured = false + + // MARK: - Plugin handling + + /// Adds `plugin` to the list of Plugins that implement functionality for this category. + /// + /// - Parameter plugin: The Plugin to add + public func add(plugin: APICategoryPlugin) throws { + let key = plugin.key + guard !key.isEmpty else { + let pluginDescription = String(describing: plugin) + let error = APIError.invalidConfiguration("Plugin \(pluginDescription) has an empty `key`.", + "Set the `key` property for \(String(describing: plugin))") + throw error + } + + guard !isConfigured else { + let pluginDescription = String(describing: plugin) + let error = ConfigurationError.amplifyAlreadyConfigured( + "\(pluginDescription) cannot be added after `Amplify.configure()`.", + "Do not add plugins after calling `Amplify.configure()`." + ) + throw error + } + + plugins[plugin.key] = plugin + } + + /// Returns the added plugin with the specified `key` property. + /// + /// - Parameter key: The PluginKey (String) of the plugin to retrieve + /// - Returns: The wrapped plugin + public func getPlugin(for key: PluginKey) throws -> APICategoryPlugin { + guard let plugin = plugins[key] else { + let keys = plugins.keys.joined(separator: ", ") + let error = APIError.invalidConfiguration("No plugin has been added for '\(key)'.", + "Either add a plugin for '\(key)', or use one of the known keys: \(keys)") + throw error + } + return plugin + } + + /// Removes the plugin registered for `key` from the list of Plugins that implement functionality for this category. + /// If no plugin has been added for `key`, no action is taken, making this method safe to call multiple times. + /// + /// - Parameter key: The key used to `add` the plugin + public func removePlugin(for key: PluginKey) { + plugins.removeValue(forKey: key) + } +} + +extension APICategory: DefaultLogger { + public static var log: Logger { + Amplify.Logging.logger(forCategory: CategoryType.api.displayName, forNamespace: String(describing: self)) + } + public var log: Logger { + Self.log + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/API/APICategoryConfiguration.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/APICategoryConfiguration.swift new file mode 100644 index 0000000000..25775cb739 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/APICategoryConfiguration.swift @@ -0,0 +1,22 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// API Category Configuration +public struct APICategoryConfiguration: CategoryConfiguration { + + /// Plugin keys to plugin configuration + public let plugins: [String: JSONValue] + + /// Initializer for API configuration + /// + /// - Parameter plugins: plugin configuration map + public init(plugins: [String: JSONValue] = [:]) { + self.plugins = plugins + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/API/APICategoryPlugin.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/APICategoryPlugin.swift new file mode 100644 index 0000000000..2fd3f8134c --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/APICategoryPlugin.swift @@ -0,0 +1,18 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// API Category Plugin +public protocol APICategoryPlugin: Plugin, APICategoryBehavior { } + +/// API Category Plugin +public extension APICategoryPlugin { + + /// The category type for API + var categoryType: CategoryType { + return .api + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/API/AuthProvider/APIAuthProviderFactory.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/AuthProvider/APIAuthProviderFactory.swift new file mode 100644 index 0000000000..fe56f3cf96 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/AuthProvider/APIAuthProviderFactory.swift @@ -0,0 +1,37 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// API Auth Provider Factory +open class APIAuthProviderFactory { + + /// Empty public initializer + public init() { + } + + /// Retrieve the OIDC auth provider + open func oidcAuthProvider() -> AmplifyOIDCAuthProvider? { + return nil + } + + open func functionAuthProvider() -> AmplifyFunctionAuthProvider? { + return nil + } +} + +public protocol AmplifyAuthTokenProvider { + typealias AuthToken = String + + func getLatestAuthToken() async throws -> String +} + +/// Amplify OIDC Auth Provider +public protocol AmplifyOIDCAuthProvider: AmplifyAuthTokenProvider {} + +/// Amplify Function Auth Provider +public protocol AmplifyFunctionAuthProvider: AmplifyAuthTokenProvider {} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/API/ClientBehavior/APICategory+AuthProviderFactoryBehavior.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/ClientBehavior/APICategory+AuthProviderFactoryBehavior.swift new file mode 100644 index 0000000000..d1d7685afa --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/ClientBehavior/APICategory+AuthProviderFactoryBehavior.swift @@ -0,0 +1,18 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension APICategory: APICategoryAuthProviderFactoryBehavior { + + /// Retrieve the plugin's auth provider factory + /// + /// - Returns: auth provider factory + public func apiAuthProviderFactory() -> APIAuthProviderFactory { + return plugin.apiAuthProviderFactory() + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/API/ClientBehavior/APICategory+GraphQLBehavior.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/ClientBehavior/APICategory+GraphQLBehavior.swift new file mode 100644 index 0000000000..10510fc7e0 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/ClientBehavior/APICategory+GraphQLBehavior.swift @@ -0,0 +1,22 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +extension APICategory: APICategoryGraphQLBehavior { + + // MARK: - Request-based GraphQL operations + public func query(request: GraphQLRequest) async throws -> GraphQLTask.Success { + try await plugin.query(request: request) + } + + public func mutate(request: GraphQLRequest) async throws -> GraphQLTask.Success { + try await plugin.mutate(request: request) + } + + public func subscribe(request: GraphQLRequest) -> AmplifyAsyncThrowingSequence> { + plugin.subscribe(request: request) + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/API/ClientBehavior/APICategory+InterceptorBehavior.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/ClientBehavior/APICategory+InterceptorBehavior.swift new file mode 100644 index 0000000000..b2f2485976 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/ClientBehavior/APICategory+InterceptorBehavior.swift @@ -0,0 +1,14 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +extension APICategory: APICategoryInterceptorBehavior { + + public func add(interceptor: URLRequestInterceptor, for apiName: String) throws { + try plugin.add(interceptor: interceptor, for: apiName) + } + +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/API/ClientBehavior/APICategory+RESTBehavior.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/ClientBehavior/APICategory+RESTBehavior.swift new file mode 100644 index 0000000000..8daa59636d --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/ClientBehavior/APICategory+RESTBehavior.swift @@ -0,0 +1,35 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension APICategory: APICategoryRESTBehavior { + + public func get(request: RESTRequest) async throws -> RESTTask.Success { + try await plugin.get(request: request) + } + + public func put(request: RESTRequest) async throws -> RESTTask.Success { + try await plugin.put(request: request) + } + + public func post(request: RESTRequest) async throws -> RESTTask.Success { + try await plugin.post(request: request) + } + + public func delete(request: RESTRequest) async throws -> RESTTask.Success { + try await plugin.delete(request: request) + } + + public func head(request: RESTRequest) async throws -> RESTTask.Success { + try await plugin.head(request: request) + } + + public func patch(request: RESTRequest) async throws -> RESTTask.Success { + try await plugin.patch(request: request) + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/API/ClientBehavior/APICategory+ReachabilityBehavior.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/ClientBehavior/APICategory+ReachabilityBehavior.swift new file mode 100644 index 0000000000..1f714f94ef --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/ClientBehavior/APICategory+ReachabilityBehavior.swift @@ -0,0 +1,24 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#if canImport(Combine) +import Foundation +import Combine + +extension APICategory: APICategoryReachabilityBehavior { +#if !os(watchOS) + /// Default implementation of `reachabilityPublisher` to delegate to plugin's method + public func reachabilityPublisher(for apiName: String?) throws -> AnyPublisher? { + return try plugin.reachabilityPublisher(for: apiName) + } +#endif + /// Default implementation of `reachabilityPublisher` to delegate to plugin's method + public func reachabilityPublisher() throws -> AnyPublisher? { + return try plugin.reachabilityPublisher() + } +} +#endif diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/API/ClientBehavior/APICategoryAuthProviderFactoryBehavior.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/ClientBehavior/APICategoryAuthProviderFactoryBehavior.swift new file mode 100644 index 0000000000..b3d1e374cc --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/ClientBehavior/APICategoryAuthProviderFactoryBehavior.swift @@ -0,0 +1,15 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// API Category Auth provider Factory Behavior +public protocol APICategoryAuthProviderFactoryBehavior { + + /// Retrieve the auth provider factory + func apiAuthProviderFactory() -> APIAuthProviderFactory +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/API/ClientBehavior/APICategoryBehavior.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/ClientBehavior/APICategoryBehavior.swift new file mode 100644 index 0000000000..3c2356d564 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/ClientBehavior/APICategoryBehavior.swift @@ -0,0 +1,14 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// Behavior of the API category that clients will use +public typealias APICategoryBehavior = + APICategoryRESTBehavior & + APICategoryGraphQLBehavior & + APICategoryInterceptorBehavior & + APICategoryReachabilityBehavior & + APICategoryAuthProviderFactoryBehavior diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/API/ClientBehavior/APICategoryGraphQLBehavior.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/ClientBehavior/APICategoryGraphQLBehavior.swift new file mode 100644 index 0000000000..d149b5d945 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/ClientBehavior/APICategoryGraphQLBehavior.swift @@ -0,0 +1,42 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// Behavior of the API category related to GraphQL operations +public protocol APICategoryGraphQLBehavior: AnyObject { + + // MARK: - Request-based GraphQL Operations + + /// Perform a GraphQL query operation against a previously configured API. This operation + /// will be asynchronous, with the callback accessible both locally and via the Hub. + /// + /// - Parameters: + /// - request: The GraphQL request containing apiName, document, variables, and responseType + /// - listener: The event listener for the operation + /// - Returns: The AmplifyOperation being enqueued + func query(request: GraphQLRequest) async throws -> GraphQLTask.Success + + /// Perform a GraphQL mutate operation against a previously configured API. This operation + /// will be asynchronous, with the callback accessible both locally and via the Hub. + /// + /// - Parameters: + /// - request: The GraphQL request containing apiName, document, variables, and responseType + /// - listener: The event listener for the operation + /// - Returns: The AmplifyOperation being enqueued + func mutate(request: GraphQLRequest) async throws -> GraphQLTask.Success + + /// Perform a GraphQL subscribe operation against a previously configured API. This operation + /// will be asychronous, with the callback accessible both locally and via the Hub. + /// + /// - Parameters: + /// - request: The GraphQL request containing apiName, document, variables, and responseType + /// - valueListener: Invoked when the GraphQL subscription receives a new value from the service + /// - completionListener: Invoked when the subscription has terminated + /// - Returns: The AmplifyInProcessReportingOperation being enqueued + func subscribe( + request: GraphQLRequest + ) -> AmplifyAsyncThrowingSequence> +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/API/ClientBehavior/APICategoryInterceptorBehavior.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/ClientBehavior/APICategoryInterceptorBehavior.swift new file mode 100644 index 0000000000..a1fb86cabb --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/ClientBehavior/APICategoryInterceptorBehavior.swift @@ -0,0 +1,26 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// A URLRequestInterceptor accepts a request and returns a request. It is invoked +/// during the "prepare" phase of an API operation. +/// +/// A URLRequestInterceptor may use the request as a data source for some other +/// operation (e.g., metrics or logging), or use it as the source for preparing a +/// new request that will be used to fulfill the operation. For example, a +/// URLRequestInterceptor may add custom headers to the request for authorization. +/// +/// URLRequestInterceptors are invoked in the order in which they are added to the +/// plugin. +public protocol APICategoryInterceptorBehavior { + + /// Adds a URLRequestInterceptor to the chain of interceptors invoked during the + /// "prepare" phase of an API operation. The Operation's URLRequest will be passed + /// to each interceptor in turn, and each interceptor will have the option to + /// return a modified request to the next member of the chain. + /// - Parameter inteceptor: The `URLRequestInterceptor` + func add(interceptor: URLRequestInterceptor, for apiName: String) throws +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/API/ClientBehavior/APICategoryRESTBehavior.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/ClientBehavior/APICategoryRESTBehavior.swift new file mode 100644 index 0000000000..0a4c33c27e --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/ClientBehavior/APICategoryRESTBehavior.swift @@ -0,0 +1,48 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Behavior of the API category related to REST operations +public protocol APICategoryRESTBehavior { + + /// Perform an HTTP GET operation + /// + /// - Parameter request: Contains information such as path, query parameters, body. + /// - Returns: An operation that can be observed for its value + func get(request: RESTRequest) async throws -> RESTTask.Success + + /// Perform an HTTP PUT operation + /// + /// - Parameter request: Contains information such as path, query parameters, body. + /// - Returns: An operation that can be observed for its value + func put(request: RESTRequest) async throws -> RESTTask.Success + + /// Perform an HTTP POST operation + /// + /// - Parameter request: Contains information such as path, query parameters, body. + /// - Returns: An operation that can be observed for its value + func post(request: RESTRequest) async throws -> RESTTask.Success + + /// Perform an HTTP DELETE operation + /// + /// - Parameter request: Contains information such as path, query parameters, body. + /// - Returns: An operation that can be observed for its value + func delete(request: RESTRequest) async throws -> RESTTask.Success + + /// Perform an HTTP HEAD operation + /// + /// - Parameter request: Contains information such as path, query parameters, body. + /// - Returns: An operation that can be observed for its value + func head(request: RESTRequest) async throws -> RESTTask.Success + + /// Perform an HTTP PATCH operation + /// + /// - Parameter request: Contains information such as path, query parameters, body. + /// - Returns: An operation that can be observed for its value + func patch(request: RESTRequest) async throws -> RESTTask.Success +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/API/ClientBehavior/APICategoryReachabilityBehavior.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/ClientBehavior/APICategoryReachabilityBehavior.swift new file mode 100644 index 0000000000..d14ab48a3b --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/ClientBehavior/APICategoryReachabilityBehavior.swift @@ -0,0 +1,27 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#if canImport(Combine) +import Foundation +import Combine + +/// API Reachability Behavior +public protocol APICategoryReachabilityBehavior { +#if !os(watchOS) + /// Attempts to create and start a reachability client for a host that corresponds to the apiName, and then + /// returns the associated Publisher which vends ReachabiltyUpdates + /// - Parameters: + /// - for: The corresponding apiName that maps to the plugin configuration + /// - Returns: A publisher that receives reachability updates, or nil if the reachability subsystem is unavailable + func reachabilityPublisher(for apiName: String?) throws -> AnyPublisher? +#endif + /// Attempts to create and start a reachability client for a host that corresponds to the apiName, and then + /// returns the associated Publisher which vends ReachabiltyUpdates + func reachabilityPublisher() throws -> AnyPublisher? + +} +#endif diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Error/APIError.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Error/APIError.swift new file mode 100644 index 0000000000..6d473e2f00 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Error/APIError.swift @@ -0,0 +1,132 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Errors specific to the API Category +public enum APIError { + + /// Dictionary used to store additional information + public typealias UserInfo = [String: Any] + + /// Status code Int + public typealias StatusCode = Int + + /// An unknown error + case unknown(ErrorDescription, RecoverySuggestion, Error? = nil) + + /// The configuration for a particular API was invalid + case invalidConfiguration(ErrorDescription, RecoverySuggestion, Error? = nil) + + /// The URL in a request was invalid or missing + case invalidURL(ErrorDescription, RecoverySuggestion, Error? = nil) + + /// An in-process operation encountered a processing error + case operationError(ErrorDescription, RecoverySuggestion, Error? = nil) + + /// The category received an underlying error from network layer. + case networkError(ErrorDescription, UserInfo? = nil, Error? = nil) + + /// A non 2xx response from the service. + case httpStatusError(StatusCode, HTTPURLResponse) + + /// An error to encapsulate an error received by a dependent plugin + case pluginError(AmplifyError) + +} + +extension APIError: AmplifyError { + public init( + errorDescription: ErrorDescription = "An unknown error occurred", + recoverySuggestion: RecoverySuggestion = "See `underlyingError` for more details", + error: Error + ) { + if let error = error as? Self { + self = error + } else if error.isOperationCancelledError { + self = .unknown("Operation cancelled", "", error) + } else { + self = .unknown(errorDescription, recoverySuggestion, error) + } + } + + public var errorDescription: ErrorDescription { + switch self { + case .unknown(let errorDescription, _, _): + return "Unexpected error occurred with message: \(errorDescription)" + + case .invalidConfiguration(let errorDescription, _, _): + return errorDescription + + case .invalidURL(let errorDescription, _, _): + return errorDescription + + case .operationError(let errorDescription, _, _): + return errorDescription + + case .networkError(let errorDescription, _, _): + return errorDescription + + case .httpStatusError(let statusCode, _): + return "The HTTP response status code is [\(statusCode)]." + + case .pluginError(let error): + return error.errorDescription + } + } + + public var recoverySuggestion: RecoverySuggestion { + switch self { + case .unknown(_, let recoverySuggestion, _): + return """ + \(recoverySuggestion) + + \(AmplifyErrorMessages.shouldNotHappenReportBugToAWS()) + """ + + case .invalidConfiguration(_, let recoverySuggestion, _): + return recoverySuggestion + + case .invalidURL(_, let recoverySuggestion, _): + return recoverySuggestion + + case .operationError(_, let recoverySuggestion, _): + return recoverySuggestion + + case .networkError(let errorDescription, _, _): + return errorDescription + + case .httpStatusError: + return """ + The metadata associated with the response is contained in the HTTPURLResponse. + For more information on HTTP status codes, take a look at + https://en.wikipedia.org/wiki/List_of_HTTP_status_codes + """ + case .pluginError(let error): + return error.recoverySuggestion + } + } + + public var underlyingError: Error? { + switch self { + case .unknown(_, _, let error): + return error + case .invalidConfiguration(_, _, let error): + return error + case .invalidURL(_, _, let error): + return error + case .operationError(_, _, let error): + return error + case .networkError(_, _, let error): + return error + case .httpStatusError: + return nil + case .pluginError(let error): + return error + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Interceptor/URLRequestInterceptor.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Interceptor/URLRequestInterceptor.swift new file mode 100644 index 0000000000..e601f186dc --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Interceptor/URLRequestInterceptor.swift @@ -0,0 +1,27 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// A URLRequestInterceptor accepts a request and returns a request. It is invoked +/// during the "prepare" phase of an API operation. +/// +/// A URLRequestInterceptor may use the request as a data source for some other +/// operation (e.g., metrics or logging), or use it as the source for preparing a +/// new request that will be used to fulfill the operation. For example, a +/// URLRequestInterceptor may add custom headers to the request for authorization. +/// +/// URLRequestInterceptors are invoked in the order in which they are added to the +/// plugin. +public protocol URLRequestInterceptor { + + // TODO: turn async https://github.com/aws-amplify/amplify-ios/issues/73 + /// Inspect and optionally modify the request, returning either the original + /// unmodified request or a modified copy. + /// - Parameter request: The URLRequest + func intercept(_ request: URLRequest) async throws -> URLRequest +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Internal/APICategory+CategoryConfigurable.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Internal/APICategory+CategoryConfigurable.swift new file mode 100644 index 0000000000..26e8f1a9e5 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Internal/APICategory+CategoryConfigurable.swift @@ -0,0 +1,34 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +extension APICategory: CategoryConfigurable { + + func configure(using configuration: CategoryConfiguration?) throws { + guard !isConfigured else { + let error = ConfigurationError.amplifyAlreadyConfigured( + "\(categoryType.displayName) has already been configured.", + "Remove the duplicate call to `Amplify.configure()`" + ) + throw error + } + + try Amplify.configure(plugins: Array(plugins.values), using: configuration) + + isConfigured = true + } + + func configure(using amplifyConfiguration: AmplifyConfiguration) throws { + try configure(using: categoryConfiguration(from: amplifyConfiguration)) + } + + func configure(using amplifyOutputs: AmplifyOutputsData) throws { + for plugin in Array(plugins.values) { + try plugin.configure(using: amplifyOutputs) + } + isConfigured = true + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Internal/APICategory+Resettable.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Internal/APICategory+Resettable.swift new file mode 100644 index 0000000000..d99b3ab7f7 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Internal/APICategory+Resettable.swift @@ -0,0 +1,26 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension APICategory: Resettable { + + public func reset() async { + await withTaskGroup(of: Void.self) { taskGroup in + for plugin in plugins.values { + taskGroup.addTask { [weak self] in + self?.log.verbose("Resetting \(String(describing: self?.categoryType)) plugin") + await plugin.reset() + self?.log.verbose("Resetting \(String(describing: self?.categoryType)) plugin: finished") + } + } + await taskGroup.waitForAll() + } + + isConfigured = false + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Operation/AmplifyOperation+APIPublishers.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Operation/AmplifyOperation+APIPublishers.swift new file mode 100644 index 0000000000..83a3d25e76 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Operation/AmplifyOperation+APIPublishers.swift @@ -0,0 +1,84 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#if canImport(Combine) +import Foundation +import Combine + +// MARK: - GraphQLSubscriptionOperation + +public extension GraphQLSubscriptionOperation { + + /// Publishes the state of the GraphQL subscription's underlying network connection. + /// + /// Subscription termination will be reported as a `completion` on the + /// `subscriptionDataPublisher` completion, so this is really only useful if you + /// want to monitor the `.connected` state. + var connectionStatePublisher: AnyPublisher { + // Suppress Void results from the result publisher, but continue to emit + // completions + let transformedResultPublisher = internalResultPublisher + .flatMap { _ in Empty(completeImmediately: true) } + + // Transform the in-process publisher to one that only outputs connectionState events + let transformedInProcessPublisher = internalInProcessPublisher + .compactMap { event -> SubscriptionConnectionState? in + switch event { + case .connection(let state): + return state + default: + return nil + } + } + .setFailureType(to: Failure.self) + + // Now that the publisher signatures match, we can merge them + return transformedResultPublisher + .merge(with: transformedInProcessPublisher) + .eraseToAnyPublisher() + } + + /// Publishes the data received from a GraphQL subscription. + /// + /// The publisher emits `GraphQLResponse` events, which are standard Swift `Result` + /// values that contain either a successfully decoded response value, or a + /// `GraphQLResponseError` describing the reason that a value could not be + /// successfully decoded. Receiving a `.failure` response does not mean the + /// subscription is terminated--the subscription may still receive values, and each + /// value is independently evaluated. Thus, you may see a data stream containing a + /// mix of successfully decoded responses, partially decoded responses, or decoding + /// errors, none of which affect the state of the underlying subscription + /// connection. + /// + /// When the subscription terminates with a cancellation or disconnection, this + /// publisher will receive a `completion`. + var subscriptionDataPublisher: AnyPublisher, Failure> { + // Suppress Void results from the result publisher, but continue to emit completions + let transformedResultPublisher = internalResultPublisher + .flatMap { _ in Empty, Failure>(completeImmediately: true) } + + // Transform the in-process publisher to one that only outputs GraphQLResponse events + let transformedInProcessPublisher = internalInProcessPublisher + .compactMap { event -> GraphQLResponse? in + switch event { + case .data(let result): + return result + default: + return nil + } + } + .setFailureType(to: Failure.self) + + // Now that the publisher signatures match, we can merge them + return transformedResultPublisher + .merge(with: transformedInProcessPublisher) + .eraseToAnyPublisher() + } + +} + +#endif diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Operation/GraphQLOperation.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Operation/GraphQLOperation.swift new file mode 100644 index 0000000000..28ebf62a88 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Operation/GraphQLOperation.swift @@ -0,0 +1,38 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// GraphQL Operation +open class GraphQLOperation: AmplifyOperation< + GraphQLOperationRequest, + GraphQLResponse, + APIError +> { } + +/// GraphQL Subscription Operation +open class GraphQLSubscriptionOperation: AmplifyInProcessReportingOperation< + GraphQLOperationRequest, + GraphQLSubscriptionEvent, + Void, + APIError +> { } + +public extension HubPayload.EventName.API { + /// eventName for HubPayloads emitted by this operation + static let mutate = "API.mutate" + + /// eventName for HubPayloads emitted by this operation + static let query = "API.query" + + /// eventName for HubPayloads emitted by this operation + static let subscribe = "API.subscribe" +} + +public extension GraphQLOperation { + typealias TaskAdapter = AmplifyOperationTaskAdapter +} + +public typealias GraphQLTask = GraphQLOperation.TaskAdapter diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Operation/NondeterminsticOperation.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Operation/NondeterminsticOperation.swift new file mode 100644 index 0000000000..cd17b65fe5 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Operation/NondeterminsticOperation.swift @@ -0,0 +1,100 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + + +import Combine +/** + A non-deterministic operation offers multiple paths to accomplish its task. + It attempts the next path if all preceding paths have failed with an error that allows for continuation. + */ +enum NondeterminsticOperationError: Error { + case totalFailure + case cancelled +} + +final class NondeterminsticOperation { + /// operation that to be eval + typealias Operation = () async throws -> T + typealias OnError = (Error) -> Bool + + private let operations: AsyncStream + private var shouldTryNextOnError: OnError = { _ in true } + private var cancellables = Set() + private var task: Task? + + deinit { + cancel() + } + + init(operations: AsyncStream, shouldTryNextOnError: OnError? = nil) { + self.operations = operations + if let shouldTryNextOnError { + self.shouldTryNextOnError = shouldTryNextOnError + } + } + + convenience init( + operationStream: AnyPublisher, + shouldTryNextOnError: OnError? = nil + ) { + var cancellables = Set() + let (asyncStream, continuation) = AsyncStream.makeStream(of: Operation.self) + operationStream.sink { _ in + continuation.finish() + } receiveValue: { + continuation.yield($0) + }.store(in: &cancellables) + + self.init( + operations: asyncStream, + shouldTryNextOnError: shouldTryNextOnError + ) + self.cancellables = cancellables + } + + /// Synchronous version of executing the operations + func execute() -> Future { + Future { [weak self] promise in + self?.task = Task { [weak self] in + do { + if let self { + promise(.success(try await self.run())) + } else { + promise(.failure(NondeterminsticOperationError.cancelled)) + } + } catch { + promise(.failure(error)) + } + } + } + } + + /// Asynchronous version of executing the operations + func run() async throws -> T { + for await operation in operations { + if Task.isCancelled { + throw NondeterminsticOperationError.cancelled + } + do { + return try await operation() + } catch { + if shouldTryNextOnError(error) { + continue + } else { + throw error + } + } + } + throw NondeterminsticOperationError.totalFailure + } + + /// Cancel the operation + func cancel() { + task?.cancel() + cancellables = Set() + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Operation/RESTOperation.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Operation/RESTOperation.swift new file mode 100644 index 0000000000..f1e4eb911e --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Operation/RESTOperation.swift @@ -0,0 +1,39 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// All HTTP operations have the same underlying Operation type +public protocol RESTOperation: AmplifyOperation { } + +/// Event names for HubPayloads emitted by this operation +public extension HubPayload.EventName.API { + + /// eventName for HubPayloads emitted by this operation + static let delete = "API.delete" + + /// eventName for HubPayloads emitted by this operation + static let get = "API.get" + + /// eventName for HubPayloads emitted by this operation + static let patch = "API.patch" + + /// eventName for HubPayloads emitted by this operation + static let post = "API.post" + + /// eventName for HubPayloads emitted by this operation + static let put = "API.put" + + /// eventName for HubPayloads emitted by this operation + static let head = "API.head" +} + +public extension RESTOperation { + typealias TaskAdapter = AmplifyOperationTaskAdapter +} + +public typealias RESTTask = RESTOperation.TaskAdapter diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Operation/RetryableGraphQLOperation.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Operation/RetryableGraphQLOperation.swift new file mode 100644 index 0000000000..f40229d9f9 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Operation/RetryableGraphQLOperation.swift @@ -0,0 +1,155 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import Combine + + +// MARK: - RetryableGraphQLOperation +public final class RetryableGraphQLOperation { + public typealias Payload = Payload + + private let nondeterminsticOperation: NondeterminsticOperation.Success> + + public init( + requestStream: AnyPublisher<() async throws -> GraphQLTask.Success, Never> + ) { + self.nondeterminsticOperation = NondeterminsticOperation( + operationStream: requestStream, + shouldTryNextOnError: Self.onError(_:) + ) + } + + deinit { + cancel() + } + + static func onError(_ error: Error) -> Bool { + guard let error = error as? APIError, + let authError = error.underlyingError as? AuthError + else { + return false + } + + switch authError { + case .notAuthorized: return true + default: return false + } + } + + public func execute( + _ operationType: GraphQLOperationType + ) -> AnyPublisher.Success, APIError> { + nondeterminsticOperation.execute().mapError { + if let apiError = $0 as? APIError { + return apiError + } else { + return APIError.operationError("Failed to execute GraphQL operation", "", $0) + } + }.eraseToAnyPublisher() + } + + public func run() async -> Result.Success, APIError> { + do { + let result = try await nondeterminsticOperation.run() + return .success(result) + } catch { + if let apiError = error as? APIError { + return .failure(apiError) + } else { + return .failure(.operationError("Failed to execute GraphQL operation", "", error)) + } + } + } + + public func cancel() { + nondeterminsticOperation.cancel() + } + +} + +public final class RetryableGraphQLSubscriptionOperation { + + public typealias Payload = Payload + public typealias SubscriptionEvents = GraphQLSubscriptionEvent + private var task: Task? + private let nondeterminsticOperation: NondeterminsticOperation> + + public init( + requestStream: AnyPublisher<() async throws -> AmplifyAsyncThrowingSequence, Never> + ) { + self.nondeterminsticOperation = NondeterminsticOperation(operationStream: requestStream) + } + + deinit { + cancel() + } + + public func subscribe() -> AnyPublisher { + let subject = PassthroughSubject() + self.task = Task { await self.trySubscribe(subject) } + return subject.eraseToAnyPublisher() + } + + private func trySubscribe(_ subject: PassthroughSubject) async { + var apiError: APIError? + do { + try Task.checkCancellation() + let sequence = try await self.nondeterminsticOperation.run() + defer { sequence.cancel() } + for try await event in sequence { + try Task.checkCancellation() + subject.send(event) + } + } catch is CancellationError { + subject.send(completion: .finished) + } catch { + if let error = error as? APIError { + apiError = error + } + Self.log.debug("Failed with subscription request: \(error)") + } + + if apiError != nil { + subject.send(completion: .failure(apiError!)) + } else { + subject.send(completion: .finished) + } + } + + public func cancel() { + self.task?.cancel() + self.nondeterminsticOperation.cancel() + } +} + +extension AsyncSequence { + fileprivate var asyncStream: AsyncStream { + AsyncStream { continuation in + Task { + var it = self.makeAsyncIterator() + do { + while let ele = try await it.next() { + continuation.yield(ele) + } + continuation.finish() + } catch { + continuation.finish() + } + } + } + } +} + +extension RetryableGraphQLSubscriptionOperation { + public static var log: Logger { + Amplify.Logging.logger(forCategory: CategoryType.api.displayName, forNamespace: String(describing: self)) + } + public var log: Logger { + Self.log + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Reachability/ReachabilityUpdate.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Reachability/ReachabilityUpdate.swift new file mode 100644 index 0000000000..e3bfd93131 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Reachability/ReachabilityUpdate.swift @@ -0,0 +1,20 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Reachability Update +public struct ReachabilityUpdate { + + /// Whether it is online or not + public let isOnline: Bool + + /// Initializer with initial online state + public init(isOnline: Bool) { + self.isOnline = isOnline + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Request/GraphQLOperationRequest.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Request/GraphQLOperationRequest.swift new file mode 100644 index 0000000000..2f5ebf1ed2 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Request/GraphQLOperationRequest.swift @@ -0,0 +1,63 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// GraphQL Operation Request +public struct GraphQLOperationRequest: AmplifyOperationRequest { + /// The name of the API to perform the request against + public let apiName: String? + + /// The GraphQL operation type + public let operationType: GraphQLOperationType + + /// The GraphQL query document used for the operation + public let document: String + + /// The GraphQL variables used for the operation + public let variables: [String: Any]? + + /// The type to decode to + public let responseType: R.Type + + /// The path to traverse before decoding to `responseType`. + public let decodePath: String? + + /// The authorization mode + public let authMode: AuthorizationMode? + + /// Options to adjust the behavior of this request, including plugin-options + public let options: Options + + /// Initializer for GraphQLOperationRequest + public init(apiName: String?, + operationType: GraphQLOperationType, + document: String, + variables: [String: Any]? = nil, + responseType: R.Type, + decodePath: String? = nil, + authMode: AuthorizationMode? = nil, + options: Options) { + self.apiName = apiName + self.operationType = operationType + self.document = document + self.variables = variables + self.responseType = responseType + self.decodePath = decodePath + self.authMode = authMode + self.options = options + } +} + +// MARK: GraphQLOperationRequest + Options +public extension GraphQLOperationRequest { + struct Options { + public let pluginOptions: Any? + + public init(pluginOptions: Any?) { + self.pluginOptions = pluginOptions + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Request/GraphQLOperationType.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Request/GraphQLOperationType.swift new file mode 100644 index 0000000000..7e9e2735ed --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Request/GraphQLOperationType.swift @@ -0,0 +1,33 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// The type of a GraphQL operation +public enum GraphQLOperationType: String { + /// A GraphQL Query operation + case query + + /// A GraphQL Mutation operation + case mutation + + /// A GraphQL Subscription operation + case subscription +} + +extension GraphQLOperationType: HubPayloadEventNameConvertible { + + /// Corresponding hub event name for this type of operation. + public var hubEventName: String { + switch self { + case .query: + return HubPayload.EventName.API.query + case .mutation: + return HubPayload.EventName.API.mutate + case .subscription: + return HubPayload.EventName.API.subscribe + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Request/GraphQLRequest.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Request/GraphQLRequest.swift new file mode 100644 index 0000000000..ba0086de66 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Request/GraphQLRequest.swift @@ -0,0 +1,65 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// Empty protocol for plugins to define specific `AuthorizationMode` types for the request. +public protocol AuthorizationMode { } + +/// GraphQL Request +public struct GraphQLRequest { + + /// The name of graphQL API being invoked, as specified in `amplifyconfiguration.json`. + /// Specify this parameter when more than one GraphQL API is configured. + public let apiName: String? + + /// Query document + public let document: String + + /// Query variables + public let variables: [String: Any]? + + /// Type to decode the graphql response data object to + public let responseType: R.Type + + /// The authorization mode + public let authMode: AuthorizationMode? + + /// The path to decode to the graphQL response data to `responseType`. Delimited by `.` The decode path + /// "listTodos.items" will traverse to the object at `listTodos`, and decode the object at `items` to `responseType` + /// The data at that decode path is a list of Todo objects so `responseType` should be `[Todo].self` + public let decodePath: String? + + /// Options to adjust the behavior of this request, including plugin-options + public var options: Options? + + public init(apiName: String? = nil, + document: String, + variables: [String: Any]? = nil, + responseType: R.Type, + decodePath: String? = nil, + authMode: AuthorizationMode? = nil, + options: GraphQLRequest.Options? = nil) { + self.apiName = apiName + self.document = document + self.variables = variables + self.responseType = responseType + self.authMode = authMode + self.decodePath = decodePath + self.options = options + } +} + +// MARK: GraphQLRequest + Options + +public extension GraphQLRequest { + struct Options { + public let pluginOptions: Any? + + public init(pluginOptions: Any?) { + self.pluginOptions = pluginOptions + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Request/RESTOperationRequest.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Request/RESTOperationRequest.swift new file mode 100644 index 0000000000..b1e5deaf8e --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Request/RESTOperationRequest.swift @@ -0,0 +1,59 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// REST Operation Request +public struct RESTOperationRequest: AmplifyOperationRequest { + + /// The name of the API to perform the request against + public let apiName: String? + + /// The type of HTTP operation + public let operationType: RESTOperationType + + /// path of the resource + public let path: String? + + /// Request headers + public let headers: [String: String]? + + /// Query parameters + public let queryParameters: [String: String]? + + /// Content body + public let body: Data? + + /// Options to adjust the behavior of this request, including plugin-options + public let options: Options + + /// Initializer with all properties + public init(apiName: String?, + operationType: RESTOperationType, + path: String? = nil, + headers: [String: String]? = nil, + queryParameters: [String: String]? = nil, + body: Data? = nil, + options: Options) { + self.apiName = apiName + self.operationType = operationType + self.path = path + self.headers = headers + self.queryParameters = queryParameters + self.body = body + self.options = options + } +} + +/// REST Operation Request options extension +public extension RESTOperationRequest { + struct Options { + + /// Empty initializer + public init() { } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Request/RESTOperationType.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Request/RESTOperationType.swift new file mode 100644 index 0000000000..9bb9283ff8 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Request/RESTOperationType.swift @@ -0,0 +1,51 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// The type of API operation +public enum RESTOperationType: String { + + /// GET operation + case get = "GET" + + /// PUT operation + case put = "PUT" + + /// POST operation + case post = "POST" + + /// PATCH operation + case patch = "PATCH" + + /// DELETE operation + case delete = "DELETE" + + /// HEAD operation + case head = "HEAD" +} + +extension RESTOperationType: HubPayloadEventNameConvertible { + + /// Hub event name for this operation type. + public var hubEventName: String { + switch self { + case .get: + return HubPayload.EventName.API.get + case .put: + return HubPayload.EventName.API.put + case .post: + return HubPayload.EventName.API.post + case .patch: + return HubPayload.EventName.API.patch + case .delete: + return HubPayload.EventName.API.delete + case .head: + return HubPayload.EventName.API.head + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Request/RESTRequest.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Request/RESTRequest.swift new file mode 100644 index 0000000000..5af207568e --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Request/RESTRequest.swift @@ -0,0 +1,45 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// REST Request +public class RESTRequest { + + /// The name of REST API being invoked, as specified in `amplifyconfiguration.json`. + /// Specify this parameter when more than one REST API is configured. + public let apiName: String? + + /// Path of the resource + public let path: String? + + /// Headers + public let headers: [String: String]? + + /// Query parameters + public let queryParameters: [String: String]? + + /// Body content + public let body: Data? + + /// Initializer with all properties + public init(apiName: String? = nil, + path: String? = nil, + headers: [String: String]? = nil, + queryParameters: [String: String]? = nil, + body: Data? = nil) { + let inputHeaders = headers ?? [:] + self.headers = inputHeaders.merging( + ["Cache-Control": "no-store"], + uniquingKeysWith: { current, _ in current} + ) + self.apiName = apiName + self.path = path + self.queryParameters = queryParameters + self.body = body + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Response/GraphQLError.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Response/GraphQLError.swift new file mode 100644 index 0000000000..7d1d21f104 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Response/GraphQLError.swift @@ -0,0 +1,48 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// The error format according to https://graphql.github.io/graphql-spec/June2018/#sec-Errors +public struct GraphQLError: Decodable { + + /// Description of the error + public let message: String + + /// list of locations describing the syntax element + public let locations: [Location]? + + /// Details the path of the response field with error. The values are either strings or 0-index integers + public let path: [JSONValue]? + + /// Additional map of of errors + public let extensions: [String: JSONValue]? + + /// Initializer with all properties + public init(message: String, + locations: [Location]? = nil, + path: [JSONValue]? = nil, + extensions: [String: JSONValue]? = nil) { + self.message = message + self.locations = locations + self.path = path + self.extensions = extensions + } +} + +extension GraphQLError { + + /// Both `line` and `column` are positive numbers describing the beginning of an associated syntax element + public struct Location: Decodable { + + /// The line describing the associated syntax element + public let line: Int + + /// The column describing the associated syntax element + public let column: Int + } +} + +extension GraphQLError: Error { } diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Response/GraphQLResponse.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Response/GraphQLResponse.swift new file mode 100644 index 0000000000..295dbc9459 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Response/GraphQLResponse.swift @@ -0,0 +1,90 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Raw GraphQL Response String +public typealias RawGraphQLResponse = String + +/// GraphQL Response Result +public typealias GraphQLResponse = + Result> + +/// An error response from a GraphQL API +public enum GraphQLResponseError: AmplifyError { + + /// An error response. The associated value will be an array of GraphQLError objects that contain service-specific + /// error messages. https://graphql.github.io/graphql-spec/June2018/#sec-Errors + case error([GraphQLError]) + + /// A partially-successful response. The `ResponseType` associated value will contain as much of the payload as the + /// service was able to fulfill, and the errors will be an array of GraphQLError that contain service-specific error + /// messages. + case partial(ResponseType, [GraphQLError]) + + /// A successful, or partially-successful response from the server that could not be transformed into the specified + /// response type. The RawGraphQLResponse contains the entire response from the service, including data and errors. + case transformationError(RawGraphQLResponse, APIError) + + /// An unknown error occurred + case unknown(ErrorDescription, RecoverySuggestion, Error?) + + public var errorDescription: ErrorDescription { + switch self { + case .error(let errors): + return "GraphQL service returned a successful response containing errors: \(errors)" + case .partial(_, let errors): + return "GraphQL service returned a partially-successful response containing errors: \(errors)" + case .transformationError: + return "Failed to decode GraphQL response to the `ResponseType` \(String(describing: ResponseType.self))" + case .unknown(let errorDescription, _, _): + return errorDescription + } + } + + public var recoverySuggestion: RecoverySuggestion { + switch self { + case .error: + return "The list of `GraphQLError` contains service-specific messages" + case .partial: + return "The list of `GraphQLError` contains service-specific messages." + case .transformationError: + return """ + Failed to transform to `ResponseType`. + Take a look at the `RawGraphQLResponse` and underlying error to see where it failed to decode. + """ + case .unknown(_, let recoverySuggestion, _): + return recoverySuggestion + } + } + + public var underlyingError: Error? { + switch self { + case .error: + return nil + case .partial: + return nil + case .transformationError(_, let error): + return error + case .unknown(_, _, let error): + return error + } + } + + public init( + errorDescription: ErrorDescription = "An unknown error occurred", + recoverySuggestion: RecoverySuggestion = "See `underlyingError` for more details", + error: Error + ) { + if let error = error as? Self { + self = error + } else { + self = .unknown(errorDescription, recoverySuggestion, error) + } + } + +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Response/SubscriptionConnectionState.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Response/SubscriptionConnectionState.swift new file mode 100644 index 0000000000..5205fe2a67 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Response/SubscriptionConnectionState.swift @@ -0,0 +1,21 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// Subscription Connection State +public enum SubscriptionConnectionState { + + /// The subscription is in process of connecting + case connecting + + /// The subscription has connected and is receiving events from the service + case connected + + /// The subscription has been disconnected because of a lifecycle event or manual disconnect request + case disconnected +} + +extension SubscriptionConnectionState: Sendable { } diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Response/SubscriptionEvent.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Response/SubscriptionEvent.swift new file mode 100644 index 0000000000..eca6747532 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Response/SubscriptionEvent.swift @@ -0,0 +1,17 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// Event for subscription +public enum GraphQLSubscriptionEvent { + /// The subscription's connection state has changed. + case connection(SubscriptionConnectionState) + + /// The subscription received data. + case data(GraphQLResponse) +} + +extension GraphQLSubscriptionEvent: Sendable { } diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Analytics/AnalyticsCategory+ClientBehavior.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Analytics/AnalyticsCategory+ClientBehavior.swift new file mode 100644 index 0000000000..e73abe1c15 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Analytics/AnalyticsCategory+ClientBehavior.swift @@ -0,0 +1,62 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +extension AnalyticsCategory: AnalyticsCategoryBehavior { + public func identifyUser(userId: String, userProfile: AnalyticsUserProfile? = nil) { + plugin.identifyUser(userId: userId, userProfile: userProfile) + } + + public func record(event: AnalyticsEvent) { + plugin.record(event: event) + } + + public func record(eventWithName eventName: String) { + plugin.record(eventWithName: eventName) + } + + public func registerGlobalProperties(_ properties: AnalyticsProperties) { + plugin.registerGlobalProperties(properties) + } + + public func unregisterGlobalProperties(_ keys: Set? = nil) { + plugin.unregisterGlobalProperties(keys) + } + + public func flushEvents() { + plugin.flushEvents() + } + + public func enable() { + plugin.enable() + } + + public func disable() { + plugin.disable() + } +} + +/// Methods that wrap `AnalyticsCategoryBehavior` to provides additional useful calling patterns +extension AnalyticsCategory { + + /// Registered global properties can be unregistered though this method. In case no keys are provided, *all* + /// registered global properties will be unregistered. Duplicate keys will be ignored. This method can be called + /// from `Amplify.Analytics` and is a wrapper for `unregisterGlobalProperties(_ keys: Set? = nil)` + /// + /// - Parameter keys: one or more of property names to unregister + public func unregisterGlobalProperties(_ keys: String...) { + plugin.unregisterGlobalProperties(keys.isEmpty ? nil : Set(keys)) + } + + /// Registered global properties can be unregistered though this method. In case no keys are provided, *all* + /// registered global properties will be unregistered. Duplicate keys will be ignored. This method can be called + /// from `Amplify.Analytics` and is a wrapper for `unregisterGlobalProperties(_ keys: Set? = nil)` + /// + /// - Parameter keys: an array of property names to unregister + public func unregisterGlobalProperties(_ keys: [String]) { + plugin.unregisterGlobalProperties(keys.isEmpty ? nil : Set(keys)) + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Analytics/AnalyticsCategory+HubPayloadEventName.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Analytics/AnalyticsCategory+HubPayloadEventName.swift new file mode 100644 index 0000000000..c9b9b86044 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Analytics/AnalyticsCategory+HubPayloadEventName.swift @@ -0,0 +1,20 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +public extension HubPayload.EventName { + /// Analytics hub events + struct Analytics { } +} + +public extension HubPayload.EventName.Analytics { + /// identifyUser event + static let identifyUser = "Analytics.identifyUser" + /// record event + static let record = "Analytics.record" + /// flushEvents event + static let flushEvents = "Analytics.flushEvents" +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Analytics/AnalyticsCategory.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Analytics/AnalyticsCategory.swift new file mode 100644 index 0000000000..4697d51685 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Analytics/AnalyticsCategory.swift @@ -0,0 +1,107 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// The Analytics category enables you to collect analytics data for your app. +final public class AnalyticsCategory: Category { + /// Analytics category type + public let categoryType = CategoryType.analytics + + /// Analytics category plugins + var plugins = [PluginKey: AnalyticsCategoryPlugin]() + + /// Returns the plugin added to the category, if only one plugin is added. Accessing this property if no plugins + /// are added, or if more than one plugin is added, will cause a preconditionFailure. + var plugin: AnalyticsCategoryPlugin { + guard isConfigured else { + return Fatal.preconditionFailure( + """ + \(categoryType.displayName) category is not configured. Call Amplify.configure() before using \ + any methods on the category. + """ + ) + } + + guard !plugins.isEmpty else { + return Fatal.preconditionFailure("No plugins added to \(categoryType.displayName) category.") + } + + guard plugins.count == 1, let plugin = plugins.first?.value else { + return Fatal.preconditionFailure( + """ + More than 1 plugin added to \(categoryType.displayName) category. \ + You must invoke operations on this category by getting the plugin you want, as in: + #"Amplify.\(categoryType.displayName).getPlugin(for: "ThePluginKey").foo() + """ + ) + } + + return plugin + } + + var isConfigured = false + + // MARK: - Plugin handling + + /// Adds `plugin` to the list of Plugins that implement functionality for this category. + /// + /// - Parameter plugin: The Plugin to add + public func add(plugin: AnalyticsCategoryPlugin) throws { + log.debug("Adding plugin: \(String(describing: plugin))") + let key = plugin.key + guard !key.isEmpty else { + let pluginDescription = String(describing: plugin) + let error = AnalyticsError.configuration( + "Plugin \(pluginDescription) has an empty `key`.", + "Set the `key` property for \(String(describing: plugin))") + throw error + } + + guard !isConfigured else { + let pluginDescription = String(describing: plugin) + let error = ConfigurationError.amplifyAlreadyConfigured( + "\(pluginDescription) cannot be added after `Amplify.configure()`.", + "Do not add plugins after calling `Amplify.configure()`." + ) + throw error + } + + plugins[plugin.key] = plugin + } + + /// Returns the added plugin with the specified `key` property. + /// + /// - Parameter key: The PluginKey (String) of the plugin to retrieve + /// - Returns: The wrapped plugin + public func getPlugin(for key: PluginKey) throws -> AnalyticsCategoryPlugin { + guard let plugin = plugins[key] else { + let keys = plugins.keys.joined(separator: ", ") + let error = AnalyticsError.configuration( + "No plugin has been added for '\(key)'.", + "Either add a plugin for '\(key)', or use one of the known keys: \(keys)") + throw error + } + return plugin + } + + /// Removes the plugin registered for `key` from the list of Plugins that implement functionality for this category. + /// If no plugin has been added for `key`, no action is taken, making this method safe to call multiple times. + /// + /// - Parameter key: The key used to `add` the plugin + public func removePlugin(for key: PluginKey) { + plugins.removeValue(forKey: key) + } + +} + +extension AnalyticsCategory: DefaultLogger { + public static var log: Logger { + Amplify.Logging.logger(forCategory: CategoryType.analytics.displayName, forNamespace: String(describing: self)) + } + public var log: Logger { + Self.log + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Analytics/AnalyticsCategoryBehavior.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Analytics/AnalyticsCategoryBehavior.swift new file mode 100644 index 0000000000..0c8918c4cd --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Analytics/AnalyticsCategoryBehavior.swift @@ -0,0 +1,61 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Analytics category properties +public typealias AnalyticsProperties = [String: AnalyticsPropertyValue] + +/// Behavior of the Analytics category that clients will use +public protocol AnalyticsCategoryBehavior { + + /// Allows you to tie a user to their actions and record traits about them. It includes + /// an unique User ID and any optional traits you know about them like their email, name, etc. + /// + /// - Parameter userId: The unique identifier for the user + /// - Parameter userProfile: User specific data (e.g. plan, accountType, email, age, location, etc) + func identifyUser(userId: String, userProfile: AnalyticsUserProfile?) + + /// Record the actions your users perform. Every action triggers what we call an “event”, + /// which can also have associated properties. + /// + /// - Parameter event: the event data. The way it is recorded depends on the service being used. + func record(event: AnalyticsEvent) + + /// Utility to create an event from a string. + /// + /// - Parameter eventName: The name of the event. + func record(eventWithName eventName: String) + + /// Register properties that will be recorded by all the subsequent `recordEvent` call. + /// Properties registered here can be overridden by the ones with the same + /// name when calling `record`. Examples of global properties would be `selectedPlan`, `campaignSource` + /// + /// - Parameter properties: The dictionary of property name to property values + func registerGlobalProperties(_ properties: AnalyticsProperties) + + /// Registered global properties can be unregistered though this method. In case no keys are provided, *all* + /// registered global properties will be unregistered. + /// + /// - Parameter keys: a set of property names to unregister + func unregisterGlobalProperties(_ keys: Set?) + + /// Attempts to submit the locally stored events to the underlying service. Implementations do not guarantee that + /// all the stored data will be sent in one request. Some analytics services have hard limits on how much data + /// you can send at once. + func flushEvents() + + /// Enable the analytics data collection. Useful to implement flows that require users to *opt-in*. + func enable() + + /// Disable the analytics data collection. Useful to implement flows that allow users to *opt-out*. + /// + /// Some countries (e.g. countries in the EU) and/or audience (e.g. children) have specific rules + /// regarding user data collection, therefore implementation of this category must always offer the + /// possibility of disabling the data collection. + func disable() +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Analytics/AnalyticsCategoryConfiguration.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Analytics/AnalyticsCategoryConfiguration.swift new file mode 100644 index 0000000000..38e3ec97a4 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Analytics/AnalyticsCategoryConfiguration.swift @@ -0,0 +1,20 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Analytics category configuration +public struct AnalyticsCategoryConfiguration: CategoryConfiguration { + /// Plugins + public let plugins: [String: JSONValue] + + /// Initializer + /// - Parameter plugins: Plugins + public init(plugins: [String: JSONValue] = [:]) { + self.plugins = plugins + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Analytics/AnalyticsCategoryPlugin.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Analytics/AnalyticsCategoryPlugin.swift new file mode 100644 index 0000000000..2f3a3156b8 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Analytics/AnalyticsCategoryPlugin.swift @@ -0,0 +1,16 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// Analytics category plugin +public protocol AnalyticsCategoryPlugin: Plugin, AnalyticsCategoryBehavior { } + +public extension AnalyticsCategoryPlugin { + /// Analytics category type + var categoryType: CategoryType { + return .analytics + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Analytics/AnalyticsProfile.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Analytics/AnalyticsProfile.swift new file mode 100644 index 0000000000..d4f1d6e3e4 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Analytics/AnalyticsProfile.swift @@ -0,0 +1,58 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// User specific data +public struct AnalyticsUserProfile { + + /// Name of the user + public var name: String? + + /// The user's email + public var email: String? + + /// The plan for the user + public var plan: String? + + /// Location data about the user + public var location: Location? + + /// Properties of the user profile + public var properties: AnalyticsProperties? + + /// Initializer + /// - Parameters: + /// - name: Name of user + /// - email: The user's e-mail + /// - plan: The plan for the user + /// - location: Location data about the user + /// - properties: Properties of the user profile + public init(name: String? = nil, + email: String? = nil, + plan: String? = nil, + location: Location? = nil, + properties: AnalyticsProperties? = nil) { + self.name = name + self.email = email + self.plan = plan + self.location = location + self.properties = properties + } +} + +extension AnalyticsUserProfile { + + /// Location specific data + public typealias Location = UserProfileLocation +} + +extension AnalyticsUserProfile: UserProfile { + public var customProperties: [String: UserProfilePropertyValue]? { + properties as? [String: UserProfilePropertyValue] + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Analytics/AnalyticsPropertyValue.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Analytics/AnalyticsPropertyValue.swift new file mode 100644 index 0000000000..e7ef8a38be --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Analytics/AnalyticsPropertyValue.swift @@ -0,0 +1,16 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Analytics properties can store values of common types +public protocol AnalyticsPropertyValue {} + +extension String: AnalyticsPropertyValue {} +extension Int: AnalyticsPropertyValue {} +extension Double: AnalyticsPropertyValue {} +extension Bool: AnalyticsPropertyValue {} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Analytics/Error/AnalyticsError.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Analytics/Error/AnalyticsError.swift new file mode 100644 index 0000000000..bfcf791c4b --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Analytics/Error/AnalyticsError.swift @@ -0,0 +1,68 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Analytics Error +public enum AnalyticsError { + /// Configuration Error + case configuration(ErrorDescription, RecoverySuggestion, Error? = nil) + /// Unknown Error + case unknown(ErrorDescription, Error? = nil) +} + +extension AnalyticsError: AmplifyError { + /// Error Description + public var errorDescription: ErrorDescription { + switch self { + case .configuration(let errorDescription, _, _): + return errorDescription + case .unknown(let errorDescription, _): + return "Unexpected error occurred with message: \(errorDescription)" + } + } + + /// Recovery Suggestion + public var recoverySuggestion: RecoverySuggestion { + switch self { + case .configuration(_, let recoverySuggestion, _): + return recoverySuggestion + case .unknown: + return AmplifyErrorMessages.shouldNotHappenReportBugToAWS() + } + } + + /// Underlying Error + public var underlyingError: Error? { + switch self { + case .configuration(_, _, let error): + return error + case .unknown(_, let error): + return error + } + } + + /// Initializer + /// - Parameters: + /// - errorDescription: Error Description + /// - recoverySuggestion: Recovery Suggestion + /// - error: Underlying Error + public init( + errorDescription: ErrorDescription = "An unknown error occurred", + recoverySuggestion: RecoverySuggestion = "(Ignored)", + error: Error + ) { + if let error = error as? Self { + self = error + } else if error.isOperationCancelledError { + self = .unknown("Operation cancelled", error) + } else { + self = .unknown(errorDescription, error) + } + } + +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Analytics/Event/AnalyticsEvent.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Analytics/Event/AnalyticsEvent.swift new file mode 100644 index 0000000000..e6871ea27c --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Analytics/Event/AnalyticsEvent.swift @@ -0,0 +1,18 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Analytics event +public protocol AnalyticsEvent { + + /// Name of the event + var name: String { get } + + // Properties of the event + var properties: AnalyticsProperties? { get } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Analytics/Event/BasicAnalyticsEvent.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Analytics/Event/BasicAnalyticsEvent.swift new file mode 100644 index 0000000000..2e2792a153 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Analytics/Event/BasicAnalyticsEvent.swift @@ -0,0 +1,28 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Basic analytics event +public struct BasicAnalyticsEvent: AnalyticsEvent { + + /// The name of the event + public var name: String + + /// Properties of the event + public var properties: AnalyticsProperties? + + /// Initializer + /// - Parameters: + /// - name: The name of the event + /// - properties: Properties of the event + public init(name: String, + properties: AnalyticsProperties? = nil) { + self.name = name + self.properties = properties + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Analytics/Internal/AnalyticsCategory+CategoryConfigurable.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Analytics/Internal/AnalyticsCategory+CategoryConfigurable.swift new file mode 100644 index 0000000000..3978b10615 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Analytics/Internal/AnalyticsCategory+CategoryConfigurable.swift @@ -0,0 +1,34 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +extension AnalyticsCategory: CategoryConfigurable { + + func configure(using configuration: CategoryConfiguration?) throws { + guard !isConfigured else { + let error = ConfigurationError.amplifyAlreadyConfigured( + "\(categoryType.displayName) has already been configured.", + "Remove the duplicate call to `Amplify.configure()`" + ) + throw error + } + + try Amplify.configure(plugins: Array(plugins.values), using: configuration) + + isConfigured = true + } + + func configure(using amplifyConfiguration: AmplifyConfiguration) throws { + try configure(using: categoryConfiguration(from: amplifyConfiguration)) + } + + func configure(using amplifyOutputs: AmplifyOutputsData) throws { + for plugin in Array(plugins.values) { + try plugin.configure(using: amplifyOutputs) + } + isConfigured = true + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Analytics/Internal/AnalyticsCategory+Resettable.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Analytics/Internal/AnalyticsCategory+Resettable.swift new file mode 100644 index 0000000000..32994d4f06 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Analytics/Internal/AnalyticsCategory+Resettable.swift @@ -0,0 +1,25 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension AnalyticsCategory: Resettable { + + public func reset() async { + await withTaskGroup(of: Void.self) { taskGroup in + for plugin in plugins.values { + taskGroup.addTask { [weak self] in + self?.log.verbose("Resetting \(String(describing: self?.categoryType)) plugin") + await plugin.reset() + self?.log.verbose("Resetting \(String(describing: self?.categoryType)) plugin: finished") + } + } + await taskGroup.waitForAll() + } + isConfigured = false + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/AuthCategory+ClientBehavior.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/AuthCategory+ClientBehavior.swift new file mode 100644 index 0000000000..8896be20ea --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/AuthCategory+ClientBehavior.swift @@ -0,0 +1,107 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension AuthCategory: AuthCategoryBehavior { + + public func signUp( + username: String, + password: String? = nil, + options: AuthSignUpRequest.Options? = nil + ) async throws -> AuthSignUpResult { + return try await plugin.signUp(username: username, password: password, options: options) + } + + public func confirmSignUp(for username: String, + confirmationCode: String, + options: AuthConfirmSignUpRequest.Options? = nil) async throws -> AuthSignUpResult { + return try await plugin.confirmSignUp(for: username, confirmationCode: confirmationCode, options: options) + } + + public func resendSignUpCode( + for username: String, + options: AuthResendSignUpCodeRequest.Options? = nil + ) async throws -> AuthCodeDeliveryDetails { + return try await plugin.resendSignUpCode(for: username, options: options) + } + + public func signIn(username: String? = nil, + password: String? = nil, + options: AuthSignInRequest.Options? = nil) async throws -> AuthSignInResult { + return try await plugin.signIn(username: username, password: password, options: options) + } + +#if os(iOS) || os(macOS) + public func signInWithWebUI( + presentationAnchor: AuthUIPresentationAnchor? = nil, + options: AuthWebUISignInRequest.Options? = nil) async throws -> AuthSignInResult { + return try await plugin.signInWithWebUI(presentationAnchor: presentationAnchor, options: options) + } + + public func signInWithWebUI( + for authProvider: AuthProvider, + presentationAnchor: AuthUIPresentationAnchor? = nil, + options: AuthWebUISignInRequest.Options? = nil) async throws -> AuthSignInResult { + return try await plugin.signInWithWebUI(for: authProvider, + presentationAnchor: presentationAnchor, + options: options) + } +#endif + + public func confirmSignIn( + challengeResponse: String, + options: AuthConfirmSignInRequest.Options? = nil + ) async throws -> AuthSignInResult { + return try await plugin.confirmSignIn(challengeResponse: challengeResponse, options: options) + } + + public func signOut(options: AuthSignOutRequest.Options? = nil) async -> AuthSignOutResult { + return await plugin.signOut(options: options) + } + + public func deleteUser() async throws { + try await plugin.deleteUser() + } + + public func fetchAuthSession(options: AuthFetchSessionRequest.Options? = nil) async throws -> AuthSession { + return try await plugin.fetchAuthSession(options: options) + } + + public func resetPassword( + for username: String, + options: AuthResetPasswordRequest.Options? = nil + ) async throws -> AuthResetPasswordResult { + return try await plugin.resetPassword(for: username, options: options) + } + + public func confirmResetPassword( + for username: String, with + newPassword: String, + confirmationCode: String, + options: AuthConfirmResetPasswordRequest.Options? = nil + ) async throws { + try await plugin.confirmResetPassword( + for: username, + with: newPassword, + confirmationCode: confirmationCode, + options: options + ) + } + + public func setUpTOTP() async throws -> TOTPSetupDetails { + try await plugin.setUpTOTP() + } + + public func verifyTOTPSetup( + code: String, + options: VerifyTOTPSetupRequest.Options? = nil + ) async throws { + try await plugin.verifyTOTPSetup(code: code, options: options) + } + +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/AuthCategory+DeviceBehavior.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/AuthCategory+DeviceBehavior.swift new file mode 100644 index 0000000000..a9da3b0099 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/AuthCategory+DeviceBehavior.swift @@ -0,0 +1,29 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension AuthCategory: AuthCategoryDeviceBehavior { + + public func fetchDevices( + options: AuthFetchDevicesRequest.Options? = nil + ) async throws -> [AuthDevice] { + return try await plugin.fetchDevices(options: options) + } + + public func forgetDevice( + _ device: AuthDevice? = nil, + options: AuthForgetDeviceRequest.Options? = nil + ) async throws { + try await plugin.forgetDevice(device, options: options) + } + + public func rememberDevice(options: AuthRememberDeviceRequest.Options? = nil) async throws { + try await plugin.rememberDevice(options: options) + } + +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/AuthCategory+HubPayloadEventName.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/AuthCategory+HubPayloadEventName.swift new file mode 100644 index 0000000000..73684d72c7 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/AuthCategory+HubPayloadEventName.swift @@ -0,0 +1,10 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +public extension HubPayload.EventName { + struct Auth { } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/AuthCategory+UserBehavior.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/AuthCategory+UserBehavior.swift new file mode 100644 index 0000000000..6589e03083 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/AuthCategory+UserBehavior.swift @@ -0,0 +1,68 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension AuthCategory: AuthCategoryUserBehavior { + + public func getCurrentUser() async throws -> AuthUser { + try await plugin.getCurrentUser() + } + + public func fetchUserAttributes( + options: AuthFetchUserAttributesRequest.Options? = nil + ) async throws -> [AuthUserAttribute] { + try await plugin.fetchUserAttributes(options: options) + } + + public func update( + userAttribute: AuthUserAttribute, + options: AuthUpdateUserAttributeRequest.Options? = nil + ) async throws -> AuthUpdateAttributeResult { + try await plugin.update(userAttribute: userAttribute, options: options) + } + + public func update(userAttributes: [AuthUserAttribute], + options: AuthUpdateUserAttributesRequest.Options? = nil) + async throws -> [AuthUserAttributeKey: AuthUpdateAttributeResult] { + try await plugin.update(userAttributes: userAttributes, options: options) + } + + @available(*, deprecated, renamed: "sendVerificationCode(forUserAttributeKey:options:)") + public func resendConfirmationCode( + forUserAttributeKey userAttributeKey: AuthUserAttributeKey, + options: AuthAttributeResendConfirmationCodeRequest.Options? = nil + ) async throws -> AuthCodeDeliveryDetails { + try await plugin.resendConfirmationCode(forUserAttributeKey: userAttributeKey, options: options) + } + + public func sendVerificationCode( + forUserAttributeKey userAttributeKey: AuthUserAttributeKey, + options: AuthSendUserAttributeVerificationCodeRequest.Options? = nil + ) async throws -> AuthCodeDeliveryDetails { + try await plugin.sendVerificationCode(forUserAttributeKey: userAttributeKey, options: options) + } + + public func confirm(userAttribute: AuthUserAttributeKey, + confirmationCode: String, + options: AuthConfirmUserAttributeRequest.Options? = nil) async throws { + try await plugin.confirm( + userAttribute: userAttribute, + confirmationCode: confirmationCode, + options: options + ) + } + + public func update( + oldPassword: String, + to newPassword: String, + options: AuthChangePasswordRequest.Options? = nil + ) async throws { + try await plugin.update(oldPassword: oldPassword, to: newPassword, options: options) + } + +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/AuthCategory.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/AuthCategory.swift new file mode 100644 index 0000000000..04fb854778 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/AuthCategory.swift @@ -0,0 +1,101 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +final public class AuthCategory: Category { + + public let categoryType = CategoryType.auth + + var plugins = [PluginKey: AuthCategoryPlugin]() + + /// Returns the plugin added to the category, if only one plugin is added. Accessing this property if no plugins + /// are added, or if more than one plugin is added, will cause a preconditionFailure. + var plugin: AuthCategoryPlugin { + guard isConfigured else { + return Fatal.preconditionFailure( + """ + \(categoryType.displayName) category is not configured. Call Amplify.configure() before using \ + any methods on the category. + """ + ) + } + + guard !plugins.isEmpty else { + return Fatal.preconditionFailure("No plugins added to \(categoryType.displayName) category.") + } + + guard plugins.count == 1 else { + return Fatal.preconditionFailure( + """ + More than 1 plugin added to \(categoryType.displayName) category. \ + You must invoke operations on this category by getting the plugin you want, as in: + #"Amplify.\(categoryType.displayName).getPlugin(for: "ThePluginKey").foo() + """ + ) + } + + return plugins.first!.value + } + + var isConfigured = false + + // MARK: - Plugin handling + + /// Adds `plugin` to the list of Plugins that implement functionality for this category. + /// + /// - Parameter plugin: The Plugin to add + public func add(plugin: AuthCategoryPlugin) throws { + let key = plugin.key + guard !key.isEmpty else { + let pluginDescription = String(describing: plugin) + let error = AuthError.configuration("Plugin \(pluginDescription) has an empty `key`.", + "Set the `key` property for \(String(describing: plugin))") + throw error + } + + guard !isConfigured else { + let pluginDescription = String(describing: plugin) + let error = ConfigurationError.amplifyAlreadyConfigured( + "\(pluginDescription) cannot be added after `Amplify.configure()`.", + "Do not add plugins after calling `Amplify.configure()`." + ) + throw error + } + + plugins[plugin.key] = plugin + } + + /// Returns the added plugin with the specified `key` property. + /// + /// - Parameter key: The PluginKey (String) of the plugin to retrieve + /// - Returns: The wrapped plugin + public func getPlugin(for key: PluginKey) throws -> AuthCategoryPlugin { + guard let plugin = plugins[key] else { + let keys = plugins.keys.joined(separator: ", ") + let error = AuthError.configuration("No plugin has been added for '\(key)'.", + "Either add a plugin for '\(key)', or use one of the known keys: \(keys)") + throw error + } + return plugin + } + + /// Removes the plugin registered for `key` from the list of Plugins that implement functionality for this category. + /// If no plugin has been added for `key`, no action is taken, making this method safe to call multiple times. + /// + /// - Parameter key: The key used to `add` the plugin + public func removePlugin(for key: PluginKey) { + plugins.removeValue(forKey: key) + } +} + +extension AuthCategory: DefaultLogger { + public static var log: Logger { + Amplify.Logging.logger(forCategory: CategoryType.auth.displayName, forNamespace: String(describing: self)) + } + public var log: Logger { + Self.log + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/AuthCategoryBehavior.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/AuthCategoryBehavior.swift new file mode 100644 index 0000000000..68f6cc2f7f --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/AuthCategoryBehavior.swift @@ -0,0 +1,165 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +#if os(iOS) || os(macOS) +import AuthenticationServices +public typealias AuthUIPresentationAnchor = ASPresentationAnchor +#endif + +/// Behavior of the Auth category that clients will use +public protocol AuthCategoryBehavior: AuthCategoryUserBehavior, AuthCategoryDeviceBehavior { + + /// SignUp a user with the authentication provider. + /// + /// If the signUp require multiple steps like passing a confirmation code, use the method + /// `confirmSignUp` after this api completes. You can check if the user is confirmed or not + /// using the result `AuthSignUpResult.userConfirmed`. + /// + /// - Parameters: + /// - username: username to signUp + /// - password: password as per the password policy of the provider + /// - options: Parameters specific to plugin behavior + func signUp( + username: String, + password: String?, + options: AuthSignUpRequest.Options? + ) async throws -> AuthSignUpResult + + /// Confirms the `signUp` operation. + /// + /// Invoke this operation as a follow up for the signUp process if the authentication provider + /// that you are using required to follow a next step after signUp. Calling this operation without + /// first calling `signUp` or `resendSignUpCode` may cause an error. + /// - Parameters: + /// - username: Username used that was used to signUp. + /// - confirmationCode: Confirmation code received to the user. + /// - options: Parameters specific to plugin behavior + func confirmSignUp(for username: String, + confirmationCode: String, + options: AuthConfirmSignUpRequest.Options?) async throws -> AuthSignUpResult + + /// Resends the confirmation code to confirm the signUp process + /// + /// - Parameters: + /// - username: Username of the user to be confirmed. + /// - options: Parameters specific to plugin behavior. + func resendSignUpCode( + for username: String, + options: AuthResendSignUpCodeRequest.Options? + ) async throws -> AuthCodeDeliveryDetails + + /// SignIn to the authentication provider + /// + /// Username and password are optional values, check the plugin documentation to decide on what all values need to + /// passed. For example in a passwordless flow you just need to pass the username and the password could be nil. + /// + /// - Parameters: + /// - username: Username to signIn the user + /// - password: Password to signIn the user + /// - options: Parameters specific to plugin behavior + func signIn(username: String?, + password: String?, + options: AuthSignInRequest.Options?) async throws -> AuthSignInResult + +#if os(iOS) || os(macOS) + /// SignIn using pre configured web UI. + /// + /// Calling this method will always launch the Auth plugin's default web user interface + /// + /// - Parameters: + /// - presentationAnchor: Anchor on which the UI is presented. + /// - options: Parameters specific to plugin behavior. + func signInWithWebUI(presentationAnchor: AuthUIPresentationAnchor?, + options: AuthWebUISignInRequest.Options?) async throws -> AuthSignInResult + + /// SignIn using an auth provider on a web UI + /// + /// Calling this method will invoke the AuthProvider's default web user interface. Depending on the plugin + /// implementation and the authentication state with the provider, this method might complete without showing + /// any UI. + /// + /// - Parameters: + /// - authProvider: Auth provider used to signIn. + /// - presentationAnchor: Anchor on which the UI is presented. + /// - options: Parameters specific to plugin behavior. + func signInWithWebUI(for authProvider: AuthProvider, + presentationAnchor: AuthUIPresentationAnchor?, + options: AuthWebUISignInRequest.Options?) async throws -> AuthSignInResult +#endif + + /// Confirms a next step in signIn flow. + /// + /// - Parameters: + /// - challengeResponse: Challenge response required to confirm the next step in signIn flow + /// - options: Parameters specific to plugin behavior. + func confirmSignIn( + challengeResponse: String, + options: AuthConfirmSignInRequest.Options? + ) async throws -> AuthSignInResult + + /// Sign out the currently logged-in user. + /// + /// - Parameters: + /// - options: Parameters specific to plugin behavior. + func signOut(options: AuthSignOutRequest.Options?) async -> AuthSignOutResult + + /// Delete the account of the currently logged-in user. + func deleteUser() async throws + + /// Fetch the current authentication session. + /// + /// - Parameters: + /// - options: Parameters specific to plugin behavior + func fetchAuthSession(options: AuthFetchSessionRequest.Options?) async throws -> AuthSession + + /// Initiate a reset password flow for the user + /// + /// - Parameters: + /// - username: username whose password need to reset + /// - options: Parameters specific to plugin behavior + func resetPassword(for username: String, + options: AuthResetPasswordRequest.Options?) async throws -> AuthResetPasswordResult + + /// Confirms a reset password flow + /// + /// - Parameters: + /// - username: username whose password need to reset + /// - newPassword: new password for the user + /// - confirmationCode: Received confirmation code + /// - options: Parameters specific to plugin behavior + func confirmResetPassword( + for username: String, + with newPassword: String, + confirmationCode: String, + options: AuthConfirmResetPasswordRequest.Options? + ) async throws + + /// Initiates TOTP Setup + /// + /// Invoke this operation to setup TOTP for the user while signed in. + /// Calling this method will initiate TOTP setup process and + /// returns a shared secret that can be used to generate QR code. + /// The setup details also contains a URI generator helper that can be used to retireve a TOTP Setup URI. + /// + func setUpTOTP() async throws -> TOTPSetupDetails + + /// Verifies TOTP Setup + /// + /// Invoke this operation to verify TOTP setup for the user while signed in. + /// Calling this method with the verification code from the associated Authenticator app + /// will complete the TOTP setup process. + /// + /// - Parameters: + /// - code: verification code from the associated Authenticator app + /// - options: Parameters specific to plugin behavior + func verifyTOTPSetup( + code: String, + options: VerifyTOTPSetupRequest.Options? + ) async throws + +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/AuthCategoryConfiguration.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/AuthCategoryConfiguration.swift new file mode 100644 index 0000000000..bd44208a7b --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/AuthCategoryConfiguration.swift @@ -0,0 +1,16 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public struct AuthCategoryConfiguration: CategoryConfiguration { + public var plugins: [String: JSONValue] + + public init(plugins: [String: JSONValue] = [:]) { + self.plugins = plugins + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/AuthCategoryDeviceBehavior.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/AuthCategoryDeviceBehavior.swift new file mode 100644 index 0000000000..6f9d437b9c --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/AuthCategoryDeviceBehavior.swift @@ -0,0 +1,29 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public protocol AuthCategoryDeviceBehavior: AnyObject { + + /// Fetch devices assigned to the current device + /// - Parameters: + /// - options: Parameters specific to plugin behavior. + func fetchDevices(options: AuthFetchDevicesRequest.Options?) async throws -> [AuthDevice] + + /// Forget device from the user + /// + /// - Parameters: + /// - authDevice: Device to be forgotten + /// - options: Parameters specific to plugin behavior. + func forgetDevice( _ device: AuthDevice?, options: AuthForgetDeviceRequest.Options?) async throws + + /// Make the current user device as remebered + /// + /// - Parameters: + /// - options: Parameters specific to plugin behavior. + func rememberDevice( options: AuthRememberDeviceRequest.Options?) async throws +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/AuthCategoryPlugin.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/AuthCategoryPlugin.swift new file mode 100644 index 0000000000..9c33ff611c --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/AuthCategoryPlugin.swift @@ -0,0 +1,16 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public protocol AuthCategoryPlugin: Plugin, AuthCategoryBehavior {} + +public extension AuthCategoryPlugin { + var categoryType: CategoryType { + return .auth + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/AuthCategoryUserBehavior.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/AuthCategoryUserBehavior.swift new file mode 100644 index 0000000000..ad9d106eaf --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/AuthCategoryUserBehavior.swift @@ -0,0 +1,89 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public protocol AuthCategoryUserBehavior: AnyObject { + + /// Returns the currently logged in user. + /// + func getCurrentUser() async throws -> AuthUser + + /// Fetch user attributes for the current user. + /// + /// - Parameters: + /// - options: Parameters specific to plugin behavior + func fetchUserAttributes( + options: AuthFetchUserAttributesRequest.Options? + ) async throws -> [AuthUserAttribute] + + /// Update user attribute for the current user + /// + /// - Parameters: + /// - userAttribute: Attribute that need to be updated + /// - options: Parameters specific to plugin behavior + func update( + userAttribute: AuthUserAttribute, + options: AuthUpdateUserAttributeRequest.Options? + ) async throws -> AuthUpdateAttributeResult + + /// Update a list of user attributes for the current user + /// + /// - Parameters: + /// - userAttributes: List of attribtues that need ot be updated + /// - options: Parameters specific to plugin behavior + func update( + userAttributes: [AuthUserAttribute], + options: AuthUpdateUserAttributesRequest.Options? + ) async throws -> [AuthUserAttributeKey: AuthUpdateAttributeResult] + + /// Resends the confirmation code required to verify an attribute + /// + /// - Parameters: + /// - userAttributeKey: Attribute to be verified + /// - options: Parameters specific to plugin behavior + @available(*, deprecated, renamed: "sendVerificationCode(forUserAttributeKey:options:)") + func resendConfirmationCode( + forUserAttributeKey userAttributeKey: AuthUserAttributeKey, + options: AuthAttributeResendConfirmationCodeRequest.Options? + ) async throws -> AuthCodeDeliveryDetails + + /// Sends the verification code required to verify an attribute + /// + /// - Parameters: + /// - userAttributeKey: Attribute to be verified + /// - options: Parameters specific to plugin behavior + func sendVerificationCode( + forUserAttributeKey userAttributeKey: AuthUserAttributeKey, + options: AuthSendUserAttributeVerificationCodeRequest.Options? + ) async throws -> AuthCodeDeliveryDetails + + /// Confirm an attribute using confirmation code + /// + /// - Parameters: + /// - userAttribute: Attribute to verify + /// - confirmationCode: Confirmation code received + /// - options: Parameters specific to plugin behavior + func confirm( + userAttribute: AuthUserAttributeKey, + confirmationCode: String, + options: AuthConfirmUserAttributeRequest.Options? + ) async throws + + /// Update the current logged in user's password + /// + /// Check the plugins documentation, you might need to re-authenticate the user after calling this method. + /// - Parameters: + /// - oldPassword: Current password of the user + /// - newPassword: New password to be updated + /// - options: Parameters specific to plugin behavior + func update( + oldPassword: String, + to newPassword: String, + options: AuthChangePasswordRequest.Options? + ) async throws +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Error/AuthError.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Error/AuthError.swift new file mode 100644 index 0000000000..889f133cf7 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Error/AuthError.swift @@ -0,0 +1,115 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Amplify error raised in the Auth category. +public enum AuthError { + + /// Caused by issue in the way auth category is configured + case configuration(ErrorDescription, RecoverySuggestion, Error? = nil) + + /// Caused by some error in the underlying service. Check the associated error for more details. + case service(ErrorDescription, RecoverySuggestion, Error? = nil) + + /// Caused by an unknown reason + case unknown(ErrorDescription, Error? = nil) + + /// Caused when one of the input field is invalid + case validation(Field, ErrorDescription, RecoverySuggestion, Error? = nil) + + /// Caused when the current session is not authorized to perform an operation + case notAuthorized(ErrorDescription, RecoverySuggestion, Error? = nil) + + /// Caused when an operation is not valid with the current state of Auth category + case invalidState(ErrorDescription, RecoverySuggestion, Error? = nil) + + /// Caused when an operation needs the user to be in signedIn state + case signedOut(ErrorDescription, RecoverySuggestion, Error? = nil) + + /// Caused when a session is expired and needs the user to be re-authenticated + case sessionExpired(ErrorDescription, RecoverySuggestion, Error? = nil) +} + +extension AuthError: AmplifyError { + + public var underlyingError: Error? { + switch self { + case .configuration(_, _, let underlyingError), + .service(_, _, let underlyingError), + .unknown(_, let underlyingError), + .validation(_, _, _, let underlyingError), + .notAuthorized(_, _, let underlyingError), + .sessionExpired(_, _, let underlyingError), + .signedOut(_, _, let underlyingError), + .invalidState(_, _, let underlyingError): + return underlyingError + } + } + + public var errorDescription: ErrorDescription { + switch self { + case .configuration(let errorDescription, _, _), + .service(let errorDescription, _, _), + .validation(_, let errorDescription, _, _), + .notAuthorized(let errorDescription, _, _), + .signedOut(let errorDescription, _, _), + .sessionExpired(let errorDescription, _, _), + .invalidState(let errorDescription, _, _): + return errorDescription + case .unknown(let errorDescription, _): + return "Unexpected error occurred with message: \(errorDescription)" + } + } + + public var recoverySuggestion: RecoverySuggestion { + switch self { + case .configuration(_, let recoverySuggestion, _), + .service(_, let recoverySuggestion, _), + .validation(_, _, let recoverySuggestion, _), + .notAuthorized(_, let recoverySuggestion, _), + .signedOut(_, let recoverySuggestion, _), + .sessionExpired(_, let recoverySuggestion, _), + .invalidState(_, let recoverySuggestion, _): + return recoverySuggestion + case .unknown: + return AmplifyErrorMessages.shouldNotHappenReportBugToAWSWithoutLineInfo() + } + } + + public init( + errorDescription: ErrorDescription = "An unknown error occurred", + recoverySuggestion: RecoverySuggestion = "(Ignored)", + error: Error + ) { + if let error = error as? Self { + self = error + } else if error.isOperationCancelledError { + self = .unknown("Operation cancelled", error) + } else { + self = .unknown(errorDescription, error) + } + } + +} + +extension AuthError: Equatable { + public static func == (lhs: AuthError, rhs: AuthError) -> Bool { + switch (lhs, rhs) { + case (.configuration, .configuration), + (.service, .service), + (.validation, .validation), + (.notAuthorized, .notAuthorized), + (.signedOut, .signedOut), + (.sessionExpired, .sessionExpired), + (.invalidState, .invalidState): + return true + default: + return false + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Internal/AuthCategory+CategoryConfigurable.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Internal/AuthCategory+CategoryConfigurable.swift new file mode 100644 index 0000000000..aff88dc2b1 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Internal/AuthCategory+CategoryConfigurable.swift @@ -0,0 +1,36 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension AuthCategory: CategoryConfigurable { + + func configure(using configuration: CategoryConfiguration?) throws { + guard !isConfigured else { + let error = ConfigurationError.amplifyAlreadyConfigured( + "\(categoryType.displayName) has already been configured.", + "Remove the duplicate call to `Amplify.configure()`" + ) + throw error + } + + try Amplify.configure(plugins: Array(plugins.values), using: configuration) + + isConfigured = true + } + + func configure(using amplifyConfiguration: AmplifyConfiguration) throws { + try configure(using: categoryConfiguration(from: amplifyConfiguration)) + } + + func configure(using amplifyOutputs: AmplifyOutputsData) throws { + for plugin in Array(plugins.values) { + try plugin.configure(using: amplifyOutputs) + } + isConfigured = true + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Internal/AuthCategory+Resettable.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Internal/AuthCategory+Resettable.swift new file mode 100644 index 0000000000..8009d39324 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Internal/AuthCategory+Resettable.swift @@ -0,0 +1,25 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension AuthCategory: Resettable { + + public func reset() async { + await withTaskGroup(of: Void.self) { taskGroup in + for plugin in plugins.values { + taskGroup.addTask { [weak self] in + self?.log.verbose("Resetting \(String(describing: self?.categoryType)) plugin") + await plugin.reset() + self?.log.verbose("Resetting \(String(describing: self?.categoryType)) plugin: finished") + } + } + await taskGroup.waitForAll() + } + isConfigured = false + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Models/AuthCodeDeliveryDetails.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Models/AuthCodeDeliveryDetails.swift new file mode 100644 index 0000000000..1f73a5ec0e --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Models/AuthCodeDeliveryDetails.swift @@ -0,0 +1,28 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public typealias AdditionalInfo = [String: String] + +/// Details on where the code has been delivered +public struct AuthCodeDeliveryDetails { + + /// Destination to which the code was delivered. + public let destination: DeliveryDestination + + /// Attribute that is confirmed or verified. + public let attributeKey: AuthUserAttributeKey? + + public init(destination: DeliveryDestination, + attributeKey: AuthUserAttributeKey? = nil) { + self.destination = destination + self.attributeKey = attributeKey + } +} + +extension AuthCodeDeliveryDetails: Equatable {} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Models/AuthDevice.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Models/AuthDevice.swift new file mode 100644 index 0000000000..269455efef --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Models/AuthDevice.swift @@ -0,0 +1,18 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Device used by the user to sign in +public protocol AuthDevice { + + /// Device id + var id: String { get } + + /// Device name + var name: String { get } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Models/AuthEventName.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Models/AuthEventName.swift new file mode 100644 index 0000000000..d2546cbdac --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Models/AuthEventName.swift @@ -0,0 +1,23 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public extension HubPayload.EventName.Auth { + + /// eventName emitted when a user is successfully signedIn to Auth category + static let signedIn = "Auth.signedIn" + + /// eventName emitted when a user is signedOut from Auth category + static let signedOut = "Auth.signedOut" + + /// eventName emitted when a user is deleted from Auth category + static let userDeleted = "Auth.userDeleted" + + /// eventName emitted when the current session has expired + static let sessionExpired = "Auth.sessionExpired" +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Models/AuthProvider.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Models/AuthProvider.swift new file mode 100644 index 0000000000..4a6a363ece --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Models/AuthProvider.swift @@ -0,0 +1,48 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Supported auth providers that help in federated sign in +/// +/// You can use these auth providers to directly sign in to one of the user's social provider and then +/// federate them to the auth plugin's underlying service. For example in the api +/// `Amplify.Auth.signInWithWebUI(for:presentationAnchor:)` you can pass in a provider +/// in the `for:` parameter which will directly show a authentication view for the passed in auth provider. +public enum AuthProvider { + + public typealias ProviderName = String + + /// Auth provider that uses Login with Amazon + case amazon + + /// Auth provider that uses Sign in with Apple + case apple + + /// Auth provider that uses Facebook Login + case facebook + + /// Auth provider that uses Google Sign-In + case google + + /// Auth provider that uses Twitter Sign-In + case twitter + + /// Auth provider that uses OpenID Connect Protocol + case oidc(ProviderName) + + /// Auth provider that uses Security Assertion Markup Language standard + case saml(ProviderName) + + /// Custom auth provider that is not in this list, the associated string value will be the identifier used by + /// the plugin service. + case custom(ProviderName) +} + +extension AuthProvider: Codable { } + +extension AuthProvider: Equatable { } diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Models/AuthSession.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Models/AuthSession.swift new file mode 100644 index 0000000000..b5f7c3c4c8 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Models/AuthSession.swift @@ -0,0 +1,28 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Defines the auth session behavior +public protocol AuthSession { + + /// True if the current user has signed in + /// + /// App developers can use this flag to make local decisions about the content to display (e.g., "Should I show this protected + /// page?") but cannot use it to make any further assertions about whether a network operation is likely to succeed or not. + /// + /// `true` if a user has authenticated, via any of: + /// - ``AuthCategoryBehavior/signIn(username:password:options:)`` + /// - ``AuthCategoryBehavior/signInWithWebUI(presentationAnchor:options:)`` + /// - ``AuthCategoryBehavior/signInWithWebUI(for:presentationAnchor:options:)`` + /// - A plugin-specific sign in method like + /// `AWSCognitoAuthPlugin.federateToIdentityPool(withProviderToken:for:options:)` + /// + /// `isSignedIn` remains `true` until we call `Amplify.Auth.signOut`. Notably, this value remains `true` + /// even when the session is expired. Refer the underlying plugin documentation regarding how to handle session expiry. + var isSignedIn: Bool { get } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Models/AuthSignInStep.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Models/AuthSignInStep.swift new file mode 100644 index 0000000000..e99fc9adf4 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Models/AuthSignInStep.swift @@ -0,0 +1,53 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// Set of allowed MFA types that would be used for continuing sign in during MFA selection step +public typealias AllowedMFATypes = Set + +/// Auth SignIn flow steps +/// +/// +public enum AuthSignInStep { + + /// Auth step is SMS multi factor authentication. + /// + /// Confirmation code for the MFA will be send to the provided SMS. + case confirmSignInWithSMSMFACode(AuthCodeDeliveryDetails, AdditionalInfo?) + + /// Auth step is in a custom challenge depending on the plugin. + /// + case confirmSignInWithCustomChallenge(AdditionalInfo?) + + /// Auth step required the user to give a new password. + /// + case confirmSignInWithNewPassword(AdditionalInfo?) + + /// Auth step is TOTP multi factor authentication. + /// + /// Confirmation code for the MFA will be retrieved from the associated Authenticator app + case confirmSignInWithTOTPCode + + /// Auth step is for continuing sign in by setting up TOTP multi factor authentication. + /// + case continueSignInWithTOTPSetup(TOTPSetupDetails) + + /// Auth step is for continuing sign in by selecting multi factor authentication type + /// + case continueSignInWithMFASelection(AllowedMFATypes) + + /// Auth step required the user to change their password. + /// + case resetPassword(AdditionalInfo?) + + /// Auth step that required the user to be confirmed + /// + case confirmSignUp(AdditionalInfo?) + + /// There is no next step and the signIn flow is complete + /// + case done +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Models/AuthSignUpStep.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Models/AuthSignUpStep.swift new file mode 100644 index 0000000000..2d66d9cf56 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Models/AuthSignUpStep.swift @@ -0,0 +1,21 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +public typealias UserId = String + +/// SignUp step to be followed. +public enum AuthSignUpStep { + + /// Need to confirm the user + case confirmUser( + AuthCodeDeliveryDetails? = nil, + AdditionalInfo? = nil, + UserId? = nil) + + /// Sign up is complete + case done +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Models/AuthUpdateAttributeStep.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Models/AuthUpdateAttributeStep.swift new file mode 100644 index 0000000000..eb289e7eb7 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Models/AuthUpdateAttributeStep.swift @@ -0,0 +1,20 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// Step for Auth.updateUserAttribute api call +public enum AuthUpdateAttributeStep { + + /// Next step is to confirm the attribute with confirmation code. + /// + /// Invoke Auth.confirm(userAttribute: ...) to confirm the attribute that was updated. + /// `AuthCodeDeliveryDetails` provides the details to which the confirmation + /// code was send and `AdditionalInfo` will provide more details if present. + case confirmAttributeWithCode(AuthCodeDeliveryDetails, AdditionalInfo?) + + /// Update Attribute step is `done` when the update attribute flow is complete. + case done +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Models/AuthUser.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Models/AuthUser.swift new file mode 100644 index 0000000000..c583cda7e6 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Models/AuthUser.swift @@ -0,0 +1,17 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// Defines the protocol for an auth user +public protocol AuthUser { + + /// User name of the auth user + var username: String { get } + + /// Unique id of the auth user + var userId: String { get } + +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Models/AuthUserAttribute.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Models/AuthUserAttribute.swift new file mode 100644 index 0000000000..7990ffe276 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Models/AuthUserAttribute.swift @@ -0,0 +1,92 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +public struct AuthUserAttribute { + public let key: AuthUserAttributeKey + public let value: String + + public init(_ key: AuthUserAttributeKey, value: String) { + self.key = key + self.value = value + } +} + +/// Represents the keys used for different user attributes. +/// +public enum AuthUserAttributeKey { + // Attribute ref - https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-settings-attributes.html + + /// Attribute key for user's address + case address + + /// Attribute key for user's birthdate + case birthDate + + /// Attribute key for user's email + case email + + /// Attribute key for user's email verfication status + case emailVerified + + /// Attribute key for user's family name + case familyName + + /// Attribute key for user's gender + case gender + + /// Attribute key for user's given name + case givenName + + /// Attribute key for user's locale + case locale + + /// Attribute key for user's middle name + case middleName + + /// Attribute key for user's name + case name + + /// Attribute key for user's nickname + case nickname + + /// Attribute key for user's phone number + case phoneNumber + + /// Attribute key for user's phone number verficiation status + case phoneNumberVerified + + /// Attribute key for user's picture + case picture + + /// Attribute key for user's preferred user name + case preferredUsername + + /// Attribute key for user's profile + case profile + + /// Attribute key for user's identifier + case sub + + /// Attribute key for time of user's information last updated + case updatedAt + + /// Attribute key for user's web page + case website + + /// Attribute key for user's time zone + case zoneInfo + + /// Attribute key for providing custom attributes + case custom(String) + + /// Attribute key for representing any other keys not mentioned here + case unknown(String) +} + +extension AuthUserAttributeKey: Hashable {} + +extension AuthUserAttributeKey: Equatable {} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Models/DeliveryDestination.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Models/DeliveryDestination.swift new file mode 100644 index 0000000000..17a417d8ec --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Models/DeliveryDestination.swift @@ -0,0 +1,26 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +public typealias Destination = String + +/// Destination to where an item (e.g., confirmation code) was delivered +public enum DeliveryDestination { + + /// Email destination with optional associated value containing the email info + case email(Destination?) + + /// Phone destination with optional associated value containing the phone number info + case phone(Destination?) + + /// SMS destination with optional associated value containing the number info + case sms(Destination?) + + /// Unknown destination with optional associated value destination detail + case unknown(Destination?) +} + +extension DeliveryDestination: Equatable { } diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Models/MFAType.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Models/MFAType.swift new file mode 100644 index 0000000000..2726503aa1 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Models/MFAType.swift @@ -0,0 +1,15 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +public enum MFAType: String { + + /// Short Messaging Service linked with a phone number + case sms + + /// Time-based One Time Password linked with an authenticator app + case totp +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Models/TOTPSetupDetails.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Models/TOTPSetupDetails.swift new file mode 100644 index 0000000000..608ddcab77 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Models/TOTPSetupDetails.swift @@ -0,0 +1,42 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public struct TOTPSetupDetails { + + /// Secret code returned by the service to help setting up TOTP + public let sharedSecret: String + + /// username that will be used to construct the URI + public let username: String + + public init(sharedSecret: String, username: String) { + self.sharedSecret = sharedSecret + self.username = username + } + /// Returns a TOTP setup URI that can help the customers avoid barcode scanning and use native password manager to handle TOTP association + /// Example: On iOS and MacOS, URI will redirect to associated Password Manager for the platform + /// + /// throws AuthError.validation if a `URL` cannot be formed with the supplied parameters + /// (for example, if the parameter string contains characters that are illegal in a URL, or is an empty string). + public func getSetupURI( + appName: String, + accountName: String? = nil) throws -> URL { + guard let URL = URL( + string: "otpauth://totp/\(appName):\(accountName ?? username)?secret=\(sharedSecret)&issuer=\(appName)") else { + + throw AuthError.validation( + "appName or accountName", + "Invalid Parameters. Cannot form URL from the supplied appName or accountName", + "Please make sure that the supplied parameters don't contain any characters that are illegal in a URL or is an empty String", + nil) + } + return URL + } + +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/AuthAttributeResendConfirmationCodeRequest.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/AuthAttributeResendConfirmationCodeRequest.swift new file mode 100644 index 0000000000..4c514c97fb --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/AuthAttributeResendConfirmationCodeRequest.swift @@ -0,0 +1,41 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +// swiftlint:disable type_name + +/// Request for resending confirmation code that was generated for update attribute +public struct AuthAttributeResendConfirmationCodeRequest: AmplifyOperationRequest { + + /// Attribute key for which the confirmation code was sent + public let attributeKey: AuthUserAttributeKey + + /// Extra request options defined in `AuthAttributeResendConfirmationCodeRequest.Options` + public var options: Options + + public init(attributeKey: AuthUserAttributeKey, + options: Options) { + self.attributeKey = attributeKey + self.options = options + } +} + +public extension AuthAttributeResendConfirmationCodeRequest { + + struct Options { + + /// Extra plugin specific options, only used in special circumstances when the existing options do not provide + /// a way to utilize the underlying auth plugin functionality. See plugin documentation for expected + /// key/values + public let pluginOptions: Any? + + public init(pluginOptions: Any? = nil) { + self.pluginOptions = pluginOptions + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/AuthAttributeSendVerificationCodeRequest.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/AuthAttributeSendVerificationCodeRequest.swift new file mode 100644 index 0000000000..ec5f0a4683 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/AuthAttributeSendVerificationCodeRequest.swift @@ -0,0 +1,41 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +// swiftlint:disable type_name + +/// Request for sending verification code that was generated for update attribute +public struct AuthSendUserAttributeVerificationCodeRequest: AmplifyOperationRequest { + + /// Attribute key for which the confirmation code was sent + public let attributeKey: AuthUserAttributeKey + + /// Extra request options defined in `AuthSendUserAttributeVerificationCodeRequest.Options` + public var options: Options + + public init(attributeKey: AuthUserAttributeKey, + options: Options) { + self.attributeKey = attributeKey + self.options = options + } +} + +public extension AuthSendUserAttributeVerificationCodeRequest { + + struct Options { + + /// Extra plugin specific options, only used in special circumstances when the existing options do not provide + /// a way to utilize the underlying auth plugin functionality. See plugin documentation for expected + /// key/values + public let pluginOptions: Any? + + public init(pluginOptions: Any? = nil) { + self.pluginOptions = pluginOptions + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/AuthChangePasswordRequest.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/AuthChangePasswordRequest.swift new file mode 100644 index 0000000000..d385b95d52 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/AuthChangePasswordRequest.swift @@ -0,0 +1,44 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Request for change password operation +public struct AuthChangePasswordRequest: AmplifyOperationRequest { + + /// Old or existing password for the signed in user + public let oldPassword: String + + /// New password for the user + public let newPassword: String + + /// Extra request options defined in `AuthChangePasswordRequest.Options` + public var options: Options + + public init(oldPassword: String, + newPassword: String, + options: Options) { + self.oldPassword = oldPassword + self.newPassword = newPassword + self.options = options + } +} + +public extension AuthChangePasswordRequest { + + struct Options { + + /// Extra plugin specific options, only used in special circumstances when the existing options do not provide + /// a way to utilize the underlying auth plugin functionality. See plugin documentation for expected + /// key/values + public let pluginOptions: Any? + + public init(pluginOptions: Any? = nil) { + self.pluginOptions = pluginOptions + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/AuthConfirmResetPasswordRequest.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/AuthConfirmResetPasswordRequest.swift new file mode 100644 index 0000000000..c4e0be2e19 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/AuthConfirmResetPasswordRequest.swift @@ -0,0 +1,49 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Request for reset password confirmation +public struct AuthConfirmResetPasswordRequest: AmplifyOperationRequest { + + /// User name for which reset password was initiated + public let username: String + + /// New password to be assigned to the user + public let newPassword: String + + /// Confirmation code received + public let confirmationCode: String + + /// Extra request options defined in `AuthConfirmResetPasswordRequest.Options` + public var options: Options + + public init(username: String, + newPassword: String, + confirmationCode: String, + options: Options) { + self.username = username + self.newPassword = newPassword + self.confirmationCode = confirmationCode + self.options = options + } +} + +public extension AuthConfirmResetPasswordRequest { + + struct Options { + + /// Extra plugin specific options, only used in special circumstances when the existing options do not provide + /// a way to utilize the underlying auth plugin functionality. See plugin documentation for expected + /// key/values + public let pluginOptions: Any? + + public init(pluginOptions: Any? = nil) { + self.pluginOptions = pluginOptions + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/AuthConfirmSignInRequest.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/AuthConfirmSignInRequest.swift new file mode 100644 index 0000000000..4609758e53 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/AuthConfirmSignInRequest.swift @@ -0,0 +1,40 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Request for confirming sign in flow +public struct AuthConfirmSignInRequest: AmplifyOperationRequest { + + /// Challenge response as part of sign in flow. + /// + /// The value of `challengeResponse` varies based on the sign in next step defined in `AuthSignInStep` + public let challengeResponse: String + + /// Extra request options defined in `AuthConfirmSignInRequest.Options` + public var options: Options + + public init(challengeResponse: String, options: Options) { + self.challengeResponse = challengeResponse + self.options = options + } +} + +public extension AuthConfirmSignInRequest { + + struct Options { + + /// Extra plugin specific options, only used in special circumstances when the existing options do not provide + /// a way to utilize the underlying auth plugin functionality. See plugin documentation for expected + /// key/values + public let pluginOptions: Any? + + public init(pluginOptions: Any? = nil) { + self.pluginOptions = pluginOptions + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/AuthConfirmSignUpRequest.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/AuthConfirmSignUpRequest.swift new file mode 100644 index 0000000000..464afab0da --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/AuthConfirmSignUpRequest.swift @@ -0,0 +1,42 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Request to confirm the signup flow +public struct AuthConfirmSignUpRequest: AmplifyOperationRequest { + + /// User name for which to confirm the signup + public let username: String + + /// Confirmation code received by the user + public let code: String + + /// Extra request options defined in `AuthConfirmSignUpRequest.Options` + public var options: Options + + public init(username: String, code: String, options: Options) { + self.username = username + self.code = code + self.options = options + } +} + +public extension AuthConfirmSignUpRequest { + + struct Options { + + /// Extra plugin specific options, only used in special circumstances when the existing options do not provide + /// a way to utilize the underlying auth plugin functionality. See plugin documentation for expected + /// key/values + public let pluginOptions: Any? + + public init(pluginOptions: Any? = nil) { + self.pluginOptions = pluginOptions + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/AuthConfirmUserAttributeRequest.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/AuthConfirmUserAttributeRequest.swift new file mode 100644 index 0000000000..57650da6cb --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/AuthConfirmUserAttributeRequest.swift @@ -0,0 +1,44 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Request to confirm a user attribute update +public struct AuthConfirmUserAttributeRequest: AmplifyOperationRequest { + + /// Attribute to be confirmed + public let attributeKey: AuthUserAttributeKey + + /// Confirmation code received by the user + public let confirmationCode: String + + /// Extra request options defined in `AuthConfirmUserAttributeRequest.Options` + public var options: Options + + public init(attributeKey: AuthUserAttributeKey, + confirmationCode: String, + options: Options) { + self.attributeKey = attributeKey + self.confirmationCode = confirmationCode + self.options = options + } +} + +public extension AuthConfirmUserAttributeRequest { + + struct Options { + + /// Extra plugin specific options, only used in special circumstances when the existing options do not provide + /// a way to utilize the underlying auth plugin functionality. See plugin documentation for expected + /// key/values + public let pluginOptions: Any? + + public init(pluginOptions: Any? = nil) { + self.pluginOptions = pluginOptions + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/AuthFetchDevicesRequest.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/AuthFetchDevicesRequest.swift new file mode 100644 index 0000000000..65a9aaa3f7 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/AuthFetchDevicesRequest.swift @@ -0,0 +1,34 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Request to fetch the devices associated with the signed in user. +public struct AuthFetchDevicesRequest: AmplifyOperationRequest { + + /// Extra request options defined in `AuthFetchDevicesRequest.Options` + public var options: Options + + public init(options: Options) { + self.options = options + } +} + +public extension AuthFetchDevicesRequest { + + struct Options { + + /// Extra plugin specific options, only used in special circumstances when the existing options do not provide + /// a way to utilize the underlying auth plugin functionality. See plugin documentation for expected + /// key/values + public let pluginOptions: Any? + + public init(pluginOptions: Any? = nil) { + self.pluginOptions = pluginOptions + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/AuthFetchSessionRequest.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/AuthFetchSessionRequest.swift new file mode 100644 index 0000000000..debdb449e4 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/AuthFetchSessionRequest.swift @@ -0,0 +1,49 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Request to fetch the current auth session +public struct AuthFetchSessionRequest: AmplifyOperationRequest { + + /// Extra request options defined in `AuthFetchSessionRequest.Options` + public var options: Options + + public init(options: Options) { + + self.options = options + } +} + +public extension AuthFetchSessionRequest { + + struct Options { + + /// forceRefresh flag when true will ignore the cached UserPoolToken and TemporaryAWSCredentials. + /// This will force the plugin to connect with server to get refreshed access token and id token with a new pair of + /// temporary AWS Credentials. + public let forceRefresh: Bool + + /// Extra plugin specific options, only used in special circumstances when the existing options do not provide + /// a way to utilize the underlying auth plugin functionality. See plugin documentation for expected + /// key/values + public let pluginOptions: Any? + + public init( + forceRefresh: Bool = false, + pluginOptions: Any? = nil) { + self.forceRefresh = forceRefresh + self.pluginOptions = pluginOptions + } + } +} + +extension AuthFetchSessionRequest.Options { + public static func forceRefresh() -> AuthFetchSessionRequest.Options { + return AuthFetchSessionRequest.Options(forceRefresh: true) + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/AuthFetchUserAttributesRequest.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/AuthFetchUserAttributesRequest.swift new file mode 100644 index 0000000000..12cb54a3b5 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/AuthFetchUserAttributesRequest.swift @@ -0,0 +1,34 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Request to fetch the user attributes of the current user +public struct AuthFetchUserAttributesRequest: AmplifyOperationRequest { + + /// Extra request options defined in `AuthFetchUserAttributesRequest.Options` + public var options: Options + + public init(options: Options) { + self.options = options + } +} + +public extension AuthFetchUserAttributesRequest { + + struct Options { + + /// Extra plugin specific options, only used in special circumstances when the existing options do not provide + /// a way to utilize the underlying auth plugin functionality. See plugin documentation for expected + /// key/values + public let pluginOptions: Any? + + public init(pluginOptions: Any? = nil) { + self.pluginOptions = pluginOptions + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/AuthForgetDeviceRequest.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/AuthForgetDeviceRequest.swift new file mode 100644 index 0000000000..530c0e1f4b --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/AuthForgetDeviceRequest.swift @@ -0,0 +1,41 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Request to forget a device of the user +public struct AuthForgetDeviceRequest: AmplifyOperationRequest { + + /// Device to forget + /// + /// If this value is not provided, the current device will be used. + public let device: AuthDevice? + + /// Extra request options defined in `AuthForgetDeviceRequest.Options` + public var options: Options + + public init(device: AuthDevice? = nil, + options: Options) { + self.device = device + self.options = options + } +} + +public extension AuthForgetDeviceRequest { + + struct Options { + + /// Extra plugin specific options, only used in special circumstances when the existing options do not provide + /// a way to utilize the underlying auth plugin functionality. See plugin documentation for expected + /// key/values + public let pluginOptions: Any? + + public init(pluginOptions: Any? = nil) { + self.pluginOptions = pluginOptions + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/AuthRememberDeviceRequest.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/AuthRememberDeviceRequest.swift new file mode 100644 index 0000000000..3da839d0d9 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/AuthRememberDeviceRequest.swift @@ -0,0 +1,34 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Request to remember the current device +public struct AuthRememberDeviceRequest: AmplifyOperationRequest { + + /// Extra request options defined in `AuthRememberDeviceRequest.Options` + public var options: Options + + public init(options: Options) { + self.options = options + } +} + +public extension AuthRememberDeviceRequest { + + struct Options { + + /// Extra plugin specific options, only used in special circumstances when the existing options do not provide + /// a way to utilize the underlying auth plugin functionality. See plugin documentation for expected + /// key/values + public let pluginOptions: Any? + + public init(pluginOptions: Any? = nil) { + self.pluginOptions = pluginOptions + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/AuthResendSignUpCodeRequest.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/AuthResendSignUpCodeRequest.swift new file mode 100644 index 0000000000..ff9af6eb6b --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/AuthResendSignUpCodeRequest.swift @@ -0,0 +1,38 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Request to resend sign up code +public struct AuthResendSignUpCodeRequest: AmplifyOperationRequest { + + /// User for which the sign up code should be resent + public let username: String + + /// Extra request options defined in `AuthResendSignUpCodeRequest.Options` + public var options: Options + + public init(username: String, options: Options) { + self.username = username + self.options = options + } +} + +public extension AuthResendSignUpCodeRequest { + + struct Options { + + /// Extra plugin specific options, only used in special circumstances when the existing options do not provide + /// a way to utilize the underlying auth plugin functionality. See plugin documentation for expected + /// key/values + public let pluginOptions: Any? + + public init(pluginOptions: Any? = nil) { + self.pluginOptions = pluginOptions + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/AuthResetPasswordRequest.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/AuthResetPasswordRequest.swift new file mode 100644 index 0000000000..79038d6406 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/AuthResetPasswordRequest.swift @@ -0,0 +1,38 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Request to reset password of a user +public struct AuthResetPasswordRequest: AmplifyOperationRequest { + + public let username: String + + /// Extra request options defined in `AuthResetPasswordRequest.Options` + public var options: Options + + public init(username: String, + options: Options) { + self.username = username + self.options = options + } +} + +public extension AuthResetPasswordRequest { + + struct Options { + + /// Extra plugin specific options, only used in special circumstances when the existing options do not provide + /// a way to utilize the underlying auth plugin functionality. See plugin documentation for expected + /// key/values + public let pluginOptions: Any? + + public init(pluginOptions: Any? = nil) { + self.pluginOptions = pluginOptions + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/AuthSignInRequest.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/AuthSignInRequest.swift new file mode 100644 index 0000000000..c61f668092 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/AuthSignInRequest.swift @@ -0,0 +1,42 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Request to sign in a user +public struct AuthSignInRequest: AmplifyOperationRequest { + + /// User name to use for the sign in flow + public let username: String? + + /// Password to use for the sign in flow + public let password: String? + + /// Extra request options defined in `AuthSignInRequest.Options` + public var options: Options + + public init(username: String?, password: String?, options: Options) { + self.username = username + self.password = password + self.options = options + } +} + +public extension AuthSignInRequest { + + struct Options { + + /// Extra plugin specific options, only used in special circumstances when the existing options do not provide + /// a way to utilize the underlying auth plugin functionality. See plugin documentation for expected + /// key/values + public let pluginOptions: Any? + + public init(pluginOptions: Any? = nil) { + self.pluginOptions = pluginOptions + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/AuthSignOutRequest.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/AuthSignOutRequest.swift new file mode 100644 index 0000000000..4d7e12093f --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/AuthSignOutRequest.swift @@ -0,0 +1,63 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import AuthenticationServices + +/// Request for sign out user +public struct AuthSignOutRequest: AmplifyOperationRequest { + + /// Extra request options defined in `AuthSignOutRequest.Options` + public var options: Options + + public init(options: Options) { + + self.options = options + } +} + +public extension AuthSignOutRequest { + + struct Options { + + /// Extra plugin specific options, only used in special circumstances when the existing options do not provide + /// a way to utilize the underlying auth plugin functionality. See plugin documentation for expected + /// key/values + public let pluginOptions: Any? + + /// SignOut the user from all devices. Check the plugin specific definition on what global signOut means. + public let globalSignOut: Bool + +#if os(iOS) || os(macOS) + /// Provide a presentation anchor if you have signedIn using `signInWithWebUI`. The signOut webUI will be presented + /// in the presentation anchor provided. + public let presentationAnchorForWebUI: AuthUIPresentationAnchor? + + public init(globalSignOut: Bool = false, + presentationAnchor: AuthUIPresentationAnchor? = nil, + pluginOptions: Any? = nil) { + self.globalSignOut = globalSignOut + self.pluginOptions = pluginOptions + self.presentationAnchorForWebUI = presentationAnchor + } +#else + public init(globalSignOut: Bool = false, pluginOptions: Any? = nil) { + self.globalSignOut = globalSignOut + self.pluginOptions = pluginOptions + } +#endif + } + +} + +#if os(iOS) || os(macOS) +extension AuthSignOutRequest.Options { + public static func presentationAnchor(_ anchor: AuthUIPresentationAnchor) -> AuthSignOutRequest.Options { + return AuthSignOutRequest.Options(presentationAnchor: anchor) + } +} +#endif diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/AuthSignUpRequest.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/AuthSignUpRequest.swift new file mode 100644 index 0000000000..e8145e454f --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/AuthSignUpRequest.swift @@ -0,0 +1,47 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Request for sign up flow +public struct AuthSignUpRequest: AmplifyOperationRequest { + + /// Username to sign up + public let username: String + + /// Password for the sign up user + public let password: String? + + /// Extra request options defined in `AuthSignUpRequest.Options` + public var options: Options + + public init(username: String, password: String?, options: Options) { + self.username = username + self.password = password + self.options = options + } +} + +public extension AuthSignUpRequest { + + struct Options { + + /// User attributes for the signed up user + public let userAttributes: [AuthUserAttribute]? + + /// Extra plugin specific options, only used in special circumstances when the existing options do not provide + /// a way to utilize the underlying auth plugin functionality. See plugin documentation for expected + /// key/values + public let pluginOptions: Any? + + public init(userAttributes: [AuthUserAttribute]? = nil, + pluginOptions: Any? = nil) { + self.userAttributes = userAttributes + self.pluginOptions = pluginOptions + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/AuthUpdateUserAttributeRequest.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/AuthUpdateUserAttributeRequest.swift new file mode 100644 index 0000000000..be3b978526 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/AuthUpdateUserAttributeRequest.swift @@ -0,0 +1,39 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Request to update a single attribute of the signed in user +public struct AuthUpdateUserAttributeRequest: AmplifyOperationRequest { + + /// User attribute to update + public let userAttribute: AuthUserAttribute + + /// Extra request options defined in `AuthUpdateUserAttributeRequest.Options` + public var options: Options + + public init(userAttribute: AuthUserAttribute, + options: Options) { + self.userAttribute = userAttribute + self.options = options + } +} + +public extension AuthUpdateUserAttributeRequest { + + struct Options { + + /// Extra plugin specific options, only used in special circumstances when the existing options do not provide + /// a way to utilize the underlying auth plugin functionality. See plugin documentation for expected + /// key/values + public let pluginOptions: Any? + + public init(pluginOptions: Any? = nil) { + self.pluginOptions = pluginOptions + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/AuthUpdateUserAttributesRequest.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/AuthUpdateUserAttributesRequest.swift new file mode 100644 index 0000000000..32a87a794d --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/AuthUpdateUserAttributesRequest.swift @@ -0,0 +1,39 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Request to update multiple user attributes of the signed in user +public struct AuthUpdateUserAttributesRequest: AmplifyOperationRequest { + + /// List of user attributes to update + public let userAttributes: [AuthUserAttribute] + + /// Extra request options defined in `AuthUpdateUserAttributesRequest.Options` + public var options: Options + + public init(userAttributes: [AuthUserAttribute], + options: Options) { + self.userAttributes = userAttributes + self.options = options + } +} + +public extension AuthUpdateUserAttributesRequest { + + struct Options { + + /// Extra plugin specific options, only used in special circumstances when the existing options do not provide + /// a way to utilize the underlying auth plugin functionality. See plugin documentation for expected + /// key/values + public let pluginOptions: Any? + + public init(pluginOptions: Any? = nil) { + self.pluginOptions = pluginOptions + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/AuthWebUISignInRequest.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/AuthWebUISignInRequest.swift new file mode 100644 index 0000000000..f320daff43 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/AuthWebUISignInRequest.swift @@ -0,0 +1,53 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#if os(iOS) || os(macOS) +import Foundation + +/// Request to initiate sign in using a web UI. +/// +/// Note that this call would also be used for sign up, forgot password, confirm password, and similar flows. +public struct AuthWebUISignInRequest: AmplifyOperationRequest { + + /// Optional auth provider to directly sign in with the provider + public let authProvider: AuthProvider? + + /// Extra request options defined in `AuthWebUISignInRequest.Options` + public var options: Options + + /// Presentation anchor on which the webUI is displayed + public let presentationAnchor: AuthUIPresentationAnchor? + + public init(presentationAnchor: AuthUIPresentationAnchor?, + authProvider: AuthProvider? = nil, + options: Options) { + self.presentationAnchor = presentationAnchor + self.authProvider = authProvider + self.options = options + } +} + +public extension AuthWebUISignInRequest { + + struct Options { + + /// Scopes to be defined for the sign in user + public let scopes: [String]? + + /// Extra plugin specific options, only used in special circumstances when the existing options do not provide + /// a way to utilize the underlying auth plugin functionality. See plugin documentation for expected + /// key/values + public let pluginOptions: Any? + + public init(scopes: [String]? = nil, + pluginOptions: Any? = nil) { + self.scopes = scopes + self.pluginOptions = pluginOptions + } + } +} +#endif diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/VerifyTOTPSetupRequest.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/VerifyTOTPSetupRequest.swift new file mode 100644 index 0000000000..4a03d3ff21 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Request/VerifyTOTPSetupRequest.swift @@ -0,0 +1,40 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Request to verify TOTP setup +public struct VerifyTOTPSetupRequest: AmplifyOperationRequest { + + /// Code from the associated Authenticator app that will be used for verification + public var code: String + + /// Extra request options defined in `VerifyTOTPSetupRequest.Options` + public var options: Options + + public init( + code: String, + options: Options) { + self.code = code + self.options = options + } +} + +public extension VerifyTOTPSetupRequest { + + struct Options { + + /// Extra plugin specific options, only used in special circumstances when the existing options do not provide + /// a way to utilize the underlying auth plugin functionality. See plugin documentation for expected + /// key/values + public let pluginOptions: Any? + + public init(pluginOptions: Any? = nil) { + self.pluginOptions = pluginOptions + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Result/AuthResetPasswordResult.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Result/AuthResetPasswordResult.swift new file mode 100644 index 0000000000..e8550776d5 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Result/AuthResetPasswordResult.swift @@ -0,0 +1,44 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Result for Auth.resetPassword api +public struct AuthResetPasswordResult { + + /// Flag to represent whether the reset password flow is complete. + /// + /// `true` if the reset password flow is complete. + public let isPasswordReset: Bool + + /// Next steps to follow for reset password api. + public let nextStep: AuthResetPasswordStep + + public init(isPasswordReset: Bool, nextStep: AuthResetPasswordStep) { + self.isPasswordReset = isPasswordReset + self.nextStep = nextStep + } +} + +extension AuthResetPasswordResult: Equatable {} + +/// The next step in Auth.resetPassword api +public enum AuthResetPasswordStep { + + /// Next step is to confirm the password with a code. + /// + /// Invoke Auth.confirmResetPassword with new password and the confirmation code for the user + /// for which `resetPassword` was invoked. `AuthCodeDeliveryDetails` describes where + /// the confirmation code was sent and `AdditionalInfo` will provide more details if present. + case confirmResetPasswordWithCode(AuthCodeDeliveryDetails, AdditionalInfo?) + + /// Reset password complete, there are no remaining steps + case done + +} + +extension AuthResetPasswordStep: Equatable {} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Result/AuthSignInResult.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Result/AuthSignInResult.swift new file mode 100644 index 0000000000..836c7fbd6f --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Result/AuthSignInResult.swift @@ -0,0 +1,32 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public struct AuthSignInResult { + + /// Informs whether the user is signedIn or not. + /// + /// When this value is false, it means that there are more steps to follow for the signIn flow. Check `nextStep` + /// to understand the next flow. If `isSignedIn` is true, signIn flow has been completed. + public var isSignedIn: Bool { + switch nextStep { + case .done: + return true + default: + return false + } + } + + /// Shows the next step required to complete the signIn flow. + /// + public var nextStep: AuthSignInStep + + public init(nextStep: AuthSignInStep) { + self.nextStep = nextStep + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Result/AuthSignOutResult.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Result/AuthSignOutResult.swift new file mode 100644 index 0000000000..d5d467653a --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Result/AuthSignOutResult.swift @@ -0,0 +1,12 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public protocol AuthSignOutResult { + +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Result/AuthSignUpResult.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Result/AuthSignUpResult.swift new file mode 100644 index 0000000000..012856908a --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Result/AuthSignUpResult.swift @@ -0,0 +1,35 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public struct AuthSignUpResult { + + /// Indicate whether the signUp flow is completed. + public var isSignUpComplete: Bool { + switch nextStep { + case .done: + return true + default: + return false + } + } + + /// Shows the next step required to complete the signUp flow. + /// + public let nextStep: AuthSignUpStep + + public let userID: String? + + public init( + _ nextStep: AuthSignUpStep, + userID: String? = nil + ) { + self.nextStep = nextStep + self.userID = userID + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Result/AuthUpdateAttributeResult.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Result/AuthUpdateAttributeResult.swift new file mode 100644 index 0000000000..26338ac62c --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Result/AuthUpdateAttributeResult.swift @@ -0,0 +1,24 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public struct AuthUpdateAttributeResult { + + /// Informs whether the user attribute is complete or not + /// + public let isUpdated: Bool + + /// Shows the next step required to complete update attribute flow. + /// + public let nextStep: AuthUpdateAttributeStep + + public init(isUpdated: Bool, nextStep: AuthUpdateAttributeStep) { + self.isUpdated = isUpdated + self.nextStep = nextStep + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/DataStoreCallback+Combine.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/DataStoreCallback+Combine.swift new file mode 100644 index 0000000000..58bae6f161 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/DataStoreCallback+Combine.swift @@ -0,0 +1,20 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Combine + +extension DataStoreResult where Success: Any { + + public func resolve(promise: Future.Promise) { + switch self { + case .success(let result): + promise(.success(result)) + case .failure(let error): + promise(.failure(causedBy: error)) + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/DataStoreCallback.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/DataStoreCallback.swift new file mode 100644 index 0000000000..be75ee967f --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/DataStoreCallback.swift @@ -0,0 +1,34 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Enum that holds the results of a `DataStore` operation. +/// - seealso: [DataStoreCallback](#DataStoreCallback) +public typealias DataStoreResult = Result + +extension DataStoreResult { + + /// Creates a `DataStoreResult` based on a error raised during `DataStore` operations. + /// In case the error is not already a `DataStoreError`, it gets wrapped + /// with `.invalidOperation`. + /// + /// - Parameter error: the root cause of the failure + /// - Returns: a `DataStoreResult.error` + public static func failure(causedBy error: Error) -> DataStoreResult { + let dataStoreError = error as? DataStoreError ?? .invalidOperation(causedBy: error) + return .failure(dataStoreError) + } + + public static var emptyResult: DataStoreResult { + .successfulVoid + } + +} + +/// Function type of every `DataStore` asynchronous API. +public typealias DataStoreCallback = (DataStoreResult) -> Void diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/DataStoreCategory+Behavior.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/DataStoreCategory+Behavior.swift new file mode 100644 index 0000000000..4d15a9bff1 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/DataStoreCategory+Behavior.swift @@ -0,0 +1,80 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +extension DataStoreCategory: DataStoreBaseBehavior { + + @discardableResult + public func save(_ model: M, + where condition: QueryPredicate? = nil) async throws -> M { + try await plugin.save(model, where: condition) + } + + public func query(_ modelType: M.Type, + byId id: String) async throws -> M? { + try await plugin.query(modelType, byId: id) + } + + public func query(_ modelType: M.Type, + byIdentifier id: String) async throws -> M? + where M: ModelIdentifiable, M.IdentifierFormat == ModelIdentifierFormat.Default { + try await plugin.query(modelType, byIdentifier: id) + } + + public func query(_ modelType: M.Type, + byIdentifier identifier: ModelIdentifier) + async throws -> M? where M: ModelIdentifiable { + try await plugin.query(modelType, byIdentifier: identifier) + } + + public func query(_ modelType: M.Type, + where predicate: QueryPredicate? = nil, + sort sortInput: QuerySortInput? = nil, + paginate paginationInput: QueryPaginationInput? = nil) async throws -> [M] { + try await plugin.query(modelType, where: predicate, sort: sortInput, paginate: paginationInput) + } + + public func delete(_ model: M, + where predicate: QueryPredicate? = nil) async throws { + try await plugin.delete(model, where: predicate) + } + + public func delete(_ modelType: M.Type, + withId id: String, + where predicate: QueryPredicate? = nil) async throws { + try await plugin.delete(modelType, withId: id, where: predicate) + } + + public func delete(_ modelType: M.Type, + withIdentifier id: String, + where predicate: QueryPredicate? = nil) async throws + where M: ModelIdentifiable, M.IdentifierFormat == ModelIdentifierFormat.Default { + try await plugin.delete(modelType, withIdentifier: id, where: predicate) + } + + public func delete(_ modelType: M.Type, + withIdentifier id: ModelIdentifier, + where predicate: QueryPredicate? = nil) async throws where M: ModelIdentifiable { + try await plugin.delete(modelType, withIdentifier: id, where: predicate) + } + + public func delete(_ modelType: M.Type, + where predicate: QueryPredicate) async throws { + try await plugin.delete(modelType, where: predicate) + } + + public func start() async throws { + try await plugin.start() + } + + public func stop() async throws { + try await plugin.stop() + } + + public func clear() async throws { + try await plugin.clear() + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/DataStoreCategory+HubPayloadEventName.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/DataStoreCategory+HubPayloadEventName.swift new file mode 100644 index 0000000000..209ef3696b --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/DataStoreCategory+HubPayloadEventName.swift @@ -0,0 +1,65 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +public extension HubPayload.EventName { + struct DataStore { } +} + +public extension HubPayload.EventName.DataStore { + /// Dispatched when DataStore begins syncing to the remote API via the API category + static let syncStarted = "DataStore.syncStarted" + + /// Dispatched when DataStore receives a sync response from the remote API via the API category. This event does not + /// define the source of the event--it could be in response to either a subscription or a locally-sourced mutation. + /// Regardless of source, incoming sync updates always have the possibility of updating local data, so listeners + /// who are interested in model updates must be notified in any case of a sync received. The HubPayload will be a + /// `MutationEvent` instance containing the newly mutated data from the remote API. + static let syncReceived = "DataStore.syncReceived" + + /// Dispatched when DataStore receives a sync response from the remote API via the API category. The Hub Payload + /// will be a `MutationEvent` instance that caused the conditional save failed. + static let conditionalSaveFailed = "DataStore.conditionalSaveFailed" + + /// Dispatched when: + /// - the DataStore starts + /// - each time a local mutation is enqueued into the outbox + /// - each time a local mutation is finished processing + /// HubPayload `OutboxStatusEvent` contains a boolean value `isEmpty` to notify if there are mutations in the outbox + static let outboxStatus = "DataStore.outboxStatus" + + /// Dispatched when DataStore has finished establishing its subscriptions to all syncable models + static let subscriptionsEstablished = "DataStore.subscriptionEstablished" + + /// Dispatched when DataStore is about to start sync queries + /// HubPayload `syncQueriesStartedEvent` contains an array of each model's `name` + static let syncQueriesStarted = "DataStore.syncQueriesStarted" + + /// Dispatched once for each model after the model instances have been synced from the cloud. + /// HubPayload `modelSyncedEvent` contains: name of model, sync type (full/delta), count of instances' mutation type + static let modelSynced = "DataStore.modelSynced" + + /// Dispatched when all models have been synced + static let syncQueriesReady = "DataStore.syncQueriesReady" + + /// Dispatched when: + /// - local store has loaded outgoing mutations from local storage + /// - if online, all data has finished syncing with cloud + /// When this event is emitted, DataStore is ready to sync changes between the local device and the cloud + static let ready = "DataStore.ready" + + /// Dispatched when DataStore starts and everytime network status changes + /// HubPayload `NetworkStatusEvent` contains a boolean value `active` to notify network status + static let networkStatus = "DataStore.networkStatus" + + /// Dispatched when a local mutation is enqueued into the outgoing mutation queue `outbox` + /// HubPayload `outboxMutationEvent` contains the name and instance of the model + static let outboxMutationEnqueued = "DataStore.outboxMutationEnqueued" + + /// Dispatched when a mutation from outgoing mutation queue `outbox` is sent to backend and updated locally. + /// HubPayload `outboxMutationEvent` contains the name and instance of the model + static let outboxMutationProcessed = "DataStore.outboxMutationProcessed" +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/DataStoreCategory.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/DataStoreCategory.swift new file mode 100644 index 0000000000..ec2e8a29ab --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/DataStoreCategory.swift @@ -0,0 +1,105 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +final public class DataStoreCategory: Category { + + /// Always .dataStore + public let categoryType: CategoryType = .dataStore + + var plugins = [PluginKey: DataStoreCategoryPlugin]() + + /// Returns the plugin added to the category, if only one plugin is added. Accessing this property if no plugins + /// are added, or if more than one plugin is added, will cause a `preconditionFailure`. + var plugin: DataStoreCategoryPlugin { + guard isConfigured else { + return Fatal.preconditionFailure( + """ + \(categoryType.displayName) category is not configured. Call Amplify.configure() before using \ + any methods on the category. + """ + ) + } + + guard !plugins.isEmpty else { + return Fatal.preconditionFailure("No plugins added to \(categoryType.displayName) category.") + } + + guard plugins.count == 1 else { + return Fatal.preconditionFailure( + """ + More than 1 plugin added to \(categoryType.displayName) category. \ + You must invoke operations on this category by getting the plugin you want, as in: + #"Amplify.\(categoryType.displayName).getPlugin(for: "ThePluginKey").foo() + """ + ) + } + + return plugins.first!.value + } + + var isConfigured = false + + // MARK: - Plugin handling + + /// Adds `plugin` to the list of Plugins that implement functionality for this category. + /// + /// - Parameter plugin: The Plugin to add + public func add(plugin: DataStoreCategoryPlugin) throws { + let key = plugin.key + guard !key.isEmpty else { + let pluginDescription = String(describing: plugin) + let error = DataStoreError.configuration("Plugin \(pluginDescription) has an empty `key`.", + "Set the `key` property for \(String(describing: plugin))") + throw error + } + + guard !isConfigured else { + let pluginDescription = String(describing: plugin) + let error = ConfigurationError.amplifyAlreadyConfigured( + "\(pluginDescription) cannot be added after `Amplify.configure()`.", + "Do not add plugins after calling `Amplify.configure()`." + ) + throw error + } + + plugins[plugin.key] = plugin + } + + /// Returns the added plugin with the specified `key` property. + /// + /// - Parameter key: The PluginKey (String) of the plugin to retrieve + /// - Returns: The wrapped plugin + public func getPlugin(for key: PluginKey) throws -> DataStoreCategoryPlugin { + guard let plugin = plugins[key] else { + let keys = plugins.keys.joined(separator: ", ") + let error = DataStoreError.configuration("No plugin has been added for '\(key)'.", + "Either add a plugin for '\(key)', or use one of the known keys: \(keys)") + throw error + } + return plugin + } + + /// Removes the plugin registered for `key` from the list of Plugins that implement functionality for this category. + /// If no plugin has been added for `key`, no action is taken, making this method safe to call multiple times. + /// + /// - Parameter key: The key used to `add` the plugin + public func removePlugin(for key: PluginKey) { + plugins.removeValue(forKey: key) + } + +} + +extension DataStoreCategory: DefaultLogger { + public static var log: Logger { + Amplify.Logging.logger(forCategory: CategoryType.dataStore.displayName, forNamespace: String(describing: self)) + } + public var log: Logger { + Self.log + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/DataStoreCategoryBehavior.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/DataStoreCategoryBehavior.swift new file mode 100644 index 0000000000..43aa4d3892 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/DataStoreCategoryBehavior.swift @@ -0,0 +1,96 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Combine + +public typealias DataStoreCategoryBehavior = DataStoreBaseBehavior & DataStoreSubscribeBehavior + +public protocol DataStoreBaseBehavior { + + /// Saves the model to storage. If sync is enabled, also initiates a sync of the mutation to the remote API + @discardableResult + func save(_ model: M, + where condition: QueryPredicate?) async throws -> M + + @available(*, deprecated, renamed: "query(byIdentifier:)") + func query(_ modelType: M.Type, + byId id: String) async throws -> M? + + func query(_ modelType: M.Type, + byIdentifier id: String) async throws -> M? + where M: ModelIdentifiable, M.IdentifierFormat == ModelIdentifierFormat.Default + + func query(_ modelType: M.Type, + byIdentifier id: ModelIdentifier) async throws -> M? + where M: ModelIdentifiable + + func query(_ modelType: M.Type, + where predicate: QueryPredicate?, + sort sortInput: QuerySortInput?, + paginate paginationInput: QueryPaginationInput?) async throws -> [M] + + func delete(_ model: M, + where predicate: QueryPredicate?) async throws + + func delete(_ modelType: M.Type, + withId id: String, + where predicate: QueryPredicate?) async throws + + func delete(_ modelType: M.Type, + withIdentifier id: String, + where predicate: QueryPredicate?) async throws where M: ModelIdentifiable, + M.IdentifierFormat == ModelIdentifierFormat.Default + + func delete(_ modelType: M.Type, + withIdentifier id: ModelIdentifier, + where predicate: QueryPredicate?) async throws where M: ModelIdentifiable + + func delete(_ modelType: M.Type, + where predicate: QueryPredicate) async throws + + /** + Synchronization starts automatically whenever you run any DataStore operation (query(), save(), delete()) + however, you can explicitly begin the process with DatasStore.start() + + - parameter completion: callback to be invoked on success or failure + */ + func start() async throws + + /** + To stop the DataStore sync process, you can use DataStore.stop(). This ensures the real time subscription + connection is closed when your app is no longer interested in updates, such as when you application is closed. + This can also be used to modify DataStore sync expressions at runtime by calling stop(), then start() + to force your sync expressions to be re-evaluated. + + - parameter completion: callback to be invoked on success or failure + */ + func stop() async throws + + /** + To clear local data from DataStore, use the clear method. + + - parameter completion: callback to be invoked on success or failure + */ + func clear() async throws +} + +public protocol DataStoreSubscribeBehavior { + /// Returns an AmplifyAsyncThrowingSequence for model changes (create, updates, delete) + /// - Parameter modelType: The model type to observe + func observe(_ modelType: M.Type) -> AmplifyAsyncThrowingSequence + + /// Returns a Publisher for query snapshots. + /// + /// - Parameters: + /// - modelType: The model type to observe + /// - predicate: The predicate to match for filtered results + /// - sortInput: The field and order of data to be returned + func observeQuery(for modelType: M.Type, + where predicate: QueryPredicate?, + sort sortInput: QuerySortInput?) + -> AmplifyAsyncThrowingSequence> +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/DataStoreCategoryConfiguration.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/DataStoreCategoryConfiguration.swift new file mode 100644 index 0000000000..b1298d96f4 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/DataStoreCategoryConfiguration.swift @@ -0,0 +1,16 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public struct DataStoreCategoryConfiguration: CategoryConfiguration { + public let plugins: [String: JSONValue] + + public init(plugins: [String: JSONValue] = [:]) { + self.plugins = plugins + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/DataStoreCategoryPlugin.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/DataStoreCategoryPlugin.swift new file mode 100644 index 0000000000..7cd1fe9bef --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/DataStoreCategoryPlugin.swift @@ -0,0 +1,14 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +public protocol DataStoreCategoryPlugin: Plugin, DataStoreCategoryBehavior { } + +public extension DataStoreCategoryPlugin { + var categoryType: CategoryType { + return .dataStore + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/DataStoreConflict.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/DataStoreConflict.swift new file mode 100644 index 0000000000..d2414cfaff --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/DataStoreConflict.swift @@ -0,0 +1,14 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// Information about a conflict that occurred attempting to sync a local model with a remote model +public struct DataStoreSyncConflict { + public let localModel: Model + public let remoteModel: Model + public let errors: [GraphQLError]? + public let mutationType: GraphQLMutationType +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/DataStoreError.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/DataStoreError.swift new file mode 100644 index 0000000000..e1954430f7 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/DataStoreError.swift @@ -0,0 +1,118 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +// MARK: - Enum + +public enum DataStoreError: Error { + case api(AmplifyError, MutationEvent? = nil) + case configuration(ErrorDescription, RecoverySuggestion, Error? = nil) + case conflict(DataStoreSyncConflict) + case invalidCondition(ErrorDescription, RecoverySuggestion, Error? = nil) + case decodingError(ErrorDescription, RecoverySuggestion) + case internalOperation(ErrorDescription, RecoverySuggestion, Error? = nil) + case invalidDatabase(path: String, Error? = nil) + case invalidModelName(String) + case invalidOperation(causedBy: Error? = nil) + case nonUniqueResult(model: String, count: Int) + case sync(ErrorDescription, RecoverySuggestion, Error? = nil) + case unknown(ErrorDescription, RecoverySuggestion, Error? = nil) +} + +// MARK: - AmplifyError + +extension DataStoreError: AmplifyError { + public var errorDescription: ErrorDescription { + switch self { + case .api(let error, _): + return error.errorDescription + case .conflict: + return "A conflict occurred syncing a local model with the remote API" + case .invalidDatabase: + return "Could not create a new database." + case .invalidModelName(let modelName): + return "No model registered with name '\(modelName)'" + case .invalidOperation(let causedBy): + return causedBy?.localizedDescription ?? "" + case .nonUniqueResult(let model, let count): + return """ + The result of the queried model of type \(model) return more than one result. + Only a single result was expected and the actual count was \(count). + """ + case .configuration(let errorDescription, _, _), + .invalidCondition(let errorDescription, _, _), + .decodingError(let errorDescription, _), + .internalOperation(let errorDescription, _, _), + .sync(let errorDescription, _, _), + .unknown(let errorDescription, _, _): + return errorDescription + } + } + + public var recoverySuggestion: RecoverySuggestion { + switch self { + case .api(let error, _): + return error.recoverySuggestion + case .conflict: + return "See this error's associated value for the details of the conflict" + case .invalidDatabase(let path, _): + return "Make sure the path \(path) is valid and the device has available storage space." + case .invalidModelName(let modelName): + // TODO: Is this the right command to run to generate models? + return "Make sure the model named '\(modelName)' is registered by running `amplify codegen`" + case .invalidOperation(let causedBy): + return causedBy?.localizedDescription ?? "" + case .nonUniqueResult: + return """ + Check that the condition applied to the query actually guarantees uniqueness, such + as unique indexes and primary keys. + """ + case .configuration(_, let recoverySuggestion, _), + .invalidCondition(_, let recoverySuggestion, _), + .decodingError(_, let recoverySuggestion), + .internalOperation(_, let recoverySuggestion, _), + .sync(_, let recoverySuggestion, _), + .unknown(_, let recoverySuggestion, _): + return recoverySuggestion + } + } + + public var underlyingError: Error? { + switch self { + case .api(let amplifyError, _): + return amplifyError + case .configuration(_, _, let underlyingError), + .invalidCondition(_, _, let underlyingError), + .internalOperation(_, _, let underlyingError), + .invalidDatabase(_, let underlyingError), + .invalidOperation(let underlyingError), + .sync(_, _, let underlyingError), + .unknown(_, _, let underlyingError): + return underlyingError + default: + return nil + } + } + + public init( + errorDescription: ErrorDescription = "An unknown error occurred", + recoverySuggestion: RecoverySuggestion = "See `underlyingError` for more details", + error: Error + ) { + if let error = error as? Self { + self = error + } else if let amplifyError = error as? AmplifyError { + self = .api(amplifyError) + } else if error.isOperationCancelledError { + self = .unknown("Operation cancelled", "", error) + } else { + self = .unknown(errorDescription, recoverySuggestion, error) + } + } + +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/DataStoreStatement.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/DataStoreStatement.swift new file mode 100644 index 0000000000..dc5b014a31 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/DataStoreStatement.swift @@ -0,0 +1,32 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// This protocol represents a statement that will be executed in a specific storage +/// implementations. Concrete types of this protocol may include SQL insert statements, +/// queries or GraphQL mutations. +public protocol DataStoreStatement { + + /// The type of the variables container related to a concrete statement implementation + associatedtype Variables + + /// The type of the `Model` associated with a particular statement + @available(*, deprecated, message: """ + Use of modelType inside the DatastoreStatement is deprecated, use modelSchema instead. + """) + var modelType: Model.Type { get } + + /// The model schema of the `Model` associated with a particular statement + var modelSchema: ModelSchema { get } + + /// The actual string content of the statement (e.g. a SQL query or a GraphQL document) + var stringValue: String { get } + + /// The variables associated with the statement + var variables: Variables { get } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/GraphQL/GraphQLMutationType.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/GraphQL/GraphQLMutationType.swift new file mode 100644 index 0000000000..2e94a75fdc --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/GraphQL/GraphQLMutationType.swift @@ -0,0 +1,15 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// Defines the type of a GraphQL mutation. +public enum GraphQLMutationType: String, Codable { + case create + case update + case delete +} + +extension GraphQLMutationType: CaseIterable { } diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/GraphQL/GraphQLQueryType.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/GraphQL/GraphQLQueryType.swift new file mode 100644 index 0000000000..bce8d02ba6 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/GraphQL/GraphQLQueryType.swift @@ -0,0 +1,16 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// Defines the type of query, +/// `list` which returns multiple results and can optionally use filters +/// `get`, which aims to fetch one result identified by its `id`. +/// `sync`, similar to `list` and returns results with optionally specifically a point in time +public enum GraphQLQueryType: String { + case get + case list + case sync +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/GraphQL/GraphQLSubscriptionType.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/GraphQL/GraphQLSubscriptionType.swift new file mode 100644 index 0000000000..f8da7ebabe --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/GraphQL/GraphQLSubscriptionType.swift @@ -0,0 +1,15 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// Defines the type of a GraphQL subscription. +public enum GraphQLSubscriptionType: String { + case onCreate + case onDelete + case onUpdate +} + +extension GraphQLSubscriptionType: CaseIterable { } diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Internal/DataStoreCategory+Configurable.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Internal/DataStoreCategory+Configurable.swift new file mode 100644 index 0000000000..a6241d4980 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Internal/DataStoreCategory+Configurable.swift @@ -0,0 +1,52 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +extension DataStoreCategory: CategoryConfigurable { + + func configure(using amplifyConfiguration: AmplifyConfiguration) throws { + if let configuration = categoryConfiguration(from: amplifyConfiguration) { + try configure(using: configuration) + } else { + try configureFirstWithEmptyConfiguration() + } + } + + func configure(using amplifyConfiguration: AmplifyOutputsData) throws { + try configureFirstWithEmptyConfiguration() + } + + func configure(using configuration: CategoryConfiguration?) throws { + guard !isConfigured else { + let error = ConfigurationError.amplifyAlreadyConfigured( + "\(categoryType.displayName) has already been configured.", + "Remove the duplicate call to `Amplify.configure()`" + ) + throw error + } + + try Amplify.configure(plugins: Array(plugins.values), using: configuration) + + isConfigured = true + } + + func configureFirstWithEmptyConfiguration() throws { + guard !isConfigured else { + let error = ConfigurationError.amplifyAlreadyConfigured( + "\(categoryType.displayName) has already been configured.", + "Remove the duplicate call to `Amplify.configure()`" + ) + throw error + } + guard let dataStorePlugin = plugins.first else { + return + } + + try dataStorePlugin.value.configure(using: []) + isConfigured = true + } + +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Internal/DataStoreCategory+Resettable.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Internal/DataStoreCategory+Resettable.swift new file mode 100644 index 0000000000..f7b7647122 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Internal/DataStoreCategory+Resettable.swift @@ -0,0 +1,26 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension DataStoreCategory: Resettable { + + public func reset() async { + await withTaskGroup(of: Void.self) { taskGroup in + for plugin in plugins.values { + taskGroup.addTask { [weak self] in + self?.log.verbose("Resetting \(String(describing: self?.categoryType)) plugin") + await plugin.reset() + self?.log.verbose("Resetting \(String(describing: self?.categoryType)) plugin: finished") + } + } + await taskGroup.waitForAll() + } + + isConfigured = false + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/AmplifyModelRegistration.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/AmplifyModelRegistration.swift new file mode 100644 index 0000000000..5ed27915bf --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/AmplifyModelRegistration.swift @@ -0,0 +1,20 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Protocol that defines a contract between the consumer and the DataStore plugin. +/// All models have to be registered and have an associated `version`. +public protocol AmplifyModelRegistration { + + /// Function called during plugin initialization. Implementations must + /// register all the available models here. + func registerModels(registry: ModelRegistry.Type) + + /// The version associated with the current schema. + var version: String { get } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/Embedded.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/Embedded.swift new file mode 100644 index 0000000000..0a588d1d43 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/Embedded.swift @@ -0,0 +1,35 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +// MARK: - Embeddable + +/// A `Embeddable` type can be used in a `Model` as an embedded type. All types embedded in a `Model` as an +/// `embedded(type:)` or `embeddedCollection(of:)` must comform to the `Embeddable` protocol except for Swift's Basic +/// types embedded as a collection. A collection of String can be embedded in the `Model` as +/// `embeddedCollection(of: String.self)` without needing to conform to Embeddable. +/// - Warning: Although this has `public` access, it is intended for internal & codegen use and should not be used +/// directly by host applications. The behavior of this may change without warning. Though it is not used by host +/// application making any change to these `public` types should be backward compatible, otherwise it will be a +/// breaking change. +public protocol Embeddable: Codable { + + /// A reference to the `ModelSchema` associated with this embedded type. + static var schema: ModelSchema { get } +} + +extension Embeddable { + public static func defineSchema(name: String? = nil, + attributes: ModelAttribute..., + define: (inout ModelSchemaDefinition) -> Void) -> ModelSchema { + var definition = ModelSchemaDefinition(name: name ?? "", + attributes: attributes) + define(&definition) + return definition.build() + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/Model+Array.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/Model+Array.swift new file mode 100644 index 0000000000..4054f30af8 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/Model+Array.swift @@ -0,0 +1,37 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension Array where Element: Model { + + /// - Warning: Although this has `public` access, it is intended for internal & codegen use and should not be used + /// directly by host applications. The behavior of this may change without warning. Though it is not used by host + /// application making any change to these `public` types should be backward compatible, otherwise it will be a + /// breaking change. + public func unique() throws -> Element? { + guard (0 ... 1).contains(count) else { + throw DataStoreError.nonUniqueResult(model: Element.modelName, count: count) + } + return first + } +} + +extension Array where Element == Model { + + /// - Warning: Although this has `public` access, it is intended for internal & codegen use and should not be used + /// directly by host applications. The behavior of this may change without warning. Though it is not used by host + /// application making any change to these `public` types should be backward compatible, otherwise it will be a + /// breaking change. + public func unique() throws -> Element? { + guard (0 ... 1).contains(count) else { + let firstModelName = self[0].modelName + throw DataStoreError.nonUniqueResult(model: firstModelName, count: count) + } + return first + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/Model+Codable.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/Model+Codable.swift new file mode 100644 index 0000000000..2eff95f1e7 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/Model+Codable.swift @@ -0,0 +1,85 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Adds JSON serialization behavior to all types that conform to the `Model` protocol. +extension Model where Self: Codable { + + /// De-serialize a JSON string into an instance of the concrete type that conforms + /// to the `Model` protocol. + /// + /// - Parameters: + /// - json: a valid JSON object as `String` + /// - decoder: an optional JSONDecoder to use to decode the model. Defaults to `JSONDecoder()`, using a + /// custom date formatter that decodes ISO8601 dates both with and without fractional seconds + /// - Returns: an instance of the concrete type conforming to `Model` + /// - Throws: `DecodingError.dataCorrupted` in case data is not a valid JSON or any + /// other decoding specific error that `JSONDecoder.decode()` might throw. + /// - Warning: Although this has `public` access, it is intended for internal & codegen use and should not be used + /// directly by host applications. The behavior of this may change without warning. Though it is not used by host + /// application making any change to these `public` types should be backward compatible, otherwise it will be a + /// breaking change. + public static func from(json: String, + decoder: JSONDecoder? = nil) throws -> Self { + let resolvedDecoder: JSONDecoder + if let decoder = decoder { + resolvedDecoder = decoder + } else { + resolvedDecoder = JSONDecoder(dateDecodingStrategy: ModelDateFormatting.decodingStrategy) + } + + return try resolvedDecoder.decode(Self.self, from: Data(json.utf8)) + } + + /// De-serialize a `Dictionary` into an instance of the concrete type that conforms + /// to the `Model` protocol. + /// + /// - Parameter dictionary: containing keys and values that match the target type + /// - Returns: an instance of the concrete type conforming to `Model` + /// - Throws: `DecodingError.dataCorrupted` in case data is not a valid JSON or any + /// other decoding specific error that `JSONDecoder.decode()` might throw. + /// - Warning: Although this has `public` access, it is intended for internal & codegen use and should not be used + /// directly by host applications. The behavior of this may change without warning. Though it is not used by host + /// application making any change to these `public` types should be backward compatible, otherwise it will be a + /// breaking change. + public static func from(dictionary: [String: Any]) throws -> Self { + let data = try JSONSerialization.data(withJSONObject: dictionary) + let decoder = JSONDecoder(dateDecodingStrategy: ModelDateFormatting.decodingStrategy) + return try decoder.decode(Self.self, from: data) + } + + /// Converts the `Model` instance to a JSON object as `String`. + /// - Parameters: + /// - encoder: an optional JSONEncoder to use to encode the model. Defaults to `JSONEncoder()`, using a + /// custom date formatter that encodes ISO8601 dates with fractional seconds + /// - Returns: the JSON representation of the `Model` + /// - seealso: https://developer.apple.com/documentation/foundation/jsonencoder/2895034-encode + /// - Warning: Although this has `public` access, it is intended for internal & codegen use and should not be used + /// directly by host applications. The behavior of this may change without warning. Though it is not used by host + /// application making any change to these `public` types should be backward compatible, otherwise it will be a + /// breaking change. + public func toJSON(encoder: JSONEncoder? = nil) throws -> String { + var resolvedEncoder = encoder ?? JSONEncoder( + dateEncodingStrategy: ModelDateFormatting.encodingStrategy + ) + + if isKnownUniquelyReferenced(&resolvedEncoder) { + resolvedEncoder.outputFormatting = .sortedKeys + } + + let data = try resolvedEncoder.encode(self) + guard let json = String(data: data, encoding: .utf8) else { + throw DataStoreError.decodingError( + "Invalid UTF-8 Data object. Could not convert the encoded Model into a valid UTF-8 JSON string", + "Check if your Model doesn't contain any value with invalid UTF-8 characters." + ) + } + + return json + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/Model+DateFormatting.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/Model+DateFormatting.swift new file mode 100644 index 0000000000..8540f066a9 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/Model+DateFormatting.swift @@ -0,0 +1,59 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// - Warning: Although this has `public` access, it is intended for internal & codegen use and should not be used +/// directly by host applications. The behavior of this may change without warning. Though it is not used by host +/// application making any change to these `public` types should be backward compatible, otherwise it will be a +/// breaking change. +public struct ModelDateFormatting { + + public static let decodingStrategy: JSONDecoder.DateDecodingStrategy = { + let strategy = JSONDecoder.DateDecodingStrategy.custom { decoder -> Date in + let container = try decoder.singleValueContainer() + let dateString = try container.decode(String.self) + let dateTime = try Temporal.DateTime(iso8601String: dateString) + return dateTime.foundationDate + } + + return strategy + }() + + public static let encodingStrategy: JSONEncoder.DateEncodingStrategy = { + let strategy = JSONEncoder.DateEncodingStrategy.custom { date, encoder in + var container = encoder.singleValueContainer() + try container.encode(Temporal.DateTime(date, timeZone: .utc).iso8601String) + } + return strategy + }() + +} + +public extension JSONDecoder { + + /// - Warning: Although this has `public` access, it is intended for internal & codegen use and should not be used + /// directly by host applications. The behavior of this may change without warning. Though it is not used by host + /// application making any change to these `public` types should be backward compatible, otherwise it will be a + /// breaking change. + convenience init(dateDecodingStrategy: JSONDecoder.DateDecodingStrategy) { + self.init() + self.dateDecodingStrategy = dateDecodingStrategy + } +} + +public extension JSONEncoder { + + /// - Warning: Although this has `public` access, it is intended for internal & codegen use and should not be used + /// directly by host applications. The behavior of this may change without warning. Though it is not used by host + /// application making any change to these `public` types should be backward compatible, otherwise it will be a + /// breaking change. + convenience init(dateEncodingStrategy: JSONEncoder.DateEncodingStrategy) { + self.init() + self.dateEncodingStrategy = dateEncodingStrategy + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/Model+Enum.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/Model+Enum.swift new file mode 100644 index 0000000000..be568603e4 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/Model+Enum.swift @@ -0,0 +1,31 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Protocol that represents a `Codable` Enum that can be persisted and easily +/// integrate with remote APIs since it must have a raw `String` value. +/// +/// That means only enums without associated values can be used as model properties. +/// +/// - Example: +/// +/// ```swift +/// public enum PostStatus: String, EnumPersistable { +/// case draft +/// case published +/// } +/// ``` +/// - Warning: Although this has `public` access, it is intended for internal use and should not be used directly +/// by host applications. The behavior of this may change without warning. +public protocol EnumPersistable: Codable { + + var rawValue: String { get } + + init?(rawValue: String) + +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/Model+Subscript.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/Model+Subscript.swift new file mode 100644 index 0000000000..97aed1b40c --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/Model+Subscript.swift @@ -0,0 +1,41 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// Implement dynamic access to properties of a `Model`. +/// +/// ```swift +/// let id = model["id"] +/// ``` +extension Model { + + /// - Warning: Although this has `public` access, it is intended for internal & codegen use and should not be used + /// directly by host applications. The behavior of this may change without warning. Though it is not used by host + /// application making any change to these `public` types should be backward compatible, otherwise it will be a + /// breaking change. + public subscript(_ key: String) -> Any?? { + + if let jsonModel = self as? JSONValueHolder { + let value = jsonModel.jsonValue(for: key) + return value + } + + let mirror = Mirror(reflecting: self) + let firstChild = mirror.children.first { $0.label == key } + guard let property = firstChild else { + return nil + } + + // Special case for properties that have optional values. Child.property is Any rather than Any?, and we want + // callers to receive a `.some(nil)` (indicating that the property exists, but has a `nil` value) rather than a + // bare `nil` (indicating the property doesn't exist). This matches how Swift handles dictionary subscripting + if case Optional.none = property.value { + return .some(nil) + } else { + return property.value + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/ModelListDecoder.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/ModelListDecoder.swift new file mode 100644 index 0000000000..f3d30371f4 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/ModelListDecoder.swift @@ -0,0 +1,40 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Registry of `ModelListDecoder`'s used to retrieve decoders that can create `ModelListProvider`s to perform +/// List functionality. +/// +/// - Warning: Although this has `public` access, it is intended for internal & codegen use and should not be used +/// directly by host applications. The behavior of this may change without warning. Though it is not used by host +/// application making any change to these `public` types should be backward compatible, otherwise it will be a breaking +/// change. +public struct ModelListDecoderRegistry { + public static var listDecoders = AtomicValue(initialValue: [ModelListDecoder.Type]()) + + /// Register a decoder during plugin configuration time, to allow runtime retrievals of list providers. + public static func registerDecoder(_ listDecoder: ModelListDecoder.Type) { + listDecoders.append(listDecoder) + } +} + +extension ModelListDecoderRegistry { + static func reset() { + listDecoders.set([ModelListDecoder.Type]()) + } +} + +/// `ModelListDecoder` provides decoding and list functionality. +/// +/// - Warning: Although this has `public` access, it is intended for internal & codegen use and should not be used +/// directly by host applications. The behavior of this may change without warning. Though it is not used by host +/// application making any change to these `public` types should be backward compatible, otherwise it will be a breaking +/// change. +public protocol ModelListDecoder { + static func decode(modelType: ModelType.Type, decoder: Decoder) -> AnyModelListProvider? +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/ModelListProvider.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/ModelListProvider.swift new file mode 100644 index 0000000000..582d9bdc1c --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/ModelListProvider.swift @@ -0,0 +1,110 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Empty protocol used as a marker to detect when the type is a `List` +/// +/// - Warning: Although this has `public` access, it is intended for internal & codegen use and should not be used +/// directly by host applications. The behavior of this may change without warning. Though it is not used by host +/// application making any change to these `public` types should be backward compatible, otherwise it will be a breaking +/// change. +public protocol ModelListMarker { } + +/// State of the ListProvider +/// +/// - Warning: Although this has `public` access, it is intended for internal & codegen use and should not be used +/// directly by host applications. The behavior of this may change without warning. Though it is not used by host +/// application making any change to these `public` types should be backward compatible, otherwise it will be a breaking +/// change. +public enum ModelListProviderState { + /// If the list represents an association between two models, the `associatedIdentifiers` will + /// hold the information necessary to query the associated elements (e.g. comments of a post) + /// + /// The associatedFields represents the field to which the owner of the `List` is linked to. + /// For example, if `Post.comments` is associated with `Comment.post` the `List` + /// of `Post` will have a reference to the `post` field in `Comment`. + case notLoaded(associatedIdentifiers: [String], associatedFields: [String]) + case loaded([Element]) +} + +/// - Warning: Although this has `public` access, it is intended for internal & codegen use and should not be used +/// directly by host applications. The behavior of this may change without warning. Though it is not used by host +/// application making any change to these `public` types should be backward compatible, otherwise it will be a breaking +/// change. +public protocol ModelListProvider { + associatedtype Element: Model + + func getState() -> ModelListProviderState + + /// Retrieve the array of `Element` from the data source asychronously. + func load() async throws -> [Element] + + /// Check if there is subsequent data to retrieve. This method always returns false if the underlying provider is + /// not loaded. Make sure the underlying data is loaded by calling `load(completion)` before calling this method. + /// If true, the next page can be retrieved using `getNextPage(completion:)`. + func hasNextPage() -> Bool + + /// Asynchronously retrieve the next page as a new in-memory List object. Returns a failure if there + /// is no next page of results. You can validate whether the list has another page with `hasNextPage()`. + func getNextPage() async throws -> List + + /// Custom encoder + func encode(to encoder: Encoder) throws +} + +/// - Warning: Although this has `public` access, it is intended for internal & codegen use and should not be used +/// directly by host applications. The behavior of this may change without warning. Though it is not used by host +/// application making any change to these `public` types should be backward compatible, otherwise it will be a breaking +/// change. +public struct AnyModelListProvider: ModelListProvider { + private let getStateClosure: () -> ModelListProviderState + private let loadAsync: () async throws -> [Element] + private let hasNextPageClosure: () -> Bool + private let getNextPageAsync: () async throws -> List + private let encodeClosure: (Encoder) throws -> Void + + public init( + provider: Provider + ) where Provider.Element == Self.Element { + self.getStateClosure = provider.getState + self.loadAsync = provider.load + self.hasNextPageClosure = provider.hasNextPage + self.getNextPageAsync = provider.getNextPage + self.encodeClosure = provider.encode + } + + public func getState() -> ModelListProviderState { + getStateClosure() + } + + public func load() async throws -> [Element] { + try await loadAsync() + } + + public func hasNextPage() -> Bool { + hasNextPageClosure() + } + + public func getNextPage() async throws -> List { + try await getNextPageAsync() + } + + public func encode(to encoder: Encoder) throws { + try encodeClosure(encoder) + } +} + +/// - Warning: Although this has `public` access, it is intended for internal & codegen use and should not be used +/// directly by host applications. The behavior of this may change without warning. Though it is not used by host +/// application making any change to these `public` types should be backward compatible, otherwise it will be a breaking +/// change. +public extension ModelListProvider { + func eraseToAnyModelListProvider() -> AnyModelListProvider { + AnyModelListProvider(provider: self) + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/ModelProvider.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/ModelProvider.swift new file mode 100644 index 0000000000..98c93dfa00 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/ModelProvider.swift @@ -0,0 +1,92 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +// swiftlint:disable type_name +/// Protocol used as a marker to detect when the type is a `LazyReference`. +/// Used to retrieve either the `reference` or the `identifiers` of the Model directly, without having load a not +/// loaded LazyReference. This is useful when translating the model object over to the payload required for the +/// underlying storage, such as storing the values in DataStore's local database or AppSync GraphQL request payload. +/// +/// +/// - Warning: Although this has `public` access, it is intended for internal & codegen use and should not be used +/// directly by host applications. The behavior of this may change without warning. Though it is not used by host +/// application making any change to these `public` types should be backward compatible, otherwise it will be a breaking +/// change. +public protocol _LazyReferenceValue { + var _state: _LazyReferenceValueState { get } // swiftlint:disable:this identifier_name +} + +public enum _LazyReferenceValueState { + case notLoaded(identifiers: [LazyReferenceIdentifier]?) + case loaded(model: Model?) +} +// swiftlint:enable type_name + +/// State of the ModelProvider +/// +/// - Warning: Although this has `public` access, it is intended for internal & codegen use and should not be used +/// directly by host applications. The behavior of this may change without warning. Though it is not used by host +/// application making any change to these `public` types should be backward compatible, otherwise it will be a breaking +/// change. +public enum ModelProviderState { + case notLoaded(identifiers: [LazyReferenceIdentifier]?) + case loaded(model: Element?) +} + +/// - Warning: Although this has `public` access, it is intended for internal & codegen use and should not be used +/// directly by host applications. The behavior of this may change without warning. Though it is not used by host +/// application making any change to these `public` types should be backward compatible, otherwise it will be a breaking +/// change. +public protocol ModelProvider { + associatedtype Element: Model + + func load() async throws -> Element? + + func getState() -> ModelProviderState + + func encode(to encoder: Encoder) throws +} + +/// - Warning: Although this has `public` access, it is intended for internal & codegen use and should not be used +/// directly by host applications. The behavior of this may change without warning. Though it is not used by host +/// application making any change to these `public` types should be backward compatible, otherwise it will be a breaking +/// change. +public struct AnyModelProvider: ModelProvider { + + private let loadAsync: () async throws -> Element? + private let getStateClosure: () -> ModelProviderState + private let encodeClosure: (Encoder) throws -> Void + + public init(provider: Provider) where Provider.Element == Self.Element { + self.loadAsync = provider.load + self.getStateClosure = provider.getState + self.encodeClosure = provider.encode + } + + public func load() async throws -> Element? { + try await loadAsync() + } + + public func getState() -> ModelProviderState { + getStateClosure() + } + + public func encode(to encoder: Encoder) throws { + try encodeClosure(encoder) + } +} + +/// - Warning: Although this has `public` access, it is intended for internal & codegen use and should not be used +/// directly by host applications. The behavior of this may change without warning. Though it is not used by host +/// application making any change to these `public` types should be backward compatible, otherwise it will be a breaking +/// change. +public extension ModelProvider { + func eraseToAnyModelProvider() -> AnyModelProvider { + AnyModelProvider(provider: self) + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/ModelProviderDecoder.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/ModelProviderDecoder.swift new file mode 100644 index 0000000000..8470cba587 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/ModelProviderDecoder.swift @@ -0,0 +1,51 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Registry of `ModelProviderDecoder`'s used to retrieve decoders that can create `ModelProvider`s to perform +/// LazyReference functionality. +/// +/// - Warning: Although this has `public` access, it is intended for internal & codegen use and should not be used +/// directly by host applications. The behavior of this may change without warning. Though it is not used by host +/// application making any change to these `public` types should be backward compatible, otherwise it will be a breaking +/// change. +public struct ModelProviderRegistry { + static var decoders = AtomicValue(initialValue: [ModelProviderDecoder.Type]()) + + /// Register a decoder during plugin configuration time, to allow runtime retrievals of model providers. + public static func registerDecoder(_ decoder: ModelProviderDecoder.Type) { + decoders.append(decoder) + } +} + +extension ModelProviderRegistry { + static func reset() { + decoders.set([ModelProviderDecoder.Type]()) + } +} + +/// Extension to hold the decoder sources +public extension ModelProviderRegistry { + + /// Static decoder sources that will be referenced to initialize different type of decoders having source as + /// a metadata. + struct DecoderSource { + public static let dataStore = "DataStore" + public static let appSync = "AppSync" + } +} + +/// `ModelProviderDecoder` provides decoding and lazy reference functionality. +/// +/// - Warning: Although this has `public` access, it is intended for internal & codegen use and should not be used +/// directly by host applications. The behavior of this may change without warning. Though it is not used by host +/// application making any change to these `public` types should be backward compatible, otherwise it will be a breaking +/// change. +public protocol ModelProviderDecoder { + static func decode(modelType: ModelType.Type, decoder: Decoder) -> AnyModelProvider? +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/ModelRegistry+Syncable.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/ModelRegistry+Syncable.swift new file mode 100644 index 0000000000..b378e84026 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/ModelRegistry+Syncable.swift @@ -0,0 +1,17 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +public extension ModelRegistry { + + /// - Warning: Although this has `public` access, it is intended for internal & codegen use and should not be used + /// directly by host applications. The behavior of this may change without warning. Though it is not used by host + /// application making any change to these `public` types should be backward compatible, otherwise it will be a + /// breaking change. + static var hasSyncableModels: Bool { + return modelSchemas.contains { !$0.isSystem } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/ModelRegistry.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/ModelRegistry.swift new file mode 100644 index 0000000000..ff3fd6ef1c --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/ModelRegistry.swift @@ -0,0 +1,104 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// - Warning: Although this has `public` access, it is intended for internal use and should not be used directly +/// by host applications. The behavior of this may change without warning. +public struct ModelRegistry { + private static let concurrencyQueue = DispatchQueue(label: "com.amazonaws.ModelRegistry.concurrency", + target: DispatchQueue.global()) + + /// ModelDecoders are used to decode untyped model data, looking up by model name + private typealias ModelDecoder = (String, JSONDecoder?) throws -> Model + + private static var modelTypes = [ModelName: Model.Type]() + + private static var modelDecoders = [ModelName: ModelDecoder]() + + private static var modelSchemaMapping = [ModelName: ModelSchema]() + + public static var models: [Model.Type] { + concurrencyQueue.sync { + Array(modelTypes.values) + } + } + + public static var modelSchemas: [ModelSchema] { + concurrencyQueue.sync { + Array(modelSchemaMapping.values) + } + } + + public static func register(modelType: Model.Type) { + register(modelType: modelType, + modelSchema: modelType.schema) { (jsonString, jsonDecoder) -> Model in + let model = try modelType.from(json: jsonString, decoder: jsonDecoder) + return model + } + } + + public static func register(modelType: Model.Type, + modelSchema: ModelSchema, + jsonDecoder: @escaping (String, JSONDecoder?) throws -> Model) { + concurrencyQueue.sync { + let modelDecoder: ModelDecoder = { jsonString, decoder in + return try jsonDecoder(jsonString, decoder) + } + let modelName = modelSchema.name + modelSchemaMapping[modelName] = modelSchema + modelTypes[modelName] = modelType + modelDecoders[modelName] = modelDecoder + } + } + + public static func modelType(from name: ModelName) -> Model.Type? { + concurrencyQueue.sync { + modelTypes[name] + } + } + + @available(*, deprecated, message: """ + Retrieving model schema using Model.Type is deprecated, instead retrieve using model name. + """) + public static func modelSchema(from modelType: Model.Type) -> ModelSchema? { + return modelSchema(from: modelType.modelName) + } + + public static func modelSchema(from name: ModelName) -> ModelSchema? { + concurrencyQueue.sync { + modelSchemaMapping[name] + } + } + + public static func decode(modelName: ModelName, + from jsonString: String, + jsonDecoder: JSONDecoder? = nil) throws -> Model { + try concurrencyQueue.sync { + guard let decoder = modelDecoders[modelName] else { + throw DataStoreError.decodingError( + "No decoder found for model named \(modelName)", + """ + There is no decoder registered for the model named \(modelName). \ + Register models with `ModelRegistry.register(modelName:)` at startup. + """) + } + + return try decoder(jsonString, jsonDecoder) + } + } +} + +extension ModelRegistry { + static func reset() { + concurrencyQueue.sync { + modelTypes = [:] + modelDecoders = [:] + modelSchemaMapping = [:] + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/Persistable.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/Persistable.swift new file mode 100644 index 0000000000..b7a53acf5a --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/Persistable.swift @@ -0,0 +1,220 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Types that conform to the `Persistable` protocol represent values that can be +/// persisted in a database. +/// +/// Core Types that conform to this protocol: +/// - `Bool` +/// - `Double` +/// - `Int` +/// - `String` +/// - `Temporal.Date` +/// - `Temporal.DateTime` +/// - `Temporal.Time` +/// - Warning: Although this has `public` access, it is intended for internal use and should not be used directly +/// by host applications. The behavior of this may change without warning. +public protocol Persistable: Encodable {} + +extension Bool: Persistable {} +extension Double: Persistable {} +extension Int: Persistable {} +extension String: Persistable {} +extension Temporal.Date: Persistable {} +extension Temporal.DateTime: Persistable {} +extension Temporal.Time: Persistable {} + +struct PersistableHelper { + + /// Polymorphic utility that allows two persistable references to be checked + /// for equality regardless of their concrete type. + /// + /// - Note: Maintainers need to keep this utility updated when news types that conform + /// to `Persistable` are added. + /// + /// - Parameters: + /// - lhs: a reference to a Persistable object + /// - rhs: another reference + /// - Returns: `true` in case both values are equal or `false` otherwise + /// - Warning: Although this has `public` access, it is intended for internal & codegen use and should not be used + /// directly by host applications. The behavior of this may change without warning. Though it is not used by host + /// application making any change to these `public` types should be backward compatible, otherwise it will be a + /// breaking change. + public static func isEqual(_ lhs: Persistable?, _ rhs: Persistable?) -> Bool { + if lhs == nil && rhs == nil { + return true + } + switch (lhs, rhs) { + case let (lhs, rhs) as (Bool, Bool): + return lhs == rhs + case let (lhs, rhs) as (Temporal.Date, Temporal.Date): + return lhs == rhs + case let (lhs, rhs) as (Temporal.DateTime, Temporal.DateTime): + return lhs == rhs + case let (lhs, rhs) as (Temporal.Time, Temporal.Time): + return lhs == rhs + case let (lhs, rhs) as (Double, Double): + return lhs == rhs + case let (lhs, rhs) as (Int, Int): + return lhs == rhs + case let (lhs, rhs) as (String, String): + return lhs == rhs + default: + return false + } + } + + // We are promoting Int to Double in the case where we are comparing these two types + public static func isEqual(_ lhs: Any?, _ rhs: Persistable?) -> Bool { + if lhs == nil && rhs == nil { + return true + } + switch (lhs, rhs) { + case let (lhs, rhs) as (Bool, Bool): + return lhs == rhs + case let (lhs, rhs) as (Temporal.Date, Temporal.Date): + return lhs == rhs + case let (lhs, rhs) as (Temporal.DateTime, Temporal.DateTime): + return lhs == rhs + case let (lhs, rhs) as (Temporal.Time, Temporal.Time): + return lhs == rhs + case let (lhs, rhs) as (Double, Double): + return lhs == rhs + case let (lhs, rhs) as (Int, Int): + return lhs == rhs + case let (lhs, rhs) as (Int, Double): + return Double(lhs) == rhs + case let (lhs, rhs) as (Double, Int): + return lhs == Double(rhs) + case let (lhs, rhs) as (String, String): + return lhs == rhs + default: + return false + } + } + + // We are promoting Int to Double in the case where we are comparing these two types + public static func isLessOrEqual(_ lhs: Any?, _ rhs: Persistable?) -> Bool { + if lhs == nil && rhs == nil { + return true + } + switch (lhs, rhs) { + // case Bool Removed + case let (lhs, rhs) as (Temporal.Date, Temporal.Date): + return lhs <= rhs + case let (lhs, rhs) as (Temporal.DateTime, Temporal.DateTime): + return lhs <= rhs + case let (lhs, rhs) as (Temporal.Time, Temporal.Time): + return lhs <= rhs + case let (lhs, rhs) as (Double, Double): + return lhs <= rhs + case let (lhs, rhs) as (Int, Int): + return lhs <= rhs + case let (lhs, rhs) as (Int, Double): + return Double(lhs) <= rhs + case let (lhs, rhs) as (Double, Int): + return lhs <= Double(rhs) + case let (lhs, rhs) as (String, String): + return lhs <= rhs + default: + return false + } + } + + // We are promoting Int to Double in the case where we are comparing these two types + public static func isLessThan(_ lhs: Any?, _ rhs: Persistable?) -> Bool { + if lhs == nil && rhs == nil { + return false + } + switch (lhs, rhs) { + // case Bool Removed + case let (lhs, rhs) as (Temporal.Date, Temporal.Date): + return lhs < rhs + case let (lhs, rhs) as (Temporal.DateTime, Temporal.DateTime): + return lhs < rhs + case let (lhs, rhs) as (Temporal.Time, Temporal.Time): + return lhs < rhs + case let (lhs, rhs) as (Double, Double): + return lhs < rhs + case let (lhs, rhs) as (Int, Int): + return lhs < rhs + case let (lhs, rhs) as (Int, Double): + return Double(lhs) < rhs + case let (lhs, rhs) as (Double, Int): + return lhs < Double(rhs) + case let (lhs, rhs) as (String, String): + return lhs < rhs + default: + return false + } + } + + // We are promoting Int to Double in the case where we are comparing these two types + public static func isGreaterOrEqual(_ lhs: Any?, _ rhs: Persistable?) -> Bool { + if lhs == nil && rhs == nil { + return true + } + switch (lhs, rhs) { + // case Bool Removed + case let (lhs, rhs) as (Temporal.Date, Temporal.Date): + return lhs >= rhs + case let (lhs, rhs) as (Temporal.DateTime, Temporal.DateTime): + return lhs >= rhs + case let (lhs, rhs) as (Temporal.Time, Temporal.Time): + return lhs >= rhs + case let (lhs, rhs) as (Double, Double): + return lhs >= rhs + case let (lhs, rhs) as (Int, Int): + return lhs >= rhs + case let (lhs, rhs) as (Int, Double): + return Double(lhs) >= rhs + case let (lhs, rhs) as (Double, Int): + return lhs >= Double(rhs) + case let (lhs, rhs) as (String, String): + return lhs >= rhs + default: + return false + } + } + + // We are promoting Int to Double in the case where we are comparing these two types + public static func isGreaterThan(_ lhs: Any?, _ rhs: Persistable?) -> Bool { + if lhs == nil && rhs == nil { + return false + } + switch (lhs, rhs) { + // case Bool Removed + case let (lhs, rhs) as (Temporal.Date, Temporal.Date): + return lhs > rhs + case let (lhs, rhs) as (Temporal.DateTime, Temporal.DateTime): + return lhs > rhs + case let (lhs, rhs) as (Temporal.Time, Temporal.Time): + return lhs > rhs + case let (lhs, rhs) as (Double, Double): + return lhs > rhs + case let (lhs, rhs) as (Int, Int): + return lhs > rhs + case let (lhs, rhs) as (Double, Int): + return lhs > Double(rhs) + case let (lhs, rhs) as (Int, Double): + return Double(lhs) > rhs + case let (lhs, rhs) as (String, String): + return lhs > rhs + default: + return false + } + } + + public static func isBetween(_ start: Persistable, _ end: Persistable, _ value: Any?) -> Bool { + if value == nil { + return false + } + return isGreaterOrEqual(value, start) && isLessOrEqual(value, end) + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/Schema/AuthRule.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/Schema/AuthRule.swift new file mode 100644 index 0000000000..8a5c6b4aeb --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/Schema/AuthRule.swift @@ -0,0 +1,74 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// - Warning: Although this has `public` access, it is intended for internal use and should not be used directly +/// by host applications. The behavior of this may change without warning. +public enum AuthStrategy { + case owner + case groups + case `private` + case `public` + case custom +} + +/// - Warning: Although this has `public` access, it is intended for internal use and should not be used directly +/// by host applications. The behavior of this may change without warning. +public enum ModelOperation { + case create + case update + case delete + case read +} + +/// - Warning: Although this has `public` access, it is intended for internal use and should not be used directly +/// by host applications. The behavior of this may change without warning. +public enum AuthRuleProvider { + case apiKey + case oidc + case iam + case userPools + case function +} + +/// - Warning: Although this has `public` access, it is intended for internal use and should not be used directly +/// by host applications. The behavior of this may change without warning. +public typealias AuthRules = [AuthRule] + +/// - Warning: Although this has `public` access, it is intended for internal use and should not be used directly +/// by host applications. The behavior of this may change without warning. +public struct AuthRule { + public let allow: AuthStrategy + public let ownerField: String? + public let identityClaim: String? + public let groupClaim: String? + public let groups: [String] + public let groupsField: String? + public let operations: [ModelOperation] + public let provider: AuthRuleProvider? + + public init(allow: AuthStrategy, + ownerField: String? = nil, + identityClaim: String? = nil, + groupClaim: String? = nil, + groups: [String] = [], + groupsField: String? = nil, + provider: AuthRuleProvider? = nil, + operations: [ModelOperation] = []) { + self.allow = allow + self.ownerField = ownerField + self.identityClaim = identityClaim + self.groupClaim = groupClaim + self.groups = groups + self.groupsField = groupsField + self.provider = provider + self.operations = operations + } +} + +extension AuthRule: Hashable { + +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/Schema/Model+Schema.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/Schema/Model+Schema.swift new file mode 100644 index 0000000000..7a09d82580 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/Schema/Model+Schema.swift @@ -0,0 +1,77 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension Model { + + /// - Warning: Although this has `public` access, it is intended for internal & codegen use and should not be used + /// directly by host applications. The behavior of this may change without warning. Though it is not used by host + /// application making any change to these `public` types should be backward compatible, otherwise it will be a + /// breaking change. + public static var schema: ModelSchema { + // TODO load schema from JSON when this it not overridden by specific models + ModelSchema(name: modelName, fields: [:]) + } + + /// - Warning: Although this has `public` access, it is intended for internal & codegen use and should not be used + /// directly by host applications. The behavior of this may change without warning. Though it is not used by host + /// application making any change to these `public` types should be backward compatible, otherwise it will be a + /// breaking change. + public var schema: ModelSchema { + type(of: self).schema + } + + /// Utility function that enables a DSL-like `ModelSchema` definition. Instead of building + /// objects individually, developers can use this to create the schema with a more fluid + /// programming model. + /// + /// - Example: + /// ```swift + /// static let schema = defineSchema { model in + /// model.fields( + /// .field(name: "title", is: .required, ofType: .string) + /// ) + /// } + /// ``` + /// + /// - Parameters + /// - name: the name of the Model. Defaults to the class name + /// - attributes: model attributes (aka "directives" or "annotations") + /// - define: the closure used to define the model attributes and fields + /// - Returns: a valid `ModelSchema` instance + /// - Warning: Although this has `public` access, it is intended for internal & codegen use and should not be used + /// directly by host applications. The behavior of this may change without warning. + public static func defineSchema(name: String? = nil, + attributes: ModelAttribute..., + define: (inout ModelSchemaDefinition) -> Void) -> ModelSchema { + var definition = ModelSchemaDefinition(name: name ?? modelName, + attributes: attributes) + define(&definition) + return definition.build() + } + + /// - Warning: Although this has `public` access, it is intended for internal & codegen use and should not be used + /// directly by host applications. The behavior of this may change without warning. + public static func rule(allow: AuthStrategy, + ownerField: String? = nil, + identityClaim: String? = nil, + groupClaim: String? = nil, + groups: [String] = [], + groupsField: String? = nil, + provider: AuthRuleProvider? = nil, + operations: [ModelOperation] = []) -> AuthRule { + return AuthRule(allow: allow, + ownerField: ownerField, + identityClaim: identityClaim, + groupClaim: groupClaim, + groups: groups, + groupsField: groupsField, + provider: provider, + operations: operations) + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/Schema/ModelField+Association.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/Schema/ModelField+Association.swift new file mode 100644 index 0000000000..6dfafe2c1b --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/Schema/ModelField+Association.swift @@ -0,0 +1,373 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Defines the association type between two models. The type of association is +/// important when defining how to store and query them. Each association have +/// its own rules depending on the storage mechanism. +/// +/// The semantics of a association can be defined as: +/// +/// **Many-to-One/One-to-Many** +/// +/// The most common association type. It defines an array/collection on one side and a +/// single `Model` reference on the other. The side with the `Model` (marked as `belongsTo`) +/// holds a reference to the other side's `id` (aka "foreign key"). +/// +/// Example: +/// +/// ``` +/// struct Post: Model { +/// let id: Model.Identifier +/// +/// // hasMany(associatedWith: Comment.keys.post) +/// let comments: [Comment] +/// } +/// +/// struct Comment: Model { +/// let id: Model.Identifier +/// +/// // belongsTo +/// let post: Post +/// } +/// ``` +/// +/// **One-to-One** +/// +/// This type of association is not too common since in these scenarios data can usually +/// be normalized and stored under the same `Model`. However, there are use-cases where +/// one-to-one can be useful, specially when one side of the association is optional. +/// +/// Example: +/// +/// ``` +/// struct Person: Model { +/// // hasOne(associatedWith: License.keys.person) +/// let license: License? +/// } +/// +/// struct License: Model { +/// // belongsTo +/// let person: Person +/// } +/// ``` +/// +/// **Many-to-Many** +/// +/// These associations mean that an instance of one `Model` can relate to many other +/// instances of another `Model` and vice-versa. Many-to-Many is achieved by combining +/// `hasMany` and `belongsTo` with an intermediate `Model` that is responsible for +/// holding a reference to the keys of both related models. +/// +/// ``` +/// struct Book: Model { +/// // hasMany(associatedWith: BookAuthor.keys.book) +/// let auhors: [BookAuthor] +/// } +/// +/// struct Author: Model { +/// // hasMany(associatedWith: BookAuthor.keys.author) +/// let books: [BookAuthor] +/// } +/// +/// struct BookAuthor: Model { +/// // belongsTo +/// let book: Book +/// +/// // belongsTo +/// let author: Author +/// } +/// ``` +/// +/// - Warning: Although this has `public` access, it is intended for internal & codegen use and should not be used +/// directly by host applications. The behavior of this may change without warning. +public enum ModelAssociation { + case hasMany(associatedFieldName: String?, associatedFieldNames: [String] = []) + case hasOne(associatedFieldName: String?, targetNames: [String]) + case belongsTo(associatedFieldName: String?, targetNames: [String]) + + public static let belongsTo: ModelAssociation = .belongsTo(associatedFieldName: nil, targetNames: []) + + public static func belongsTo(targetName: String? = nil) -> ModelAssociation { + let targetNames = targetName.map { [$0] } ?? [] + return .belongsTo(associatedFieldName: nil, targetNames: targetNames) + } + + public static func hasMany( + associatedWith: CodingKey? = nil, + associatedFields: [CodingKey] = [] + ) -> ModelAssociation { + return .hasMany( + associatedFieldName: associatedWith?.stringValue, + associatedFieldNames: associatedFields.map { $0.stringValue } + ) + } + + @available(*, deprecated, message: "Use hasOne(associatedWith:targetNames:)") + public static func hasOne(associatedWith: CodingKey?, targetName: String? = nil) -> ModelAssociation { + let targetNames = targetName.map { [$0] } ?? [] + return .hasOne(associatedWith: associatedWith, targetNames: targetNames) + } + + public static func hasOne(associatedWith: CodingKey?, targetNames: [String] = []) -> ModelAssociation { + return .hasOne(associatedFieldName: associatedWith?.stringValue, targetNames: targetNames) + } + + @available(*, deprecated, message: "Use belongsTo(associatedWith:targetNames:)") + public static func belongsTo(associatedWith: CodingKey?, targetName: String?) -> ModelAssociation { + let targetNames = targetName.map { [$0] } ?? [] + return .belongsTo(associatedFieldName: associatedWith?.stringValue, targetNames: targetNames) + } + + public static func belongsTo(associatedWith: CodingKey?, targetNames: [String] = []) -> ModelAssociation { + return .belongsTo(associatedFieldName: associatedWith?.stringValue, targetNames: targetNames) + } + +} + +extension ModelField { + + /// - Warning: Although this has `public` access, it is intended for internal & codegen use and should not be used + /// directly by host applications. The behavior of this may change without warning. Though it is not used by host + /// application making any change to these `public` types should be backward compatible, otherwise it will be a + /// breaking change. + public var hasAssociation: Bool { + return association != nil + } + + /// If the field represents an association returns the `Model.Type`. + /// - seealso: `ModelFieldType` + /// - seealso: `ModelFieldAssociation` + /// - Warning: Although this has `public` access, it is intended for internal & codegen use and should not be used + /// directly by host applications. The behavior of this may change without warning. Though it is not used by host + /// application making any change to these `public` types should be backward compatible, otherwise it will be a + /// breaking change. + @available(*, deprecated, message: """ + Use of associated model type is deprecated, use `associatedModelName` instead. + """) + public var associatedModel: Model.Type? { + switch type { + case .model(let modelName), .collection(let modelName): + return ModelRegistry.modelType(from: modelName) + default: + return nil + } + } + + /// If the field represents an association returns the `ModelName`. + /// - seealso: `ModelFieldType` + /// - seealso: `ModelFieldAssociation` + /// - Warning: Although this has `public` access, it is intended for internal & codegen use and should not be used + /// directly by host applications. The behavior of this may change without warning. Though it is not used by host + /// application making any change to these `public` types should be backward compatible, otherwise it will be a + /// breaking change. + public var associatedModelName: ModelName? { + switch type { + case .model(let modelName), .collection(let modelName): + return modelName + default: + return nil + } + } + + /// This calls `associatedModelName` but enforces that the field must represent an association. + /// In case the field type is not a `Model` it calls `preconditionFailure`. Consumers + /// should fix their models in order to recover from it, since associations are only + /// possible between two `Model`. + /// + /// - Note: as a maintainer, make sure you use this computed property only when context + /// allows (i.e. the field is a valid relationship, such as foreign keys). + /// - Warning: Although this has `public` access, it is intended for internal & codegen use and should not be used + /// directly by host applications. The behavior of this may change without warning. Though it is not used by host + /// application making any change to these `public` types should be backward compatible, otherwise it will be a + /// breaking change. + @available(*, deprecated, message: """ + Use of requiredAssociatedModel with Model.Type is deprecated, use `requiredAssociatedModelName` + that return ModelName instead. + """) + public var requiredAssociatedModel: Model.Type { + guard let modelType = associatedModel else { + return Fatal.preconditionFailure(""" + Model fields that are foreign keys must be connected to another Model. + Check the `ModelSchema` section of your "\(name)+Schema.swift" file. + """) + } + return modelType + } + + /// This calls `associatedModelName` but enforces that the field must represent an association. + /// In case the field type is not a `Model` it calls `preconditionFailure`. Consumers + /// should fix their models in order to recover from it, since associations are only + /// possible between two `Model`. + /// + /// - Note: as a maintainer, make sure you use this computed property only when context + /// allows (i.e. the field is a valid relationship, such as foreign keys). + /// - Warning: Although this has `public` access, it is intended for internal & codegen use and should not be used + /// directly by host applications. The behavior of this may change without warning. Though it is not used by host + /// application making any change to these `public` types should be backward compatible, otherwise it will be a + /// breaking change. + public var requiredAssociatedModelName: ModelName { + guard let modelName = associatedModelName else { + return Fatal.preconditionFailure(""" + Model fields that are foreign keys must be connected to another Model. + Check the `ModelSchema` section of your "\(name)+Schema.swift" file. + """) + } + return modelName + } + + /// - Warning: Although this has `public` access, it is intended for internal & codegen use and should not be used + /// directly by host applications. The behavior of this may change without warning. Though it is not used by host + /// application making any change to these `public` types should be backward compatible, otherwise it will be a + /// breaking change. + public var isAssociationOwner: Bool { + guard case .belongsTo = association else { + return false + } + return true + } + + /// - Warning: Although this has `public` access, it is intended for internal & codegen use and should not be used + /// directly by host applications. The behavior of this may change without warning. Though it is not used by host + /// application making any change to these `public` types should be backward compatible, otherwise it will be a + /// breaking change. + public var _isBelongsToOrHasOne: Bool { // swiftlint:disable:this identifier_name + switch association { + case .belongsTo, .hasOne: + return true + case .hasMany, .none: + return false + } + } + + /// - Warning: Although this has `public` access, it is intended for internal & codegen use and should not be used + /// directly by host applications. The behavior of this may change without warning. Though it is not used by host + /// application making any change to these `public` types should be backward compatible, otherwise it will be a + /// breaking change. + public var associatedField: ModelField? { + if hasAssociation { + let associatedModel = requiredAssociatedModelName + switch association { + case .belongsTo(let associatedKey, _), + .hasOne(let associatedKey, _), + .hasMany(let associatedKey, _): + // TODO handle modelName casing (convert to camelCase) + let key = associatedKey ?? associatedModel + let schema = ModelRegistry.modelSchema(from: associatedModel) + return schema?.field(withName: key) + case .none: + return nil + } + } + return nil + } + + /// - Warning: Although this has `public` access, it is intended for internal & codegen use and should not be used + /// directly by host applications. The behavior of this may change without warning. Though it is not used by host + /// application making any change to these `public` types should be backward compatible, otherwise it will be a + /// breaking change. + public var associatedFieldNames: [String] { + switch association { + case .hasMany(let associatedKey, let associatedKeys): + if associatedKeys.isEmpty, let associatedKey = associatedKey { + return [associatedKey] + } + return associatedKeys + + case .hasOne, .belongsTo: + return ModelRegistry.modelSchema(from: requiredAssociatedModelName)? + .primaryKey + .fields + .map(\.name) ?? [] + + case .none: + return [] + } + } + + /// - Warning: Although this has `public` access, it is intended for internal & codegen use and should not be used + /// directly by host applications. The behavior of this may change without warning. Though it is not used by host + /// application making any change to these `public` types should be backward compatible, otherwise it will be a + /// breaking change. + public var isOneToOne: Bool { + if case .hasOne = association { + return true + } + if case .belongsTo = association, case .hasOne = associatedField?.association { + return true + } + return false + } + + /// - Warning: Although this has `public` access, it is intended for internal & codegen use and should not be used + /// directly by host applications. The behavior of this may change without warning. Though it is not used by host + /// application making any change to these `public` types should be backward compatible, otherwise it will be a + /// breaking change. + public var isOneToMany: Bool { + if case .hasMany = association, case .belongsTo = associatedField?.association { + return true + } + return false + } + + /// - Warning: Although this has `public` access, it is intended for internal & codegen use and should not be used + /// directly by host applications. The behavior of this may change without warning. Though it is not used by host + /// application making any change to these `public` types should be backward compatible, otherwise it will be a + /// breaking change. + public var isManyToOne: Bool { + if case .belongsTo = association, case .hasMany = associatedField?.association { + return true + } + return false + } + + /// - Warning: Although this has `public` access, it is intended for internal & codegen use and should not be used + /// directly by host applications. The behavior of this may change without warning. Though it is not used by host + /// application making any change to these `public` types should be backward compatible, otherwise it will be a + /// breaking change. + @available(*, deprecated, message: """ + Use `embeddedType` is deprecated, use `embeddedTypeSchema` instead. + """) + public var embeddedType: Embeddable.Type? { + switch type { + case .embedded(let type, _), .embeddedCollection(let type, _): + if let embeddedType = type as? Embeddable.Type { + return embeddedType + } + return nil + default: + return nil + } + } + + /// - Warning: Although this has `public` access, it is intended for internal & codegen use and should not be used + /// directly by host applications. The behavior of this may change without warning. Though it is not used by host + /// application making any change to these `public` types should be backward compatible, otherwise it will be a + /// breaking change. + public var embeddedTypeSchema: ModelSchema? { + switch type { + case .embedded(_, let modelSchema), .embeddedCollection(_, let modelSchema): + return modelSchema + default: + return nil + } + } + + /// - Warning: Although this has `public` access, it is intended for internal & codegen use and should not be used + /// directly by host applications. The behavior of this may change without warning. Though it is not used by host + /// application making any change to these `public` types should be backward compatible, otherwise it will be a + /// breaking change. + public var isEmbeddedType: Bool { + switch type { + case .embedded, .embeddedCollection: + return true + default: + return false + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/Schema/ModelPrimaryKey.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/Schema/ModelPrimaryKey.swift new file mode 100644 index 0000000000..38d8ccec63 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/Schema/ModelPrimaryKey.swift @@ -0,0 +1,95 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +public struct ModelPrimaryKey { + public var fields: [ModelField] = [] + private var fieldsLookup: Set = [] + + public var isCompositeKey: Bool { + fields.count > 1 + } + + init?(allFields: ModelFields, + attributes: [ModelAttribute], + primaryKeyFieldKeys: [String] = []) { + self.fields = resolvePrimaryKeyFields(allFields: allFields, + attributes: attributes, + primaryKeyFieldKeys: primaryKeyFieldKeys) + + if fields.isEmpty { + return nil + } + + self.fieldsLookup = Set(fields.map { $0.name }) + } + + /// Returns the list of fields that make up the primary key for the model. + /// In case of a custom primary key, the model has a `@key` directive + /// without a name and at least 1 field + func primaryFieldsFromIndexes(attributes: [ModelAttribute]) -> [ModelFieldName]? { + attributes.compactMap { + if case let .index(fields, name) = $0, name == nil, fields.count >= 1 { + return fields + } + return nil + }.first + } + + /// Resolve the model fields that are part of the primary key. + /// For backward compatibility with different versions of the codegen, + /// the algorithm tries first to resolve them using first the `primaryKeyFields` + /// received from `primaryKey` member set in `ModelSchemaDefinition`, + /// if not available tries to infer the fields using the `.indexes` and it eventually + /// falls back on the `.primaryKey` attribute. + /// + /// It returns an array of fields as custom and composite primary keys are supported. + /// - Parameter fields: schema model fields + /// - Returns: an array of model fields + func resolvePrimaryKeyFields(allFields: ModelFields, + attributes: [ModelAttribute], + primaryKeyFieldKeys: [String]) -> [ModelField] { + var primaryKeyFields: [ModelField] = [] + + if !primaryKeyFieldKeys.isEmpty { + primaryKeyFields = primaryKeyFieldKeys.map { + guard let field = allFields[$0] else { + preconditionFailure("Primary key field named (\($0)) not found in schema fields.") + } + return field + } + + /// if indexes aren't defined most likely the model has a default `id` as PK + /// so we have to rely on the `.primaryKey` attribute of each individual field + } else if attributes.indexes.filter({ $0.isPrimaryKeyIndex }).isEmpty { + primaryKeyFields = allFields.values.filter { $0.isPrimaryKey } + + /// Use the array of fields with a primary key index + } else if let fieldNames = primaryFieldsFromIndexes(attributes: attributes) { + primaryKeyFields = fieldNames.compactMap { + if let field = allFields[$0] { + return field + } + return nil + } + } + return primaryKeyFields + } + + /// Convenience method to verify if a field is part of the primary key + /// - Parameter name: field name + /// - Returns: true if the field is part of the primary key + public func contains(named name: ModelFieldName) -> Bool { + fieldsLookup.contains(name) + } + + /// Returns the first index in which a model field name of the collection + /// is equal to the provided `name` + /// Returns `nil` if no element was found. + public func indexOfField(named name: ModelFieldName) -> Int? { + fields.firstIndex(where: { $0.name == name }) + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/Schema/ModelSchema+Attributes.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/Schema/ModelSchema+Attributes.swift new file mode 100644 index 0000000000..7e296271fd --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/Schema/ModelSchema+Attributes.swift @@ -0,0 +1,28 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// Convenience getters for attributes +public extension ModelSchema { + + var isSyncable: Bool { + !attributes.contains(.isSystem) + } + + var isSystem: Bool { + attributes.contains(.isSystem) + } + + var hasAuthenticationRules: Bool { + return !authRules.isEmpty + } + + var hasAssociations: Bool { + fields.values.contains { modelField in + modelField.hasAssociation + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/Schema/ModelSchema+AuthRulesByOperation.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/Schema/ModelSchema+AuthRulesByOperation.swift new file mode 100644 index 0000000000..d7d02d2b6a --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/Schema/ModelSchema+AuthRulesByOperation.swift @@ -0,0 +1,18 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public extension Array where Element == AuthRule { + + /// Returns all the `AuthRule` that apply to a given a `ModelOperation` + /// - Parameter operation: `ModelOperation` operation + /// - Returns: Auth rules + func filter(modelOperation operation: ModelOperation) -> [Element] { + filter { $0.operations.contains(operation) } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/Schema/ModelSchema+Definition.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/Schema/ModelSchema+Definition.swift new file mode 100644 index 0000000000..b27d40bb55 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/Schema/ModelSchema+Definition.swift @@ -0,0 +1,327 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Defines the type of a `Model` field. +/// - Warning: Although this has `public` access, it is intended for internal & codegen use and should not be used +/// directly by host applications. The behavior of this may change without warning. +public enum ModelFieldType { + + case string + case int + case double + case date + case dateTime + case time + case timestamp + case bool + case `enum`(type: EnumPersistable.Type) + case embedded(type: Codable.Type, schema: ModelSchema?) + case embeddedCollection(of: Codable.Type, schema: ModelSchema?) + case model(name: ModelName) + case collection(of: ModelName) + + public static func model(type: Model.Type) -> ModelFieldType { + .model(name: type.modelName) + } + + public static func collection(of type: Model.Type) -> ModelFieldType { + .collection(of: type.modelName) + } + + public static func embedded(type: Codable.Type) -> ModelFieldType { + guard let embeddedType = type as? Embeddable.Type else { + return .embedded(type: type, schema: nil) + } + return .embedded(type: type, schema: embeddedType.schema) + } + + public static func embeddedCollection(of type: Codable.Type) -> ModelFieldType { + guard let embeddedType = type as? Embeddable.Type else { + return .embedded(type: type, schema: nil) + } + return .embeddedCollection(of: type, schema: embeddedType.schema) + } + + public var isArray: Bool { + switch self { + case .collection, .embeddedCollection: + return true + default: + return false + } + } + + @available(*, deprecated, message: """ + This has been replaced with `.embedded(type)` and `.embeddedCollection(of)` \ + Please use Amplify CLI 4.21.4 or newer to re-generate your Models to conform to Embeddable type. + """) + public static func customType(_ type: Codable.Type) -> ModelFieldType { + return .embedded(type: type, schema: nil) + } + + public static func from(type: Any.Type) -> ModelFieldType { + if type is String.Type { + return .string + } + if type is Int.Type || type is Int64.Type { + return .int + } + if type is Double.Type { + return .double + } + if type is Bool.Type { + return .bool + } + if type is Date.Type { + return .dateTime + } + if type is Temporal.Date.Type { + return .date + } + if type is Temporal.DateTime.Type { + return .dateTime + } + if type is Temporal.Time.Type { + return .time + } + if let enumType = type as? EnumPersistable.Type { + return .enum(type: enumType) + } + if let modelType = type as? Model.Type { + return .model(type: modelType) + } + if let embeddedType = type as? Codable.Type { + return .embedded(type: embeddedType) + } + return Fatal.preconditionFailure("Could not create a ModelFieldType from \(String(describing: type)) MetaType") + } +} + +/// - Warning: Although this has `public` access, it is intended for internal & codegen use and should not be used +/// directly by host applications. The behavior of this may change without warning. +public enum ModelFieldNullability { + case optional + case required + + var isRequired: Bool { + switch self { + case .optional: + return false + case .required: + return true + } + } +} + +/// - Warning: Although this has `public` access, it is intended for internal & codegen use and should not be used +/// directly by host applications. The behavior of this may change without warning. +public struct ModelSchemaDefinition { + + internal let name: String + + @available(*, deprecated, message: "Use of pluralName is deprecated, use syncPluralName instead.") + public var pluralName: String? + + public var listPluralName: String? + public var syncPluralName: String? + + public var authRules: AuthRules + internal var fields: ModelFields + internal var primarykeyFields: [ModelFieldName] + internal var attributes: [ModelAttribute] + + init(name: String, + pluralName: String? = nil, + listPluralName: String? = nil, + syncPluralName: String? = nil, + authRules: AuthRules = [], + attributes: [ModelAttribute] = []) { + self.name = name + self.pluralName = pluralName + self.listPluralName = listPluralName + self.syncPluralName = syncPluralName + self.fields = [:] as ModelFields + self.authRules = authRules + self.attributes = attributes + self.primarykeyFields = [] + } + + public mutating func fields(_ fields: ModelFieldDefinition...) { + fields.forEach { definition in + let field = definition.modelField + self.fields[field.name] = field + } + } + + public mutating func attributes(_ attributes: ModelAttribute...) { + self.attributes = attributes + let primaryKeyDefinition: [[ModelFieldName]] = attributes.compactMap { + if case let .primaryKey(fields: fields) = $0 { + return fields + } + return nil + } + + if primaryKeyDefinition.count > 1 { + preconditionFailure("Multiple primary key definitions found on schema \(name)") + } + primarykeyFields = primaryKeyDefinition.first ?? [] + } + + internal func build() -> ModelSchema { + return ModelSchema(name: name, + pluralName: pluralName, + listPluralName: listPluralName, + syncPluralName: syncPluralName, + authRules: authRules, + attributes: attributes, + fields: fields, + primaryKeyFieldKeys: primarykeyFields) + } +} + +/// - Warning: Although this has `public` access, it is intended for internal & codegen use and should not be used +/// directly by host applications. The behavior of this may change without warning. +public enum ModelFieldDefinition { + case field(name: String, + type: ModelFieldType, + nullability: ModelFieldNullability, + isReadOnly: Bool, + association: ModelAssociation?, + attributes: [ModelFieldAttribute], + authRules: AuthRules) + + public static func field(_ key: CodingKey, + is nullability: ModelFieldNullability = .required, + isReadOnly: Bool = false, + ofType type: ModelFieldType = .string, + attributes: [ModelFieldAttribute] = [], + association: ModelAssociation? = nil, + authRules: AuthRules = []) -> ModelFieldDefinition { + return .field(name: key.stringValue, + type: type, + nullability: nullability, + isReadOnly: isReadOnly, + association: association, + attributes: attributes, + authRules: authRules) + } + + @available(*, deprecated, message: "Use .primaryKey(fields:)") + public static func id(_ key: CodingKey) -> ModelFieldDefinition { + return id(key.stringValue) + } + + @available(*, deprecated, message: "Use .primaryKey(fields:)") + public static func id(_ name: String = "id") -> ModelFieldDefinition { + return .field(name: name, + type: .string, + nullability: .required, + isReadOnly: false, + association: nil, + attributes: [.primaryKey], + authRules: []) + } + + public static func hasMany(_ key: CodingKey, + is nullability: ModelFieldNullability = .required, + isReadOnly: Bool = false, + ofType type: Model.Type, + associatedWith associatedKey: CodingKey) -> ModelFieldDefinition { + return .field(key, + is: nullability, + isReadOnly: isReadOnly, + ofType: .collection(of: type), + association: .hasMany(associatedWith: associatedKey)) + } + + public static func hasMany(_ key: CodingKey, + is nullability: ModelFieldNullability = .required, + isReadOnly: Bool = false, + ofType type: Model.Type, + associatedFields associatedKeys: [CodingKey]) -> ModelFieldDefinition { + return .field(key, + is: nullability, + isReadOnly: isReadOnly, + ofType: .collection(of: type), + association: .hasMany(associatedWith: associatedKeys.first, associatedFields: associatedKeys)) + } + + public static func hasOne(_ key: CodingKey, + is nullability: ModelFieldNullability = .required, + isReadOnly: Bool = false, + ofType type: Model.Type, + associatedWith associatedKey: CodingKey, + targetName: String? = nil) -> ModelFieldDefinition { + return .field(key, + is: nullability, + isReadOnly: isReadOnly, + ofType: .model(type: type), + association: .hasOne(associatedWith: associatedKey, targetNames: targetName.map { [$0] } ?? [])) + } + + public static func hasOne(_ key: CodingKey, + is nullability: ModelFieldNullability = .required, + isReadOnly: Bool = false, + ofType type: Model.Type, + associatedWith associatedKey: CodingKey, + targetNames: [String]) -> ModelFieldDefinition { + return .field(key, + is: nullability, + isReadOnly: isReadOnly, + ofType: .model(type: type), + association: .hasOne(associatedWith: associatedKey, targetNames: targetNames)) + } + + public static func belongsTo(_ key: CodingKey, + is nullability: ModelFieldNullability = .required, + isReadOnly: Bool = false, + ofType type: Model.Type, + associatedWith associatedKey: CodingKey? = nil, + targetName: String? = nil) -> ModelFieldDefinition { + return .field(key, + is: nullability, + isReadOnly: isReadOnly, + ofType: .model(type: type), + association: .belongsTo(associatedWith: associatedKey, targetNames: targetName.map { [$0] } ?? [])) + } + + public static func belongsTo(_ key: CodingKey, + is nullability: ModelFieldNullability = .required, + isReadOnly: Bool = false, + ofType type: Model.Type, + associatedWith associatedKey: CodingKey? = nil, + targetNames: [String]) -> ModelFieldDefinition { + return .field(key, + is: nullability, + isReadOnly: isReadOnly, + ofType: .model(type: type), + association: .belongsTo(associatedWith: associatedKey, targetNames: targetNames)) + } + + public var modelField: ModelField { + guard case let .field(name, + type, + nullability, + isReadOnly, + association, + attributes, + authRules) = self else { + return Fatal.preconditionFailure("Unexpected enum value found: \(String(describing: self))") + } + return ModelField(name: name, + type: type, + isRequired: nullability.isRequired, + isReadOnly: isReadOnly, + isArray: type.isArray, + attributes: attributes, + association: association, + authRules: authRules) + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/Schema/ModelSchema+Identifiers.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/Schema/ModelSchema+Identifiers.swift new file mode 100644 index 0000000000..a627525ba9 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/Schema/ModelSchema+Identifiers.swift @@ -0,0 +1,34 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension ModelSchema { + func lazyReferenceIdentifiers(from modelObject: [String: JSONValue]) throws -> [LazyReferenceIdentifier] { + enum ExtractionError: Error { + case unsupportedLazyReferenceIdentifier(name: String, value: JSONValue?) + } + + var identifiers = [LazyReferenceIdentifier]() + + for identifierField in primaryKey.fields { + let object = modelObject[identifierField.name] + + switch object { + case .string(let identifierValue): + identifiers.append(.init(name: identifierField.name, value: identifierValue)) + default: + throw ExtractionError.unsupportedLazyReferenceIdentifier( + name: identifierField.name, + value: object + ) + } + } + + return identifiers + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/Schema/ModelSchema.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/Schema/ModelSchema.swift new file mode 100644 index 0000000000..ac1045e523 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/Schema/ModelSchema.swift @@ -0,0 +1,214 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// - Warning: Although this has `public` access, it is intended for internal & codegen use and should not be used +/// directly by host applications. The behavior of this may change without warning. +public typealias ModelFieldName = String + +/// - Warning: Although this has `public` access, it is intended for internal & codegen use and should not be used +/// directly by host applications. The behavior of this may change without warning. +public enum ModelAttribute: Equatable { + /// Represents a database index, often used for frequent query optimizations. + case index(fields: [ModelFieldName], name: String?) + + /// This model is used by the Amplify system or a plugin, and should not be used by the app developer + case isSystem + + /// Defines the primary key for the schema. + case primaryKey(fields: [ModelFieldName]) + + /// Convenience factory method to initialize a `.primaryKey` attribute by + /// using the model coding keys + public static func primaryKey(fields: [CodingKey]) -> ModelAttribute { + return .primaryKey(fields: fields.map { $0.stringValue }) + } +} + +/// - Warning: Although this has `public` access, it is intended for internal & codegen use and should not be used +/// directly by host applications. The behavior of this may change without warning. +public enum ModelFieldAttribute { + @available(*, deprecated, message: "Use the primaryKey member of the schema") + case primaryKey +} + +/// - Warning: Although this has `public` access, it is intended for internal & codegen use and should not be used +/// directly by host applications. The behavior of this may change without warning. +public struct ModelField { + + public let name: ModelFieldName + public let type: ModelFieldType + public let isRequired: Bool + public let isReadOnly: Bool + public let isArray: Bool + public let attributes: [ModelFieldAttribute] + public let association: ModelAssociation? + public let authRules: AuthRules + + @available(*, deprecated, message: "Use the primaryKey member of the schema") + public var isPrimaryKey: Bool { + return attributes.contains { $0 == .primaryKey } + } + + public init(name: String, + type: ModelFieldType, + isRequired: Bool = false, + isReadOnly: Bool = false, + isArray: Bool = false, + attributes: [ModelFieldAttribute] = [], + association: ModelAssociation? = nil, + authRules: AuthRules = []) { + self.name = name + self.type = type + self.isRequired = isRequired + self.isReadOnly = isReadOnly + self.isArray = isArray + self.attributes = attributes + self.association = association + self.authRules = authRules + } +} + +/// - Warning: Although this has `public` access, it is intended for internal & codegen use and should not be used +/// directly by host applications. The behavior of this may change without warning. +public typealias ModelFields = [String: ModelField] + +/// - Warning: Although this has `public` access, it is intended for internal & codegen use and should not be used +/// directly by host applications. The behavior of this may change without warning. +public typealias ModelName = String + +/// - Warning: Although this has `public` access, it is intended for internal & codegen use and should not be used +/// directly by host applications. The behavior of this may change without warning. +public struct ModelSchema { + + public let name: String + + @available(*, deprecated, message: "Use of pluralName is deprecated, use syncPluralName instead.") + public let pluralName: String? + + public let listPluralName: String? + public let syncPluralName: String? + public let authRules: AuthRules + public let fields: ModelFields + public let attributes: [ModelAttribute] + public let indexes: [ModelAttribute] + + public let sortedFields: [ModelField] + + private var _primaryKey: ModelPrimaryKey? + public var primaryKey: ModelPrimaryKey { + guard let primaryKey = _primaryKey else { + return Fatal.preconditionFailure("Primary Key not defined for `\(name)`") + } + return primaryKey + } + + public init(name: String, + pluralName: String? = nil, + listPluralName: String? = nil, + syncPluralName: String? = nil, + authRules: AuthRules = [], + attributes: [ModelAttribute] = [], + fields: ModelFields = [:], + primaryKeyFieldKeys: [ModelFieldName] = []) { + self.name = name + self.pluralName = pluralName + self.listPluralName = listPluralName + self.syncPluralName = syncPluralName + self.authRules = authRules + self.attributes = attributes + self.fields = fields + self.indexes = attributes.indexes + self._primaryKey = ModelPrimaryKey(allFields: fields, + attributes: attributes, + primaryKeyFieldKeys: primaryKeyFieldKeys) + + let indexOfPrimaryKeyField = _primaryKey?.indexOfField ?? { (_: String) in nil } + self.sortedFields = fields.sortedFields(indexOfPrimaryKeyField: indexOfPrimaryKeyField) + } + + public func field(withName name: String) -> ModelField? { + return fields[name] + } +} + +// MARK: - ModelAttribute + Index + +extension ModelAttribute { + /// Convenience method to check if a model attribute is a primary key index + var isPrimaryKeyIndex: Bool { + if case let .index(fields: fields, name: name) = self, + name == nil, fields.count >= 1 { + return true + } + return false + } +} + +/// - Warning: Although this has `public` access, it is intended for internal & codegen use and should not be used +/// directly by host applications. The behavior of this may change without warning. +extension Array where Element == ModelAttribute { + var indexes: [ModelAttribute] { + filter { + switch $0 { + case .index: + return true + default: + return false + } + } + } +} + +public extension ModelSchema { + /// Returns the list of fields that make up the primary key for the model. + /// In case of a custom primary key, the model has a `@key` directive + /// without a name and at least 1 field + var primaryKeyIndexFields: [ModelFieldName]? { + attributes.compactMap { + if case let .index(fields, _) = $0, $0.isPrimaryKeyIndex { + return fields + } + return nil + }.first + } +} + +// MARK: - Dictionary + ModelField + +extension Dictionary where Key == String, Value == ModelField { + + /// Returns an array of the values sorted by some pre-defined rules: + /// + /// 1. primary key always comes first (sorted based on their schema declaration order in case of a composite key) + /// 2. foreign keys always come at the end + /// 3. the remaining fields are sorted alphabetically + /// + /// This is useful so code that uses the fields to generate queries and other + /// persistence-related operations guarantee that the results are always consistent. + func sortedFields(indexOfPrimaryKeyField: (ModelFieldName) -> Int?) -> [Value] { + return values.sorted { one, other in + if let oneIndex = indexOfPrimaryKeyField(one.name), + let otherIndex = indexOfPrimaryKeyField(other.name) { + return oneIndex < otherIndex + } + + if indexOfPrimaryKeyField(one.name) != nil { + return true + } + if indexOfPrimaryKeyField(other.name) != nil { + return false + } + if one.hasAssociation && !other.hasAssociation { + return false + } + if !one.hasAssociation && other.hasAssociation { + return true + } + return one.name < other.name + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/Schema/ModelValueConverter.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/Schema/ModelValueConverter.swift new file mode 100644 index 0000000000..812de4ecc1 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Internal/Schema/ModelValueConverter.swift @@ -0,0 +1,68 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Establishes how `Model` fields should be converted to and from different targets (e.g. SQL and GraphQL). +/// - Warning: Although this has `public` access, it is intended for internal use and should not be used directly +/// by host applications. The behavior of this may change without warning. +public protocol ModelValueConverter { + + /// The base type on the source (i.e. the `Model` property type) + associatedtype SourceType + + /// The type on the target (i.e. the `SQL` value type) + associatedtype TargetType + + /// Converts a source value of a certain type to the target type + /// + /// - Parameters: + /// - source: the value from the `Model` + /// - fieldType: the type of the `Model` field + /// - Returns: the converted value + static func convertToTarget(from source: SourceType, fieldType: ModelFieldType) throws -> TargetType + + /// Converts a target value of a certain type to the `Model` field type + /// + /// - Parameters: + /// - target: the value from the target + /// - fieldType: the type of the `Model` field + /// - Returns: the converted value to the expected `ModelFieldType` + static func convertToSource(from target: TargetType, fieldType: ModelFieldType) throws -> SourceType + +} + +/// Extension with reusable JSON encoding/decoding utilities. +extension ModelValueConverter { + + static var jsonDecoder: JSONDecoder { + JSONDecoder(dateDecodingStrategy: ModelDateFormatting.decodingStrategy) + } + + static var jsonEncoder: JSONEncoder { + let encoder = JSONEncoder(dateEncodingStrategy: ModelDateFormatting.encodingStrategy) + encoder.outputFormatting = .sortedKeys + return encoder + } + + /// - Warning: Although this has `public` access, it is intended for internal & codegen use and should not be used + /// directly by host applications. The behavior of this may change without warning. Though it is not used by host + /// application making any change to these `public` types should be backward compatible, otherwise it will be a + /// breaking change. + public static func toJSON(_ value: Encodable) throws -> String? { + let data = try jsonEncoder.encode(value.eraseToAnyEncodable()) + return String(data: data, encoding: .utf8) + } + + /// - Warning: Although this has `public` access, it is intended for internal & codegen use and should not be used + /// directly by host applications. The behavior of this may change without warning. Though it is not used by host + /// application making any change to these `public` types should be backward compatible, otherwise it will be a + /// breaking change. + public static func fromJSON(_ value: String) throws -> Any? { + return try JSONSerialization.jsonObject(with: Data(value.utf8)) + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/JSONHelper/JSONValueHolder.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/JSONHelper/JSONValueHolder.swift new file mode 100644 index 0000000000..ad15f4ce66 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/JSONHelper/JSONValueHolder.swift @@ -0,0 +1,35 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// A type that holds internal data in json format +/// +/// Internally a `JSONValueHolder` will have the data stored as a json map. Properties of the type can be retrieved +/// using the function `jsonValue(for:)` passing in the key of the property. +/// +/// Example: +/// ============================================== +/// struct DynamicModel: JSONValueHolder { +/// let values: [String: Any] +/// +/// public func jsonValue(for key: String) -> Any?? { +/// return values[key] +/// } +/// } +public protocol JSONValueHolder { + + /// Return the value for the given key. + /// + /// If a particular key has nil as it value, this method should return .some(nil) as the value. + func jsonValue(for key: String) -> Any?? + + /// Return the value for the given key. + /// + /// If a particular key has nil as it value, this method should return .some(nil) as the value. + func jsonValue(for key: String, modelSchema: ModelSchema) -> Any?? +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Lazy/ArrayLiteralListProvider.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Lazy/ArrayLiteralListProvider.swift new file mode 100644 index 0000000000..7c8e128853 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Lazy/ArrayLiteralListProvider.swift @@ -0,0 +1,52 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public struct ArrayLiteralListProvider: ModelListProvider { + + let elements: [Element] + public init(elements: [Element]) { + self.elements = elements + } + + public func getState() -> ModelListProviderState { + return .loaded(elements) + } + + public func load() -> Result<[Element], CoreError> { + .success(elements) + } + + public func load(completion: @escaping (Result<[Element], CoreError>) -> Void) { + completion(.success(elements)) + } + + public func load() async throws -> [Element] { + return elements + } + + public func hasNextPage() -> Bool { + false + } + + public func getNextPage(completion: @escaping (Result, CoreError>) -> Void) { + completion(.failure(CoreError.clientValidation("No pagination on an array literal", + "Don't call this method", + nil))) + } + + public func getNextPage() async throws -> List { + throw CoreError.clientValidation("No pagination on an array literal", + "Don't call this method", + nil) + } + + public func encode(to encoder: Encoder) throws { + try elements.encode(to: encoder) + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Lazy/DefaultModelProvider.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Lazy/DefaultModelProvider.swift new file mode 100644 index 0000000000..24dca4f26a --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Lazy/DefaultModelProvider.swift @@ -0,0 +1,46 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +// MARK: - DefaultModelProvider + +public struct DefaultModelProvider: ModelProvider { + + var loadedState: ModelProviderState + + public init(element: Element? = nil) { + self.loadedState = .loaded(model: element) + } + + public init(identifiers: [LazyReferenceIdentifier]?) { + self.loadedState = .notLoaded(identifiers: identifiers) + } + + public func load() async throws -> Element? { + switch loadedState { + case .notLoaded: + return nil + case .loaded(let model): + return model + } + } + + public func getState() -> ModelProviderState { + loadedState + } + + public func encode(to encoder: Encoder) throws { + switch loadedState { + case .notLoaded(let identifiers): + var container = encoder.singleValueContainer() + try container.encode(identifiers) + case .loaded(let element): + try element.encode(to: encoder) + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Lazy/LazyReference.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Lazy/LazyReference.swift new file mode 100644 index 0000000000..343b059225 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Lazy/LazyReference.swift @@ -0,0 +1,160 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import Combine + +/// A Codable struct to hold key value pairs representing the identifier's field name and value. +/// Useful for maintaining order for key-value pairs when used as an Array type. +public struct LazyReferenceIdentifier: Codable { + public let name: String + public let value: String + + public init(name: String, value: String) { + self.name = name + self.value = value + } +} + +extension Array where Element == LazyReferenceIdentifier { + public var stringValue: String { + var fields = [(String, Persistable)]() + for id in self { + fields.append((id.name, id.value)) + } + return LazyReferenceModelIdentifier(fields: fields).stringValue + } +} + +struct LazyReferenceModelIdentifier: ModelIdentifierProtocol { + var fields: [(name: String, value: Persistable)] +} + +// swiftlint: disable identifier_name +/// This class represents a lazy reference to a `Model`, meaning that the reference +/// may or may not exist at instantiation time. +/// +/// The default implementation `DefaultModelProvider` only handles in-memory data, therefore `get()` and +/// `require()` will simply return the current `reference`. +public class LazyReference: Codable, _LazyReferenceValue { + + /// Represents the data state of the `LazyModel`. + enum LoadedState { + case notLoaded(identifiers: [LazyReferenceIdentifier]?) + case loaded(ModelType?) + } + + var loadedState: LoadedState + + @_spi(LazyReference) + public var _state: _LazyReferenceValueState { + switch loadedState { + case .notLoaded(let identifiers): + return .notLoaded(identifiers: identifiers) + case .loaded(let model): + return .loaded(model: model) + } + } + + /// The provider for fulfilling list behaviors + let modelProvider: AnyModelProvider + + public init(modelProvider: AnyModelProvider) { + self.modelProvider = modelProvider + switch self.modelProvider.getState() { + case .loaded(let element): + self.loadedState = .loaded(element) + case .notLoaded(let identifiers): + self.loadedState = .notLoaded(identifiers: identifiers) + } + } + + // MARK: - Initializers + + public convenience init(_ reference: ModelType?) { + let modelProvider = DefaultModelProvider(element: reference).eraseToAnyModelProvider() + self.init(modelProvider: modelProvider) + } + + public convenience init(identifiers: [LazyReferenceIdentifier]?) { + let modelProvider = DefaultModelProvider(identifiers: identifiers).eraseToAnyModelProvider() + self.init(modelProvider: modelProvider) + } + + // MARK: - Codable implementation + + /// Decodable implementation is delegated to the ModelProviders. + required convenience public init(from decoder: Decoder) throws { + for modelDecoder in ModelProviderRegistry.decoders.get() { + if let modelProvider = modelDecoder.decode(modelType: ModelType.self, decoder: decoder) { + self.init(modelProvider: modelProvider) + return + } + } + let json = try JSONValue(from: decoder) + switch json { + case .object(let object): + if let element = try? ModelType(from: decoder) { + self.init(element) + return + } else { + let identifiers = try ModelType.schema.lazyReferenceIdentifiers(from: object) + self.init(identifiers: identifiers) + return + } + default: + break + } + self.init(identifiers: nil) + } + + /// Encodable implementation is delegated to the underlying ModelProviders. + public func encode(to encoder: Encoder) throws { + try modelProvider.encode(to: encoder) + } + + // MARK: - APIs + + /// This function is responsible for retrieving the model reference. In the default + /// implementation this means simply returning the existing `reference`, but different + /// storage mechanisms can implement their own logic to fetch data, + /// e.g. from DataStore's SQLite or AppSync. + /// + /// - Returns: the model `reference`, if it exists. + public func get() async throws -> ModelType? { + switch loadedState { + case .notLoaded: + let element = try await modelProvider.load() + self.loadedState = .loaded(element) + return element + case .loaded(let element): + return element + } + } + + /// The equivalent of `get()` but aimed to retrieve references that are considered + /// non-optional. However, referential integrity issues and/or availability constraints + /// might affect how required data is fetched. In such scenarios the implementation + /// must throw an error to communicate to developers why required data could not be fetched. + /// + /// - Throws: an error of type `DataError` when the data marked as required cannot be retrieved. + public func require() async throws -> ModelType { + switch loadedState { + case .notLoaded: + guard let element = try await modelProvider.load() else { + throw CoreError.clientValidation("Data is required but underlying data source successfully loaded no data. ", "") + } + self.loadedState = .loaded(element) + return element + case .loaded(let element): + guard let element = element else { + throw CoreError.clientValidation("Data is required but containing LazyReference is loaded with no data.", "") + } + return element + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Lazy/List+Combine.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Lazy/List+Combine.swift new file mode 100644 index 0000000000..271608fa2f --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Lazy/List+Combine.swift @@ -0,0 +1,16 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#if canImport(Combine) +import Combine + +extension List { + + public typealias LazyListPublisher = AnyPublisher<[Element], DataStoreError> + +} +#endif diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Lazy/List+LazyLoad.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Lazy/List+LazyLoad.swift new file mode 100644 index 0000000000..71b833c976 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Lazy/List+LazyLoad.swift @@ -0,0 +1,39 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// This extension adds lazy load logic to the `List`. Lazy loading means +/// the contents of a list that represents an association between two models will only be +/// loaded when it's needed. +extension List { + + // MARK: - Asynchronous API + + /// Call this to initialize the collection if you have retrieved the list by traversing from your model objects + /// to its associated children objects. For example, a Post model may contain a list of Comments. By retrieving the + /// post object and traversing to the comments, the comments are not retrieved from the data source until this + /// method is called. Data will be retrieved based on the plugin's data source and may have different failure + /// conditions--for example, a data source that requires network connectivity may fail if the network is + /// unavailable. Alternately, you can trigger an implicit `fetch` by invoking the Collection methods (such as using + /// `map`, or iterating in a `for/in` loop) on the List, which will retrieve data if it hasn't already been + /// retrieved. In such cases, the time to perform that operation will include the time required to request data + /// from the underlying data source. + /// + /// If you have directly created this list object (for example, by calling `List(elements:)`) then the collection + /// has already been initialized and calling this method will have no effect. + public func fetch() async throws { + guard case .notLoaded = loadedState else { + return + } + do { + self.elements = try await listProvider.load() + } catch { + throw error + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Lazy/List+Model.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Lazy/List+Model.swift new file mode 100644 index 0000000000..283cc53ff7 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Lazy/List+Model.swift @@ -0,0 +1,172 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import Combine + +/// `List` is a custom `Collection` that is capable of loading records from a data source. This is especially +/// useful when dealing with Model associations that need to be lazy loaded. Lazy loading is performed when you access +/// the `Collection` methods by retrieving the data from the underlying data source and then stored into this object, +/// before returning the data to you. Consumers must be aware that multiple calls to the data source and then stored +/// into this object will happen simultaneously if the object is used from different threads, thus not thread safe. +/// Lazy loading is idempotent and will return the stored results on subsequent access. +public class List: Collection, Codable, ExpressibleByArrayLiteral, ModelListMarker { + public typealias Index = Int + public typealias Element = ModelType + + /// Represents the data state of the `List`. + enum LoadedState { + case notLoaded + case loaded([Element]) + } + + /// The current state of lazily loaded list + var loadedState: LoadedState + + /// Boolean property to check if list is loaded + public var isLoaded: Bool { + if case .loaded = loadedState { + return true + } + + return false + } + + /// The provider for fulfilling list behaviors + let listProvider: AnyModelListProvider + + /// The array of `Element` that backs the custom collection implementation. + /// + /// Attempting to access the list object will attempt to retrieve the elements in memory or retrieve it from the + /// provider's data source. This is not thread safe as it can be performed from multiple threads, however the + /// provider's call to `load` should be idempotent and should result in the final loaded state. An attempt to set + /// this again will result in no-op and will not overwrite the existing loaded data. + public internal(set) var elements: [Element] { + get { + switch loadedState { + case .loaded(let elements): + return elements + case .notLoaded: + let message = "Call `fetch` to lazy load the list before accessing `elements`." + Amplify.log.error(message) + assertionFailure(message) + return [] + } + } + set { + switch loadedState { + case .loaded: + Amplify.log.error(""" + There is an attempt to set an already loaded List. The existing data will not be overwritten + """) + return + case .notLoaded: + loadedState = .loaded(newValue) + } + } + } + + // MARK: - Initializers + + public init(listProvider: AnyModelListProvider) { + self.listProvider = listProvider + switch self.listProvider.getState() { + case .loaded(let elements): + self.loadedState = .loaded(elements) + case .notLoaded: + self.loadedState = .notLoaded + } + } + + public convenience init(elements: [Element]) { + let loadProvider = ArrayLiteralListProvider(elements: elements).eraseToAnyModelListProvider() + self.init(listProvider: loadProvider) + } + + // MARK: - ExpressibleByArrayLiteral + + required convenience public init(arrayLiteral elements: Element...) { + self.init(elements: elements) + } + + // MARK: - Collection conformance + + /// Accessing the elements on a list that has not been loaded yet will operate slower than O(1) as the data will be + /// retrieved synchronously as part of this call. + public var startIndex: Index { + elements.startIndex + } + + /// Accessing the elements on a list that has not been loaded yet will operate slower than O(1) as the data will be + /// retrieved synchronously as part of this call. + public var endIndex: Index { + elements.endIndex + } + + /// Accessing the elements on a list that has not been loaded yet will operate slower than O(1) as the data will be + /// retrieved synchronously as part of this call. + public func index(after index: Index) -> Index { + elements.index(after: index) + } + + /// Accessing the elements on a list that has not been loaded yet will operate slower than O(1) as the data will be + /// retrieved synchronously as part of this call. + public subscript(position: Int) -> Element { + elements[position] + } + + /// Accessing the elements on a list that has not been loaded yet will operate slower than O(1) as the data will be + /// retrieved synchronously as part of this call. + public __consuming func makeIterator() -> IndexingIterator<[Element]> { + elements.makeIterator() + } + + /// Accessing the elements on a list that has not been loaded yet will operate slower than O(1) as the data will be + /// retrieved synchronously as part of this call. + public var count: Int { + elements.count + } + + // MARK: - Persistent Operations + + public var totalCount: Int { + // TODO handle total count + return 0 + } + + /// `limit` is currently not a supported API. + @available(*, deprecated, message: "Not supported.") + public func limit(_ limit: Int) -> Self { + return self + } + + // MARK: - Codable + + /// The decoding logic uses `ModelListDecoderRegistry` to find available decoders to decode to plugin specific + /// implementations of a `ModelListProvider` for `List`. The decoders should be added to the registry by the + /// plugin as part of its configuration steps. By delegating responsibility to the `ModelListDecoder`, it is up to + /// the plugin to successfully return an instance of `ModelListProvider`. + required convenience public init(from decoder: Decoder) throws { + for listDecoder in ModelListDecoderRegistry.listDecoders.get() { + if let listProvider = listDecoder.decode(modelType: ModelType.self, decoder: decoder) { + self.init(listProvider: listProvider) + return + } + } + let json = try JSONValue(from: decoder) + if case .array = json { + let elements = try [Element](from: decoder) + self.init(elements: elements) + } else { + self.init() + } + } + + public func encode(to encoder: Encoder) throws { + try listProvider.encode(to: encoder) + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Lazy/List+Pagination.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Lazy/List+Pagination.swift new file mode 100644 index 0000000000..28d6fb2d02 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Lazy/List+Pagination.swift @@ -0,0 +1,41 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension List { + + /// Check if there is subsequent data to retrieve. If true, the next page can be retrieved using + /// `getNextPage(completion:)`. Calling `hasNextPage()` will load the underlying elements from the data source if not yet + /// loaded before. + public func hasNextPage() -> Bool { + switch loadedState { + case .loaded: + return listProvider.hasNextPage() + case .notLoaded: + let message = "Call `fetch` to lazy load the list before using this method." + Amplify.log.error(message) + assertionFailure(message) + return false + } + } + + /// Retrieve the next page as a new in-memory List object. Calling `getNextPage(completion:)` will load the + /// underlying elements of the receiver from the data source if not yet loaded before + public func getNextPage() async throws -> List { + switch loadedState { + case .loaded: + return try await listProvider.getNextPage() + case .notLoaded: + let message = "Call `fetch` to lazy load the list before using this method." + let error: CoreError = .listOperation(message, "", nil) + Amplify.log.error(error: error) + assertionFailure(message) + throw error + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Model+ModelName.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Model+ModelName.swift new file mode 100644 index 0000000000..ac6b893d12 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Model+ModelName.swift @@ -0,0 +1,17 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +extension Model { + + public static var modelName: String { + return String(describing: self) + } + + public var modelName: String { + return type(of: self).modelName + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Model.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Model.swift new file mode 100644 index 0000000000..85b6c1a822 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Model.swift @@ -0,0 +1,81 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +// MARK: - Model + +/// All persistent models should conform to the Model protocol. +public protocol Model: Codable { + /// Alias of Model identifier (i.e. primary key) + @available(*, deprecated, message: "Use ModelIdentifier") + typealias Identifier = String + + /// A reference to the `ModelSchema` associated with this model. + static var schema: ModelSchema { get } + + /// The reference to the root path. It might be `nil` if models do not support + /// property references. + static var rootPath: PropertyContainerPath? { get } + + /// The name of the model, as registered in `ModelRegistry`. + static var modelName: String { get } + + /// Convenience property to return the Type's `modelName`. Developers are strongly encouraged not to override the + /// instance property, as an implementation that returns a different value for the instance property will cause + /// undefined behavior. + var modelName: String { get } + + /// For internal use only when a model schema is provided + /// (i.e. calls from Flutter) + func identifier(schema: ModelSchema) -> ModelIdentifierProtocol + + /// Convenience property to access the serialized value of a model identifier + var identifier: String { get } +} + +extension Model { + public var identifier: String { + guard let schema = ModelRegistry.modelSchema(from: modelName) else { + preconditionFailure("Schema not found for \(modelName).") + } + return identifier(schema: schema).stringValue + } + + public func identifier(schema modelSchema: ModelSchema) -> ModelIdentifierProtocol { + // resolve current instance identifier fields + let fields: ModelIdentifierProtocol.Fields = modelSchema.primaryKey.fields.map { + guard let fieldValue = self[$0.name] else { + preconditionFailure("Identifier field named \($0.name) for model \(modelSchema.name) not found.") + } + + switch fieldValue { + case let value as Persistable: + return (name: $0.name, value: value) + case let value as EnumPersistable: + return (name: $0.name, value: value.rawValue) + default: + preconditionFailure( + "Invalid identifier value \(String(describing: fieldValue)) for field \($0.name) in model \(modelSchema.name)") + } + } + + guard !modelSchema.fields.isEmpty else { + return DefaultModelIdentifier.makeDefault(fromModel: self) + } + + if fields.count == 1, fields[0].name == ModelIdentifierFormat.Default.name { + return ModelIdentifier(fields: fields) + } else { + return ModelIdentifier(fields: fields) + } + } + + /// The `rootPath` is set to `nil` by default. Specific models should override this + /// behavior and provide the proper path reference when available. + public static var rootPath: PropertyContainerPath? { nil } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/ModelIdentifiable.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/ModelIdentifiable.swift new file mode 100644 index 0000000000..141a7ff03d --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/ModelIdentifiable.swift @@ -0,0 +1,140 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// AnyModelIdentifierFormat +public protocol AnyModelIdentifierFormat {} + +/// Defines the identifier (primary key) format +public enum ModelIdentifierFormat { + /// Default identifier ("id") + public enum Default: AnyModelIdentifierFormat { + public static let name = "id" + } + + /// Custom or Composite identifier + public enum Custom: AnyModelIdentifierFormat { + /// Separator used to derive value of composite key + public static let separator = "#" + } +} + +/// Defines requirements for a model to be identifiable with a unique identifier +/// that can be either a single field or a combination of fields +public protocol ModelIdentifiable { + associatedtype IdentifierFormat: AnyModelIdentifierFormat + associatedtype IdentifierProtocol: ModelIdentifierProtocol +} + +/// Defines a `ModelIdentifier` requirements. +public protocol ModelIdentifierProtocol { + typealias Field = (name: String, value: Persistable) + typealias Fields = [Field] + + /// Array of `ModelIdentifierProtocol.Field` that make up the + /// model instance identifier + var fields: ModelIdentifierProtocol.Fields { get } + + /// Serialized instance of the identifier. + /// Its value is the concatenation of its fields. + var stringValue: String { get } + + /// Convenience accessor to the model identifier fields names + var keys: [String] { get } + + /// Convenience accessor to the model identifier field values + var values: [Persistable] { get } + + var predicate: QueryPredicate { get } +} + +public extension ModelIdentifierProtocol { + var stringValue: String { + if fields.count == 1, let field = fields.first { + return field.value.stringValue + } + return fields.map { "\"\($0.value.stringValue)\"" }.joined(separator: ModelIdentifierFormat.Custom.separator) + } + + var keys: [String] { + fields.map { $0.name } + } + + var values: [Persistable] { + fields.map { $0.value } + } + + var predicate: QueryPredicate { + guard let firstField = fields.first else { + preconditionFailure("Found empty model identifier \(fields)") + } + return fields[1...].reduce(field(firstField.name).eq(firstField.value)) { acc, modelField in + field(modelField.name).eq(modelField.value) && acc + } + } +} + +/// General concrete implementation of a `ModelIdentifierProtocol` +public struct ModelIdentifier: ModelIdentifierProtocol { + public var fields: Fields +} + +public extension ModelIdentifier where F == ModelIdentifierFormat.Custom { + static func make(fields: ModelIdentifierProtocol.Field...) -> Self { + Self(fields: fields) + } + /// General purpose initializer, mainly used by Flutter as the platform only has a single model type. + static func make(fields: [ModelIdentifierProtocol.Field]) -> Self { + Self(fields: fields) + } +} + +/// Convenience type for a ModelIdentifier with a `ModelIdentifierFormat.Default` format +public typealias DefaultModelIdentifier = ModelIdentifier + +extension DefaultModelIdentifier { + /// Factory to instantiate a `DefaultModelIdentifier`. + /// - Parameter id: model id value + /// - Returns: an instance of `ModelIdentifier` for the given model type + public static func makeDefault(id: String) -> ModelIdentifier { + ModelIdentifier(fields: [ + (name: ModelIdentifierFormat.Default.name, value: id) + ]) + } + + /// Convenience factory to instantiate a `DefaultModelIdentifier` from a given model + /// - Parameter model: model + /// - Returns: an instance of `ModelIdentifier` for the given model type + public static func makeDefault(fromModel model: M) -> ModelIdentifier { + guard let idValue = model[ModelIdentifierFormat.Default.name] as? String else { + fatalError("Couldn't find default identifier for model \(model)") + } + return .makeDefault(id: idValue) + } +} + +// MARK: - Persistable + stringValue +private extension Persistable { + var stringValue: String { + var value: String + switch self { + case let self as Temporal.Date: + value = self.iso8601String + + case let self as Temporal.DateTime: + value = self.iso8601String + + case let self as Temporal.Time: + value = self.iso8601String + + default: + value = "\(self)" + } + return value + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/PropertyPath.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/PropertyPath.swift new file mode 100644 index 0000000000..212430f56d --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/PropertyPath.swift @@ -0,0 +1,134 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// Runtime information about a property. Its `name` and `parent` property reference, +/// as well as whether the property represents a collection of the type or not. +public struct PropertyPathMetadata { + public let name: String + public let isCollection: Bool + public let parent: PropertyPath? +} + +/// Represents a property of a `Model`. PropertyPath is a way of representing the +/// structure of a model with static typing, so developers can reference model +/// properties in queries and other functionality that require them. +public protocol PropertyPath { + + /// Access the property metadata. + /// + /// **Implementation note:** this function is in place over an implicit accessor over + /// a property named `metadata` in order to avoid name conflict with the actual property + /// names that will get generate from the `Model`. + /// + /// - Returns the property metadata, that contains the name and a reference to its parent. + func getMetadata() -> PropertyPathMetadata +} + +/// This is a specialized protocol to indicate the property is a container of other properties, +/// i.e. a `struct` representing another `Model`. +/// +/// - SeeAlso: `ModelPath` +public protocol PropertyContainerPath: PropertyPath { + + /// + func getKeyPath() -> String + + /// Must return a reference to the type containing the properties + func getModelType() -> Model.Type + +} + +extension PropertyContainerPath { + + public func getKeyPath() -> String { + var metadata = getMetadata() + var path = [String]() + while let parent = metadata.parent { + path.insert(metadata.name, at: 0) + metadata = parent.getMetadata() + } + return path.joined(separator: ".") + } +} + +/// Represents a scalar (i.e. data type) of a model property. +public struct FieldPath: PropertyPath { + private let metadata: PropertyPathMetadata + + init(name: String, parent: PropertyPath? = nil) { + self.metadata = PropertyPathMetadata(name: name, isCollection: false, parent: parent) + } + + public func getMetadata() -> PropertyPathMetadata { + return metadata + } +} + +/// Represents the `Model` structure itself, a container of property references. +/// +/// - Example: +/// ```swift +/// class PostModelPath : ModelPath {} +/// +/// extension ModelPath where ModelType == Post { +/// var id: FieldPath { id() } +/// var title: FieldPath { string("title") } +/// var blog: ModelPath { BlogModelPath(name: "blog", parent: self) } +/// } +/// ``` +open class ModelPath: PropertyContainerPath { + + private let metadata: PropertyPathMetadata + + public init(name: String = "root", isCollection: Bool = false, parent: PropertyPath? = nil) { + self.metadata = PropertyPathMetadata(name: name, isCollection: isCollection, parent: parent) + } + + public func getMetadata() -> PropertyPathMetadata { + return metadata + } + + public func getModelType() -> Model.Type { + return ModelType.self + } + + public func isRoot() -> Bool { + return metadata.parent == nil + } + + public func id(_ name: String = "id") -> FieldPath { + FieldPath(name: name, parent: self) + } + + public func string(_ name: String) -> FieldPath { + FieldPath(name: name, parent: self) + } + + public func bool(_ name: String) -> FieldPath { + FieldPath(name: name, parent: self) + } + + public func date(_ name: String) -> FieldPath { + FieldPath(name: name, parent: self) + } + + public func datetime(_ name: String) -> FieldPath { + FieldPath(name: name, parent: self) + } + + public func time(_ name: String) -> FieldPath { + FieldPath(name: name, parent: self) + } + + public func int(_ name: String) -> FieldPath { + FieldPath(name: name, parent: self) + } + + public func double(_ name: String) -> FieldPath { + FieldPath(name: name, parent: self) + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Temporal/DataStoreError+Temporal.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Temporal/DataStoreError+Temporal.swift new file mode 100644 index 0000000000..4cbec5437c --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Temporal/DataStoreError+Temporal.swift @@ -0,0 +1,23 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension DataStoreError { + + public static func invalidDateFormat(_ value: String) -> DataStoreError { + return DataStoreError.decodingError( + """ + Could not parse \(value) as a Date using the ISO8601 format. + """, + """ + Check if the format used to parse the date is the correct one. Check + `TemporalFormat` for all the options. + """) + } + +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Temporal/Date+Operation.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Temporal/Date+Operation.swift new file mode 100644 index 0000000000..5bf0f19b4f --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Temporal/Date+Operation.swift @@ -0,0 +1,120 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// `DateUnit` for use in adding and subtracting units from `Temporal.Date`, `Temporal.DateTime`, and `Temporal.Time`. +/// +/// `DateUnit` uses `Calendar.Component` under the hood to ensure date oddities are accounted for. +/// +/// let tomorrow = Temporal.Date.now() + .days(1) +/// let twoWeeksFromNow = Temporal.Date.now() + .weeks(2) +/// let nextYear = Temporal.Date.now() + .years(1) +/// +/// let yesterday = Temporal.Date.now() - .days(1) +/// let sixMonthsAgo = Temporal.Date.now() - .months(6) +public struct DateUnit { + let calendarComponent: Calendar.Component + let value: Int + + /// One day. Equivalent to 1 x `Calendar.Component.day` + public static let oneDay: DateUnit = .days(1) + + /// One week. Equivalent to 7 x `Calendar.Component.day` + public static let oneWeek: DateUnit = .weeks(1) + + /// One month. Equivalent to 1 x `Calendar.Component.month` + public static let oneMonth: DateUnit = .months(1) + + /// One year. Equivalent to 1 x `Calendar.Component.year` + public static let oneYear: DateUnit = .years(1) + + /// DateUnit amount of days. + /// One day is 1 x `Calendar.Component.day` + /// + /// let fiveDays = DateUnit.days(5) + /// // or + /// let fiveDays: DateUnit = .days(5) + /// + /// - Parameter value: Amount of days in this `DateUnit` + /// - Returns: A `DateUnit` with the defined number of days. + public static func days(_ value: Int) -> Self { + .init(calendarComponent: .day, value: value) + } + + /// DateUnit amount of weeks. + /// One week is 7 x the `Calendar.Component.day` + /// + /// let twoWeeks = DateUnit.weeks(2) + /// // or + /// let twoWeeks: DateUnit = .weeks(2) + /// + /// - Parameter value: Amount of weeks in this `DateUnit` + /// - Returns: A `DateUnit` with the defined number of weeks. + public static func weeks(_ value: Int) -> Self { + .init(calendarComponent: .day, value: value * 7) + } + + /// DateUnit amount of months. + /// One month is 1 x `Calendar.Component.month` + /// + /// let sixMonths = DateUnit.months(6) + /// // or + /// let sixMonths: DateUnit = .months(6) + /// + /// - Parameter value: Amount of months in this `DateUnit` + /// - Returns: A `DateUnit` with the defined number of months. + public static func months(_ value: Int) -> Self { + .init(calendarComponent: .month, value: value) + } + + /// DateUnit amount of years. + /// One year is 1 x `Calendar.Component.year` + /// + /// let oneYear = DateUnit.years(1) + /// // or + /// let oneYear: DateUnit = .years(1) + /// + /// - Parameter value: Amount of years in this `DateUnit` + /// - Returns: A `DateUnit` with the defined number of years. + public static func years(_ value: Int) -> Self { + .init(calendarComponent: .year, value: value) + } +} + +/// Supports addition and subtraction of `Temporal.Date` and `Temporal.DateTime` with `DateUnit` +public protocol DateUnitOperable { + static func + (left: Self, right: DateUnit) -> Self + static func - (left: Self, right: DateUnit) -> Self +} + +extension TemporalSpec where Self: DateUnitOperable { + + /// Add a `DateUnit` to a `Temporal.Date` or `Temporal.DateTime` + /// + /// let tomorrow = Temporal.Date.now() + .days(1) + /// + /// - Parameters: + /// - left: `Temporal.Date` or `Temporal.DateTime` + /// - right: `DateUnit` to add to `left` + /// - Returns: A new `Temporal.Date` or `Temporal.DateTime` the `DateUnit` was added to. + public static func + (left: Self, right: DateUnit) -> Self { + return left.add(value: right.value, to: right.calendarComponent) + } + + /// Subtract a `DateUnit` from a `Temporal.Date` or `Temporal.DateTime` + /// + /// let yesterday = Temporal.Date.now() - .day(1) + /// + /// - Parameters: + /// - left: `Temporal.Date` or `Temporal.DateTime` + /// - right: `DateUnit` to subtract from `left` + /// - Returns: A new `Temporal.Date` or `Temporal.DateTime` the `DateUnit` was subtracted from. + public static func - (left: Self, right: DateUnit) -> Self { + return left.add(value: -right.value, to: right.calendarComponent) + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Temporal/Date.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Temporal/Date.swift new file mode 100644 index 0000000000..9b27c313e0 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Temporal/Date.swift @@ -0,0 +1,43 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension Temporal { + + /// `Temporal.Date` represents a `Date` with specific allowable formats. + /// + /// * `.short` => `yyyy-MM-dd` + /// * `.medium` => `yyyy-MM-ddZZZZZ` + /// * `.long` => `yyyy-MM-ddZZZZZ` + /// * `.full` => `yyyy-MM-ddZZZZZ` + /// + /// - Note: `.medium`, `.long`, and `.full` are the same date format. + public struct Date: TemporalSpec { + + // Inherits documentation from `TemporalSpec` + public let foundationDate: Foundation.Date + + // Inherits documentation from `TemporalSpec` + public let timeZone: TimeZone? = .utc + + // Inherits documentation from `TemporalSpec` + public static func now() -> Self { + Temporal.Date(Foundation.Date(), timeZone: .utc) + } + + // Inherits documentation from `TemporalSpec` + public init(_ date: Foundation.Date, timeZone: TimeZone?) { + self.foundationDate = Temporal + .iso8601Calendar + .startOfDay(for: date) + } + } +} + +// Allow date unit operations on `Temporal.Date` +extension Temporal.Date: DateUnitOperable {} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Temporal/DateTime.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Temporal/DateTime.swift new file mode 100644 index 0000000000..95c65e5f6e --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Temporal/DateTime.swift @@ -0,0 +1,67 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension Temporal { + /// `Temporal.DateTime` represents a `DateTime` with specific allowable formats. + /// + /// * `.short` => `yyyy-MM-dd'T'HH:mm` + /// * `.medium` => `yyyy-MM-dd'T'HH:mm:ss` + /// * `.long` => `yyyy-MM-dd'T'HH:mm:ssZZZZZ` + /// * `.full` => `yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ` + public struct DateTime: TemporalSpec { + + // Inherits documentation from `TemporalSpec` + public let foundationDate: Foundation.Date + + // Inherits documentation from `TemporalSpec` + public let timeZone: TimeZone? + + // Inherits documentation from `TemporalSpec` + public static func now() -> Self { + Temporal.DateTime(Foundation.Date(), timeZone: .utc) + } + + /// `Temporal.Time` of this `Temporal.DateTime`. + public var time: Time { + Time(foundationDate, timeZone: timeZone) + } + + // Inherits documentation from `TemporalSpec` + public init(_ date: Foundation.Date, timeZone: TimeZone? = .utc) { + let calendar = Temporal.iso8601Calendar + let components = calendar.dateComponents( + DateTime.iso8601DateComponents, + from: date + ) + + self.timeZone = timeZone + + foundationDate = calendar + .date(from: components) ?? date + } + + /// `Calendar.Component`s used in `init(_ date:)` + static let iso8601DateComponents: Set = + [ + .year, + .month, + .day, + .hour, + .minute, + .second, + .nanosecond, + .timeZone + ] + } +} + +// Allow date unit and time unit operations on `Temporal.DateTime` +extension Temporal.DateTime: DateUnitOperable, TimeUnitOperable {} + +extension Temporal.DateTime: Sendable { } diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Temporal/README.md b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Temporal/README.md new file mode 100644 index 0000000000..5611ecd5ba --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Temporal/README.md @@ -0,0 +1,61 @@ +## Custom Data Types + +`DataStore > Model > Temporal` + +Model-based programming aims to simplify data management on apps by abstracting away the complexities of data persistence from the core logic of the app. Therefore, data types are a critical piece of it. + +This module provides types that complements the Swift provided [`Date`](https://developer.apple.com/documentation/foundation/date) with more control over the date granularity when persisting values to a database (i.e. time-only, date-only, date+time). + +**Table of Contents** + +- [Custom Data Types](#custom-data-types) + - [1. Temporal](#1-temporal) + - [1.1. `Temporal.Date`, `Temporal.DateTime`, `Temporal.Time`](#11-temporaldate-temporaldatetime-temporaltime) + - [1.2. ISO-8601](#12-iso-8601) + - [1.3. The underlying `Date`](#13-the-underlying-date) + - [1.4. Operations](#14-operations) + - [1.5. References](#15-references) + +### 1. Temporal + +The Swift foundation module provides the [`Date`](https://developer.apple.com/documentation/foundation/date) struct that represents a single point in time and can fit any precision, calendrical system or time zone. While that approach is concise and powerful, when it comes to representing persistent data its flexibility can result in ambiguity (i.e. should only the date portion be used or both date and time). + + +#### 1.1. `Temporal.Date`, `Temporal.DateTime`, `Temporal.Time` + +The `TemporalSpec` protocol was introduced to establish a more strict way to represent dates that make sense in a data persistence context. + +#### 1.2. ISO-8601 + +The temporal implementations rely on a fixed [ISO-8601](https://www.iso.org/iso-8601-date-and-time-format.html) Calendar implementation ([`.iso8601`](https://developer.apple.com/documentation/foundation/calendar/identifier/iso8601)). If a representation of the date is needed in different calendars, use the underlying date object described in the next section. + +#### 1.3. The underlying `Date` + +Both `DateTime` and `Time` are backed by a [`Date`](https://developer.apple.com/documentation/foundation/date) instance. Therefore, they are compatible with all existing Date APIs from Foundation, including third-party libraries. + +#### 1.4. Operations + +Swift offers great support for complex date operations using [`Calendar`](https://developer.apple.com/documentation/foundation/calendar), but unfortunately simple use-cases often require several lines of code. + +The `TemporalSpec` implementation offers utilities that enable simple date operations to be defined in a readable and idiomatic way. + +Time: + +```swift +// current time plus 2 hours +let time = Time.now + .hours(2) +``` + +Date/Time: + +```swift +// current date/time 2 weeks ago +let datetime = DateTime.now - .weeks(2) +``` + +#### 1.5. References + +Some resources that inspired types defined here: + +- Joda Time: https://www.joda.org/joda-time/ +- Ruby on Rails Date API: https://api.rubyonrails.org/classes/Date.html diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Temporal/SpecBasedDateConverting.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Temporal/SpecBasedDateConverting.swift new file mode 100644 index 0000000000..5aaa135d8d --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Temporal/SpecBasedDateConverting.swift @@ -0,0 +1,48 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Internal generic method to reduce code reuse in the `init`s of `TemporalSpec` +/// conforming types +@usableFromInline +internal struct SpecBasedDateConverting { + @usableFromInline + internal typealias DateConverter = (_ string: String, _ format: TemporalFormat?) throws -> (Date, TimeZone) + + @usableFromInline + internal let convert: DateConverter + + @inlinable + @inline(never) + init(converter: @escaping DateConverter = Self.default) { + self.convert = converter + } + + @inlinable + @inline(never) + internal static func `default`( + iso8601String: String, + format: TemporalFormat? = nil + ) throws -> (Date, TimeZone) { + let date: Foundation.Date + let tz: TimeZone = TimeZone(iso8601DateString: iso8601String) ?? .utc + if let format = format { + date = try Temporal.date( + from: iso8601String, + with: [format(for: Spec.self)] + ) + + } else { + date = try Temporal.date( + from: iso8601String, + with: TemporalFormat.sortedFormats(for: Spec.self) + ) + } + return (date, tz) + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Temporal/Temporal+Cache.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Temporal/Temporal+Cache.swift new file mode 100644 index 0000000000..cb3b7b051f --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Temporal/Temporal+Cache.swift @@ -0,0 +1,146 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension Temporal { + // We lock on reads and writes to prevent race conditions + // of the formatter cache dictionary. + // + // DateFormatter itself is thread safe. + private static var formatterCache: [String: DateFormatter] = [:] + + @usableFromInline + /// The `Calendar` used for date operations. + /// + /// `identifier` is `.iso8601` + /// `timeZome` is `.utc` a.k.a. `TimeZone(abbreviation: "UTC")` + internal static let iso8601Calendar: Calendar = { + var calendar = Calendar(identifier: .iso8601) + calendar.timeZone = .utc + return calendar + }() + + /// Pointer to lock to ensure exclusive access. + private static let lock: UnsafeMutablePointer = { + let pointer = UnsafeMutablePointer.allocate(capacity: 1) + pointer.initialize(to: os_unfair_lock()) + return pointer + }() + + /// Internal helper function to retrieve and/or create `DateFormatter`s + /// - Parameters: + /// - format: The `DateFormatter().dateFormat` + /// - timeZone: The `DateFormatter().timeZone` + /// - Returns: A `DateFormatter` + internal static func formatter( + for format: String, + in timeZone: TimeZone + ) -> DateFormatter { + // lock before read from cache + os_unfair_lock_lock(lock) + + // unlock at return + defer { os_unfair_lock_unlock(lock) } + + // If the formatter is already in the cache and + // the time zones match, we return it rather than + // creating a new one. + if let formatter = formatterCache[format], + formatter.timeZone == timeZone { + return formatter + // defer takes care of unlock + } + + // If: + // - There's not another reference to this formatter + // - It's already in the cache + // - The formatter's time zone doesn't match the requested time zone + // Then: + // We can safely change the formatter's time zone and return it + if isKnownUniquelyReferenced(&formatterCache[format]), + let formatter = formatterCache[format], + formatter.timeZone != timeZone { + formatter.timeZone = timeZone + return formatter + // defer takes care of unlock + } + + // We're about to create a new formatter, + // so let's make sure we're being responsible + // about the amount of formatters we're caching. + // If we already have at least 15 formatters in the cache, + // we're going to evict one using a + // random replacement (RR) eviction policy + if formatterCache.count >= 15 { + // From the `randomElement()` documentation. + // "A random element from the collection. If the collection is empty, the method returns nil." + // Since we just confirmed the cache isn't empty, and we're guaranteeing exclusive access + // through the lock, we can safely force unwrap here. + let keyToEvict = formatterCache.randomElement()!.key + formatterCache[keyToEvict] = nil + // This can likely be improved at a later time by + // using a LFU, or potentially LRU, eviction policy. + } + + // Finally, if the formatter is not in the cache + // or a formatter with the matching format is cached, + // but the time zone doesn't match *and* the formatter + // is already referenced elsewhere, we create one. + let formatter = DateFormatter() + formatter.dateFormat = format + formatter.calendar = iso8601Calendar + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = timeZone + + formatterCache[format] = formatter + return formatter + // defer takes care of unlock + } + + @usableFromInline + /// Turn a `String` into a `Foundation.Date` + /// - Parameters: + /// - string: The date in `String` form. + /// - formats: Any formats in `String` form that you want to check. + /// - timeZone: The `TimeZone` used by the `DateFormatter` when converted. + /// Default is `.utc` a.k.a. `TimeZone(abbreviation: "UTC")` + /// - Returns: A `Foundation.Date` if conversion was successful. + /// - Throws: `DataStoreError.invalidDateFormat(_:)` if conversion was unsuccessful. + internal static func date( + from string: String, + with formats: [String], + in timeZone: TimeZone = .utc + ) throws -> Foundation.Date { + for format in formats { + let formatter = formatter(for: format, in: timeZone) + if let date = formatter.date(from: string) { + return date + } + } + throw DataStoreError + .invalidDateFormat(formats.joined(separator: " | ")) + } + + @usableFromInline + /// Turn a `Foundation.Date` into a `String` + /// - Parameters: + /// - date: The `Foundation.Date` to be converted to `String` form. + /// - formats: Any formats in `String` form that you want to check. + /// - timeZone: The `TimeZone` used by the `DateFormatter` when converted. + /// Default is `.utc` a.k.a. `TimeZone(abbreviation: "UTC")` + /// - Returns: The `String` representation of the `date` formatted according to the `format` argument.. + internal static func string( + from date: Foundation.Date, + with format: String, + in timeZone: TimeZone = .utc + ) -> String { + let formatter = formatter(for: format, in: timeZone) + let string = formatter.string(from: date) + return string + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Temporal/Temporal+Codable.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Temporal/Temporal+Codable.swift new file mode 100644 index 0000000000..024b534d2d --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Temporal/Temporal+Codable.swift @@ -0,0 +1,25 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension TemporalSpec where Self: Codable { + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let value = try container.decode(String.self) + try self.init(iso8601String: value) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(iso8601String) + } +} + +extension Temporal.Date: Codable {} +extension Temporal.DateTime: Codable {} +extension Temporal.Time: Codable {} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Temporal/Temporal+Comparable.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Temporal/Temporal+Comparable.swift new file mode 100644 index 0000000000..bc9e9e47e0 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Temporal/Temporal+Comparable.swift @@ -0,0 +1,30 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Adds conformance to the [Comparable](https://developer.apple.com/documentation/swift/comparable) protocol. +/// Implementations are required to implement the `==` and `<` operators. Swift +/// takes care of deriving the other operations from those two. +/// +/// - Note: the implementation simply delegates to the `iso8601String` formatted date. +extension TemporalSpec where Self: Comparable { + + public static func == (lhs: Self, rhs: Self) -> Bool { + return lhs.iso8601FormattedString(format: .full, timeZone: .utc) + == rhs.iso8601FormattedString(format: .full, timeZone: .utc) + } + + public static func < (lhs: Self, rhs: Self) -> Bool { + return lhs.iso8601FormattedString(format: .full, timeZone: .utc) + < rhs.iso8601FormattedString(format: .full, timeZone: .utc) + } +} + +extension Temporal.Date: Comparable {} +extension Temporal.DateTime: Comparable {} +extension Temporal.Time: Comparable {} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Temporal/Temporal+Hashable.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Temporal/Temporal+Hashable.swift new file mode 100644 index 0000000000..ae3cdd1f7a --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Temporal/Temporal+Hashable.swift @@ -0,0 +1,14 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension TemporalSpec where Self: Hashable { + func hash(into hasher: inout Hasher) { + hasher.combine(foundationDate) + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Temporal/Temporal.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Temporal/Temporal.swift new file mode 100644 index 0000000000..e92f4f9435 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Temporal/Temporal.swift @@ -0,0 +1,124 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// `Temporal` is namespace to all temporal types and basic date operations. It can +/// not be directly instantiated. +/// +/// Available `Temporal` `Specs` +/// - Temporal.Date +/// - Temporal.DateTime +/// - Temporal.Time +public enum Temporal {} + +/// The `TemporalSpec` protocol defines an [ISO-8601](https://www.iso.org/iso-8601-date-and-time-format.html) +/// formatted Date value. Types that conform to this protocol are responsible for providing +/// the parsing and formatting logic with the correct granularity. +public protocol TemporalSpec { + /// A static builder that return an instance that represent the current point in time. + static func now() -> Self + + /// The underlying `Date` object. All `TemporalSpec` implementations must be backed + /// by a Foundation `Date` instance. + var foundationDate: Foundation.Date { get } + + /// The timezone field is an optional field used to specify the timezone associated + /// with a particular date. + var timeZone: TimeZone? { get } + + /// The ISO-8601 formatted string in the UTC `TimeZone`. + /// - SeeAlso: `iso8601FormattedString(TemporalFormat, TimeZone) -> String` + var iso8601String: String { get } + + /// Parses an ISO-8601 `String` into a `TemporalSpec`. + /// + /// - Note: if no timezone is present in the string, `.autoupdatingCurrent` is used. + /// + /// - Parameters + /// - iso8601String: the string in the ISO8601 format + /// - Throws: `DataStoreError.decodeError` in case the provided string is not + /// formatted as expected by the scalar type. + /// - Important: This will cycle through all available formats for the concrete type. + /// If you know the format, use `init(iso8601String:format:) throws` instead. + init(iso8601String: String) throws + + /// Parses an ISO-8601 `String` into a `TemporalSpec`. + /// + /// - Note: if no timezone is present in the string, `.autoupdatingCurrent` is used. + /// + /// - Parameters + /// - iso8601String: the string in the ISO8601 format + /// - format: The `TemporalFormat` of the `iso8601String` + /// - Throws: `DataStoreError.decodeError` in case the provided string is not + /// formatted as expected by the scalar type. + init(iso8601String: String, format: TemporalFormat) throws + + /// Constructs a `TemporalSpec` from a `Date` object. + /// - Parameter date: The `Date` instance that will be used as the reference of the + /// `TemporalSpec` instance. + init(_ date: Foundation.Date, timeZone: TimeZone?) + + /// A string representation of the underlying date formatted using ISO8601 rules. + /// + /// - Parameters: + /// - format: the desired `TemporalFormat`. + /// - timeZone: the target `TimeZone` + /// - Returns: the ISO8601 formatted string in the requested format + func iso8601FormattedString(format: TemporalFormat, timeZone: TimeZone) -> String +} + +extension TemporalSpec { + + /// Create an iso8601 `String` with the desired format option for this spec. + /// - Parameters: + /// - format: Format to use for `Date` -> `String` conversion. + /// - timeZone: `TimeZone` that the `DateFormatter` will use in conversion. + /// Default is `.utc` a.k.a. `TimeZone(abbreviation: "UTC")` + /// - Returns: A `String` formatted according to the `format` and `timeZone` arguments. + public func iso8601FormattedString( + format: TemporalFormat, + timeZone: TimeZone = .utc + ) -> String { + Temporal.string( + from: foundationDate, + with: format(for: Self.self), + in: timeZone + ) + } + + /// The ISO8601 representation of the scalar using `.full` as the format and `.utc` as `TimeZone`. + /// - SeeAlso: `iso8601FormattedString(format:timeZone:)` + public var iso8601String: String { + iso8601FormattedString(format: .full, timeZone: timeZone ?? .utc) + } + + @inlinable + public init(iso8601String: String, format: TemporalFormat) throws { + let (date, tz) = try SpecBasedDateConverting() + .convert(iso8601String, format) + + self.init(date, timeZone: tz) + } + + @inlinable + public init( + iso8601String: String + ) throws { + let (date, tz) = try SpecBasedDateConverting() + .convert(iso8601String, nil) + + self.init(date, timeZone: tz) + } +} + +extension TimeZone { + /// Utility UTC ("Coordinated Universal Time") TimeZone instance. + public static var utc: TimeZone { + TimeZone(abbreviation: "UTC")! + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Temporal/TemporalFormat.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Temporal/TemporalFormat.swift new file mode 100644 index 0000000000..d9cc9ca6f7 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Temporal/TemporalFormat.swift @@ -0,0 +1,98 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public struct TemporalFormat { + let dateFormat: String + let dateTimeFormat: String + let timeFormat: String + + /** + dateFormat is `yyyy-MM-dd` + dateTimeFormat is `yyyy-MM-dd'T'HH:mm` + timeFormat is `HH:mm` + */ + public static let short = TemporalFormat( + dateFormat: "yyyy-MM-dd", + dateTimeFormat: "yyyy-MM-dd'T'HH:mm", + timeFormat: "HH:mm" + ) + + /** + dateFormat is `yyyy-MM-ddZZZZZ` + dateTimeFormat is `yyyy-MM-dd'T'HH:mm:ss` + timeFormat is `HH:mm:ss` + */ + public static let medium = TemporalFormat( + dateFormat: "yyyy-MM-ddZZZZZ", + dateTimeFormat: "yyyy-MM-dd'T'HH:mm:ss", + timeFormat: "HH:mm:ss" + ) + + /** + dateFormat is `yyyy-MM-ddZZZZZ` + dateTimeFormat is `yyyy-MM-dd'T'HH:mm:ssZZZZZ` + timeFormat is `HH:mm:ss.SSS` + */ + public static let long = TemporalFormat( + dateFormat: "yyyy-MM-ddZZZZZ", + dateTimeFormat: "yyyy-MM-dd'T'HH:mm:ssZZZZZ", + timeFormat: "HH:mm:ss.SSS" + ) + + /** + dateFormat is `yyyy-MM-ddZZZZZ` + dateTimeFormat is `yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ` + timeFormat is `HH:mm:ss.SSSZZZZZ` + */ + public static let full = TemporalFormat( + dateFormat: "yyyy-MM-ddZZZZZ", + dateTimeFormat: "yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ", + timeFormat: "HH:mm:ss.SSSZZZZZ" + ) + + public static let allCases = [ + TemporalFormat.full, + .long, + .medium, + .short + ] + + private func keyPath(for type: TemporalSpec.Type) -> KeyPath { + let keyPath: KeyPath + if type == Temporal.Time.self { + keyPath = \TemporalFormat.timeFormat + } else if type == Temporal.Date.self { + keyPath = \TemporalFormat.dateFormat + } else { + keyPath = \TemporalFormat.dateTimeFormat + } + return keyPath + } + + @usableFromInline + internal static func sortedFormats(for type: TemporalSpec.Type) -> [String] { + let formats: [String] + // If the TemporalSpec is `Date`, let's only return `.full` and `.short` + // because `.medium`, `.long`, and `.full` are all the same format. + // If the formats ever differ, this needs to be updated. + if type == Temporal.Date.self { + formats = [TemporalFormat.full, .short] + .map { $0(for: type) } + } else { + formats = Self.allCases + .map { $0(for: type) } + } + return formats + } + + @usableFromInline + internal func callAsFunction(for type: TemporalSpec.Type) -> String { + self[keyPath: keyPath(for: type)] + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Temporal/TemporalOperation.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Temporal/TemporalOperation.swift new file mode 100644 index 0000000000..a413ab566d --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Temporal/TemporalOperation.swift @@ -0,0 +1,38 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension TemporalSpec { + + /// Add a certain amount of `Calendar.Component`s to a `TemporalSpec` + /// - Parameters: + /// - value: The amount to add, or subtract in case of negative values + /// - component: The component that will get the value added + /// - Returns: An instance of the current DateScalar type + func add(value: Int, to component: Calendar.Component) -> Self { + let calendar = Temporal.iso8601Calendar + let result = calendar.date( + byAdding: component, + value: value, + to: foundationDate + ) + guard let date = result else { + fatalError( + """ + The Date operation of the component \(component) and value \(value) + could not be completed. The operation is done on a ISO-8601 Calendar + and the values passed are not valid in an ISO-8601 context. + + This is likely caused by an invalid value in the operation. If you + use user input as values in the operation, make sure you validate them first. + """ + ) + } + return Self.init(date, timeZone: timeZone) + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Temporal/TemporalUnit.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Temporal/TemporalUnit.swift new file mode 100644 index 0000000000..eb1353496f --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Temporal/TemporalUnit.swift @@ -0,0 +1,20 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Defines a common format for a `TemporalSpec` unit used in operations. It is a tuple +/// of `Calendar.Component`, such as `.year`, and an integer value. Those two are later +/// used in date operations such "4 hours from now" and "2 months ago". +@available(*, deprecated, message: "This protocol will be removed in the future.") +public protocol TemporalUnit { + /// The `Calendar.Component` (e.g. `.year`, `.hour`) + var component: Calendar.Component { get } + + /// The integer value. Must be a valid value for the given `component` + var value: Int { get } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Temporal/Time+Operation.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Temporal/Time+Operation.swift new file mode 100644 index 0000000000..83166eb39a --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Temporal/Time+Operation.swift @@ -0,0 +1,131 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// `TimeUnit` for use in adding and subtracting units from `Temporal.DateTime`, and `Temporal.Time`. +/// +/// `TimeUnit` uses `Calendar.Component` under the hood to ensure date oddities are accounted for. +/// +/// let twoHoursFromNow = Temporal.Time.now() + .hours(1) +/// let fiveMinutesAgo = Temporal.Time.now() - .minutes(5) +/// let yesterday = Temporal.Date.now() - .days(1) +/// let sixMonthsAgo = Temporal.Date.now() - .months(6) +/// +/// - Attention: **Don't** use `TimeUnit` to calculate dates, use `DateUnit` instead. +/// Also make sure to use the most applicable `Unit`, e.g. don't use `.minutes(60)` if you really want `.hours(1)`. +/// There are not always 24 hours in a day, 60 minutes in an hour, etc. +public struct TimeUnit { + public let calendarComponent: Calendar.Component + public let value: Int + + /// One second. Equivalent to 1 x `Calendar.Component.second` + public static let oneSecond: TimeUnit = .seconds(1) + + /// One minute. Equivalent to 1 x `Calendar.Component.minute` + public static let oneMinute: TimeUnit = .minutes(1) + + /// One hour. Equivalent to 1 x `Calendar.Component.hour` + public static let oneHour: TimeUnit = .hours(1) + + /// `TimeUnit` amount of hours. + /// + /// One hour is 1 x `Calendar.Component.hour` + /// + /// let twoHours = TimeUnit.hours(2) + /// // or + /// let twoHours: TimeUnit = .hours(2) + /// + /// - Parameter value: Amount of hours in this `TimeUnit` + /// - Returns: A `TimeUnit` with the defined number of hours. + public static func hours(_ value: Int) -> Self { + .init(calendarComponent: .hour, value: value) + } + + /// `TimeUnit` amount of minutes. + /// + /// One minute is 1 x `Calendar.Component.minute` + /// + /// let fiveMinutes = TimeUnit.minutes(5) + /// // or + /// let fiveMinutes: TimeUnit = .minutes(5) + /// + /// - Parameter value: Amount of minutes in this `TimeUnit` + /// - Returns: A `TimeUnit` with the defined number of minutes. + public static func minutes(_ value: Int) -> Self { + .init(calendarComponent: .minute, value: value) + } + + /// `TimeUnit` amount of seconds. + /// + /// One second is 1 x `Calendar.Component.seconds` + /// + /// let thirtySeconds = TimeUnit.seconds(30) + /// // or + /// let thirtySeconds: TimeUnit = .seconds(30) + /// + /// - Parameter value: Amount of seconds in this `TimeUnit` + /// - Returns: A `TimeUnit` with the defined number of seconds. + public static func seconds(_ value: Int) -> Self { + .init(calendarComponent: .second, value: value) + } + + /// `TimeUnit` amount of milliseconds. + /// + /// One second is 1 x `Calendar.Component.nanosecond` \* `NSEC_PER_MSEC` + /// + /// let oneMillisecond = TimeUnit.milliseconds(1) + /// // or + /// let oneMillisecond: TimeUnit = .milliseconds(1) + /// + /// - Parameter value: Amount of milliseconds in this `TimeUnit` + /// - Returns: A `TimeUnit` with the defined number of milliseconds. + public static func milliseconds(_ value: Int) -> Self { + .init(calendarComponent: .nanosecond, value: value * Int(NSEC_PER_MSEC)) + } + + /// `TimeUnit` amount of nanoseconds. + /// + /// One second is 1 x `Calendar.Component.nanosecond` + /// + /// let tenNanoseconds = TimeUnit.nanoseconds(10) + /// // or + /// let tenNanoseconds: TimeUnit = .nanoseconds(10) + /// + /// - Parameter value: Amount of nanoseconds in this `TimeUnit` + /// - Returns: A `TimeUnit` with the defined number of nanoseconds. + public static func nanoseconds(_ value: Int) -> Self { + .init(calendarComponent: .nanosecond, value: value) + } +} + +/// Supports addition and subtraction of `Temporal.Time` and `Temporal.DateTime` with `TimeUnit` +public protocol TimeUnitOperable { + static func + (left: Self, right: TimeUnit) -> Self + static func - (left: Self, right: TimeUnit) -> Self +} + +extension TemporalSpec where Self: TimeUnitOperable { + + /// Add a `TimeUnit` to a `Temporal.Time` or `Temporal.DateTime` + /// - Parameters: + /// - left: `Temporal.Time` or `Temporal.DateTime` + /// - right: `TimeUnit` to add to `left` + /// - Returns: A new `Temporal.Time` or `Temporal.DateTime` the `TimeUnit` was added to. + public static func + (left: Self, right: TimeUnit) -> Self { + return left.add(value: right.value, to: right.calendarComponent) + } + + /// Subtract a `TimeUnit` from a `Temporal.Time` or `Temporal.DateTime` + /// - Parameters: + /// - left: `Temporal.Time` or `Temporal.DateTime` + /// - right: `TimeUnit` to subtract from `left` + /// - Returns: A new `Temporal.Time` or `Temporal.DateTime` the `TimeUnit` was subtracted from. + public static func - (left: Self, right: TimeUnit) -> Self { + return left.add(value: -right.value, to: right.calendarComponent) + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Temporal/Time.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Temporal/Time.swift new file mode 100644 index 0000000000..d4185e874d --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Temporal/Time.swift @@ -0,0 +1,72 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension Temporal { + /// `Temporal.Time` represents a `Time` with specific allowable formats. + /// + /// * `.short` => `HH:mm` + /// * `.medium` => `HH:mm:ss` + /// * `.long` => `HH:mm:ss.SSS` + /// * `.full` => `HH:mm:ss.SSSZZZZZ` + public struct Time: TemporalSpec { + // Inherits documentation from `TemporalSpec` + public let foundationDate: Foundation.Date + + // Inherits documentation from `TemporalSpec` + public let timeZone: TimeZone? = .utc + + // Inherits documentation from `TemporalSpec` + public static func now() -> Self { + Temporal.Time(Foundation.Date(), timeZone: .utc) + } + + // Inherits documentation from `TemporalSpec` + public init(_ date: Foundation.Date, timeZone: TimeZone?) { + // Sets the date to a fixed instant so time-only operations are safe + let calendar = Temporal.iso8601Calendar + var components = calendar.dateComponents( + [ + .year, + .month, + .day, + .hour, + .minute, + .second, + .nanosecond, + .timeZone + ], + from: date + ) + // This is the same behavior of Foundation.Date when parsed from strings + // without year-month-day information + components.year = 2_000 + components.month = 1 + components.day = 1 + self.foundationDate = calendar + .date(from: components) ?? date + } + + @available(*, deprecated, message: """ + iso8601DateComponents will be removed from the public API in the future. This isn't + used to interact with any other public APIs and doesn't provide any value. If you + believe otherwise, please open an issue at + `https://github.com/aws-amplify/amplify-ios/issues/new/choose` + outlining your use case. + + If you're currently using this, please make a property in your own + module to replace the use of this one. + """) + public static var iso8601DateComponents: Set { + [.hour, .minute, .second, .nanosecond, .timeZone] + } + } +} + +// Allow time unit operations on `Temporal.Time` +extension Temporal.Time: TimeUnitOperable {} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Temporal/TimeZone+Extension.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Temporal/TimeZone+Extension.swift new file mode 100644 index 0000000000..efbbbfb673 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Model/Temporal/TimeZone+Extension.swift @@ -0,0 +1,149 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension TimeZone { + + @usableFromInline + internal init?(iso8601DateString: String) { + switch ISO8601TimeZonePart.from(iso8601DateString: iso8601DateString) { + case .some(.utc): + self.init(abbreviation: "UTC") + case let .some(.hh(hours: hours)): + self.init(secondsFromGMT: hours * 60 * 60) + case let .some(.hhmm(hours: hours, minutes: minutes)), + let .some(.hh_mm(hours: hours, minuts: minutes)): + self.init(secondsFromGMT: hours * 60 * 60 + + (hours > 0 ? 1 : -1) * minutes * 60) + case let .some(.hh_mm_ss(hours: hours, minutes: minutes, seconds: seconds)): + self.init(secondsFromGMT: hours * 60 * 60 + + (hours > 0 ? 1 : -1) * minutes * 60 + + (hours > 0 ? 1 : -1) * seconds) + case .none: + return nil + } + } +} + +// swiftlint:disable identifier_name +/// ISO8601 Time Zone formats +/// - Note: +/// `±hh:mm:ss` is not a standard of ISO8601 date formate. It's supported by `AWSDateTime` exclusively. +/// +/// references: +/// https://en.wikipedia.org/wiki/ISO_8601#Time_zone_designators +/// https://docs.aws.amazon.com/appsync/latest/devguide/scalars.html#graph-ql-aws-appsync-scalars +private enum ISO8601TimeZoneFormat { + case utc, hh, hhmm, hh_mm, hh_mm_ss + + var format: String { + switch self { + case .utc: + return "Z" + case .hh: + return "±hh" + case .hhmm: + return "±hhmm" + case .hh_mm: + return "±hh:mm" + case .hh_mm_ss: + return "±hh:mm:ss" + } + } + + var regex: NSRegularExpression? { + switch self { + case .utc: + return try? NSRegularExpression(pattern: "^Z$") + case .hh: + return try? NSRegularExpression(pattern: "^[+-]\\d{2}$") + case .hhmm: + return try? NSRegularExpression(pattern: "^[+-]\\d{2}\\d{2}$") + case .hh_mm: + return try? NSRegularExpression(pattern: "^[+-]\\d{2}:\\d{2}$") + case .hh_mm_ss: + return try? NSRegularExpression(pattern: "^[+-]\\d{2}:\\d{2}:\\d{2}$") + } + } + + var parts: [NSRange] { + switch self { + case .utc: + return [] + case .hh: + return [NSRange(location: 0, length: 3)] + case .hhmm: + return [ + NSRange(location: 0, length: 3), + NSRange(location: 3, length: 2) + ] + case .hh_mm: + return [ + NSRange(location: 0, length: 3), + NSRange(location: 4, length: 2) + ] + case .hh_mm_ss: + return [ + NSRange(location: 0, length: 3), + NSRange(location: 4, length: 2), + NSRange(location: 7, length: 2) + ] + } + } +} + +private enum ISO8601TimeZonePart { + case utc + case hh(hours: Int) + case hhmm(hours: Int, minutes: Int) + case hh_mm(hours: Int, minuts: Int) + case hh_mm_ss(hours: Int, minutes: Int, seconds: Int) + + static func from(iso8601DateString: String) -> ISO8601TimeZonePart? { + return tryExtract(from: iso8601DateString, with: .utc) + ?? tryExtract(from: iso8601DateString, with: .hh) + ?? tryExtract(from: iso8601DateString, with: .hhmm) + ?? tryExtract(from: iso8601DateString, with: .hh_mm) + ?? tryExtract(from: iso8601DateString, with: .hh_mm_ss) + ?? nil + } +} + +private func tryExtract( + from dateString: String, + with format: ISO8601TimeZoneFormat +) -> ISO8601TimeZonePart? { + guard dateString.count > format.format.count else { + return nil + } + + let tz = String(dateString.dropFirst(dateString.count - format.format.count)) + + guard format.regex.flatMap({ + $0.firstMatch(in: tz, range: NSRange(location: 0, length: tz.count)) + }) != nil else { + return nil + } + + let parts = format.parts.compactMap { range in + Range(range, in: tz).flatMap { Int(tz[$0]) } + } + + guard parts.count == format.parts.count else { + return nil + } + + switch format { + case .utc: return .utc + case .hh: return .hh(hours: parts[0]) + case .hhmm: return .hhmm(hours: parts[0], minutes: parts[1]) + case .hh_mm: return .hh_mm(hours: parts[0], minuts: parts[1]) + case .hh_mm_ss: return .hh_mm_ss(hours: parts[0], minutes: parts[1], seconds: parts[2]) + } +} +// swiftlint:enable identifier_name diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Query/Evaluable.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Query/Evaluable.swift new file mode 100644 index 0000000000..a8bdc7cde5 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Query/Evaluable.swift @@ -0,0 +1,12 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public protocol Evaluable { + func evaluate(target: Model) -> Bool +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Query/ModelKey.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Query/ModelKey.swift new file mode 100644 index 0000000000..8096bfc9c7 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Query/ModelKey.swift @@ -0,0 +1,140 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// The `ModelKey` protocol is used to decorate Swift standard's `CodingKey` enum with +/// query functions and operators that are used to build query conditions. +/// +/// ``` +/// let post = Post.keys +/// +/// Amplify.DataStore.query(Post.self, where: { +/// post.title.contains("[Amplify]") +/// .and(post.content.ne(nil)) +/// }) +/// ``` +/// +/// **Using Operators:** +/// +/// The operators on a `ModelKey` reference are defined so queries can also be written +/// with Swift operators as well: +/// +/// ``` +/// let post = Post.keys +/// +/// Amplify.DataStore.query(Post.self, where: { +/// post.title ~= "[Amplify]" && +/// post.content != nil +/// }) +/// ``` +public protocol ModelKey: CodingKey, CaseIterable, QueryFieldOperation {} + +extension CodingKey where Self: ModelKey { + + // MARK: - beginsWith + public func beginsWith(_ value: String) -> QueryPredicateOperation { + return field(stringValue).beginsWith(value) + } + + // MARK: - between + public func between(start: Persistable, end: Persistable) -> QueryPredicateOperation { + return field(stringValue).between(start: start, end: end) + } + + // MARK: - contains + + public func contains(_ value: String) -> QueryPredicateOperation { + return field(stringValue).contains(value) + } + + public static func ~= (key: Self, value: String) -> QueryPredicateOperation { + return key.contains(value) + } + + // MARK: - not contains + public func notContains(_ value: String) -> QueryPredicateOperation { + return field(stringValue).notContains(value) + } + + // MARK: - eq + + public func eq(_ value: Persistable?) -> QueryPredicateOperation { + return field(stringValue).eq(value) + } + + public func eq(_ value: EnumPersistable) -> QueryPredicateOperation { + return field(stringValue).eq(value) + } + + public static func == (key: Self, value: Persistable?) -> QueryPredicateOperation { + return key.eq(value) + } + + public static func == (key: Self, value: EnumPersistable) -> QueryPredicateOperation { + return key.eq(value) + } + + // MARK: - ge + + public func ge(_ value: Persistable) -> QueryPredicateOperation { + return field(stringValue).ge(value) + } + + public static func >= (key: Self, value: Persistable) -> QueryPredicateOperation { + return key.ge(value) + } + + // MARK: - gt + + public func gt(_ value: Persistable) -> QueryPredicateOperation { + return field(stringValue).gt(value) + } + + public static func > (key: Self, value: Persistable) -> QueryPredicateOperation { + return key.gt(value) + } + + // MARK: - le + + public func le(_ value: Persistable) -> QueryPredicateOperation { + return field(stringValue).le(value) + } + + public static func <= (key: Self, value: Persistable) -> QueryPredicateOperation { + return key.le(value) + } + + // MARK: - lt + + public func lt(_ value: Persistable) -> QueryPredicateOperation { + return field(stringValue).lt(value) + } + + public static func < (key: Self, value: Persistable) -> QueryPredicateOperation { + return key.lt(value) + } + + // MARK: - ne + + public func ne(_ value: Persistable?) -> QueryPredicateOperation { + return field(stringValue).ne(value) + } + + public func ne(_ value: EnumPersistable) -> QueryPredicateOperation { + return field(stringValue).ne(value) + } + + public static func != (key: Self, value: Persistable?) -> QueryPredicateOperation { + return key.ne(value) + } + + public static func != (key: Self, value: EnumPersistable) -> QueryPredicateOperation { + return key.ne(value) + } + +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Query/QueryField.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Query/QueryField.swift new file mode 100644 index 0000000000..9d29967569 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Query/QueryField.swift @@ -0,0 +1,164 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Creates a new instance of the `QueryField` that can be used to create query predicates. +/// +/// ```swift +/// field("id").eq("some-uuid") +/// // or using the operator-based: +/// field("id") == "some-uuid" +/// ``` +/// +/// - Parameter name: the name of the field +/// - Returns: an instance of the `QueryField` +/// - seealso: `ModelKey` for `CodingKey`-based approach on predicates +public func field(_ name: String) -> QueryField { + return QueryField(name: name) +} + +/// `QueryFieldOperation` provides functions that creates predicates based on a field name. +/// These functions are matchers that get executed at a later point by specific implementations +/// of the `Model` filtering logic (e.g. SQL or GraphQL queries). +/// +/// - seealso: `QueryField` +/// - seealso: `ModelKey` +public protocol QueryFieldOperation { + // MARK: - Functions + + func beginsWith(_ value: String) -> QueryPredicateOperation + func between(start: Persistable, end: Persistable) -> QueryPredicateOperation + func contains(_ value: String) -> QueryPredicateOperation + func notContains(_ value: String) -> QueryPredicateOperation + func eq(_ value: Persistable?) -> QueryPredicateOperation + func eq(_ value: EnumPersistable) -> QueryPredicateOperation + func ge(_ value: Persistable) -> QueryPredicateOperation + func gt(_ value: Persistable) -> QueryPredicateOperation + func le(_ value: Persistable) -> QueryPredicateOperation + func lt(_ value: Persistable) -> QueryPredicateOperation + func ne(_ value: Persistable?) -> QueryPredicateOperation + func ne(_ value: EnumPersistable) -> QueryPredicateOperation + + // MARK: - Operators + + static func ~= (key: Self, value: String) -> QueryPredicateOperation + static func == (key: Self, value: Persistable?) -> QueryPredicateOperation + static func == (key: Self, value: EnumPersistable) -> QueryPredicateOperation + static func >= (key: Self, value: Persistable) -> QueryPredicateOperation + static func > (key: Self, value: Persistable) -> QueryPredicateOperation + static func <= (key: Self, value: Persistable) -> QueryPredicateOperation + static func < (key: Self, value: Persistable) -> QueryPredicateOperation + static func != (key: Self, value: Persistable?) -> QueryPredicateOperation + static func != (key: Self, value: EnumPersistable) -> QueryPredicateOperation +} + +public struct QueryField: QueryFieldOperation { + + public let name: String + + // MARK: - beginsWith + public func beginsWith(_ value: String) -> QueryPredicateOperation { + return QueryPredicateOperation(field: name, operator: .beginsWith(value)) + } + + // MARK: - between + public func between(start: Persistable, end: Persistable) -> QueryPredicateOperation { + return QueryPredicateOperation(field: name, operator: .between(start: start, end: end)) + } + + // MARK: - contains + + public func contains(_ value: String) -> QueryPredicateOperation { + return QueryPredicateOperation(field: name, operator: .contains(value)) + } + + public static func ~= (key: Self, value: String) -> QueryPredicateOperation { + return key.contains(value) + } + + // MARK: - not contains + public func notContains(_ value: String) -> QueryPredicateOperation { + return QueryPredicateOperation(field: name, operator: .notContains(value)) + } + + // MARK: - eq + + public func eq(_ value: Persistable?) -> QueryPredicateOperation { + return QueryPredicateOperation(field: name, operator: .equals(value)) + } + + public func eq(_ value: EnumPersistable) -> QueryPredicateOperation { + return QueryPredicateOperation(field: name, operator: .equals(value.rawValue)) + } + + public static func == (key: Self, value: Persistable?) -> QueryPredicateOperation { + return key.eq(value) + } + + public static func == (key: Self, value: EnumPersistable) -> QueryPredicateOperation { + return key.eq(value) + } + + // MARK: - ge + + public func ge(_ value: Persistable) -> QueryPredicateOperation { + return QueryPredicateOperation(field: name, operator: .greaterOrEqual(value)) + } + + public static func >= (key: Self, value: Persistable) -> QueryPredicateOperation { + return key.ge(value) + } + + // MARK: - gt + + public func gt(_ value: Persistable) -> QueryPredicateOperation { + return QueryPredicateOperation(field: name, operator: .greaterThan(value)) + } + + public static func > (key: Self, value: Persistable) -> QueryPredicateOperation { + return key.gt(value) + } + + // MARK: - le + + public func le(_ value: Persistable) -> QueryPredicateOperation { + return QueryPredicateOperation(field: name, operator: .lessOrEqual(value)) + } + + public static func <= (key: Self, value: Persistable) -> QueryPredicateOperation { + return key.le(value) + } + + // MARK: - lt + + public func lt(_ value: Persistable) -> QueryPredicateOperation { + return QueryPredicateOperation(field: name, operator: .lessThan(value)) + } + + public static func < (key: Self, value: Persistable) -> QueryPredicateOperation { + return key.lt(value) + } + + // MARK: - ne + + public func ne(_ value: Persistable?) -> QueryPredicateOperation { + return QueryPredicateOperation(field: name, operator: .notEqual(value)) + } + + public func ne(_ value: EnumPersistable) -> QueryPredicateOperation { + return QueryPredicateOperation(field: name, operator: .notEqual(value.rawValue)) + } + + public static func != (key: Self, value: Persistable?) -> QueryPredicateOperation { + return key.ne(value) + } + + public static func != (key: Self, value: EnumPersistable) -> QueryPredicateOperation { + return key.ne(value) + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Query/QueryOperator+Equatable.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Query/QueryOperator+Equatable.swift new file mode 100644 index 0000000000..41ee77159b --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Query/QueryOperator+Equatable.swift @@ -0,0 +1,32 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension QueryOperator: Equatable { + public static func == (lhs: QueryOperator, rhs: QueryOperator) -> Bool { + switch (lhs, rhs) { + case let (.contains(one), .contains(other)), + let (.beginsWith(one), .beginsWith(other)): + return one == other + case let (.equals(one), .equals(other)), + let (.notEqual(one), .notEqual(other)): + return PersistableHelper.isEqual(one, other) + case let (.greaterOrEqual(one), .greaterOrEqual(other)), + let (.greaterThan(one), .greaterThan(other)), + let (.lessOrEqual(one), .lessOrEqual(other)), + let (.lessThan(one), .lessThan(other)): + return PersistableHelper.isEqual(one, other) + case let (.between(oneStart, oneEnd), .between(otherStart, otherEnd)): + return PersistableHelper.isEqual(oneStart, otherStart) + && PersistableHelper.isEqual(oneEnd, otherEnd) + default: + return false + } + } + +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Query/QueryOperator.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Query/QueryOperator.swift new file mode 100644 index 0000000000..2fcb50ccd2 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Query/QueryOperator.swift @@ -0,0 +1,110 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public enum QueryOperator: Encodable { + case notEqual(_ value: Persistable?) + case equals(_ value: Persistable?) + case lessOrEqual(_ value: Persistable) + case lessThan(_ value: Persistable) + case greaterOrEqual(_ value: Persistable) + case greaterThan(_ value: Persistable) + case contains(_ value: String) + case notContains(_ value: String) + case between(start: Persistable, end: Persistable) + case beginsWith(_ value: String) + + public func evaluate(target: Any) -> Bool { + switch self { + case .notEqual(let predicateValue): + return !PersistableHelper.isEqual(target, predicateValue) + case .equals(let predicateValue): + return PersistableHelper.isEqual(target, predicateValue) + case .lessOrEqual(let predicateValue): + return PersistableHelper.isLessOrEqual(target, predicateValue) + case .lessThan(let predicateValue): + return PersistableHelper.isLessThan(target, predicateValue) + case .greaterOrEqual(let predicateValue): + return PersistableHelper.isGreaterOrEqual(target, predicateValue) + case .greaterThan(let predicateValue): + return PersistableHelper.isGreaterThan(target, predicateValue) + case .contains(let predicateString): + if let targetString = target as? String { + return targetString.contains(predicateString) + } + return false + case .notContains(let predicateString): + if let targetString = target as? String { + return !targetString.contains(predicateString) + } + case .between(let start, let end): + return PersistableHelper.isBetween(start, end, target) + case .beginsWith(let predicateValue): + if let targetString = target as? String { + return targetString.starts(with: predicateValue) + } + } + return false + } + + private enum CodingKeys: String, CodingKey { + case type + case value + case start + case end + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + switch self { + case .notEqual(let value): + try container.encode("notEqual", forKey: .type) + if let value = value { + try container.encode(value, forKey: .value) + } + case .equals(let value): + try container.encode("equals", forKey: .type) + if let value = value { + try container.encode(value, forKey: .value) + } + case .lessOrEqual(let value): + try container.encode("lessOrEqual", forKey: .type) + try container.encode(value, forKey: .value) + + case .lessThan(let value): + try container.encode("lessThan", forKey: .type) + try container.encode(value, forKey: .value) + + case .greaterOrEqual(let value): + try container.encode("greaterOrEqual", forKey: .type) + try container.encode(value, forKey: .value) + + case .greaterThan(let value): + try container.encode("greaterThan", forKey: .type) + try container.encode(value, forKey: .value) + + case .contains(let value): + try container.encode("contains", forKey: .type) + try container.encode(value, forKey: .value) + + case .notContains(let value): + try container.encode("notContains", forKey: .type) + try container.encode(value, forKey: .value) + + case .between(let start, let end): + try container.encode("between", forKey: .type) + try container.encode(start, forKey: .start) + try container.encode(end, forKey: .end) + + case .beginsWith(let value): + try container.encode("beginsWith", forKey: .type) + try container.encode(value, forKey: .value) + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Query/QueryPaginationInput.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Query/QueryPaginationInput.swift new file mode 100644 index 0000000000..75b46ffbac --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Query/QueryPaginationInput.swift @@ -0,0 +1,48 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// A simple struct that holds pagination information that can be applied queries. +public struct QueryPaginationInput { + + /// The default page size. + public static let defaultLimit: UInt = 100 + + /// The page number. It starts at 0. + public let page: UInt + + /// The number of results per page. + public let limit: UInt + +} + +extension QueryPaginationInput { + + /// Creates a `QueryPaginationInput` in an expressive way, enabling a short + /// and developer friendly access to an instance of `QueryPaginationInput`. + /// + /// - Parameters: + /// - page: the page number (starting at 0) + /// - limit: the page size (defaults to `QueryPaginationInput.defaultLimit`) + /// - Returns: a new instance of `QueryPaginationInput` + public static func page(_ page: UInt, + limit: UInt = QueryPaginationInput.defaultLimit) -> QueryPaginationInput { + return QueryPaginationInput(page: page, limit: limit) + } + + /// Utility that created a `QueryPaginationInput` with `page` 0 and `limit` 1 + public static var firstResult: QueryPaginationInput { + .page(0, limit: 1) + } + + /// Utility that created a `QueryPaginationInput` with `page` 0 and the default `limit` + public static var firstPage: QueryPaginationInput { + .page(0) + } + +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Query/QueryPredicate+Equatable.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Query/QueryPredicate+Equatable.swift new file mode 100644 index 0000000000..db140e5a0f --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Query/QueryPredicate+Equatable.swift @@ -0,0 +1,41 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +private func isEqual(_ one: QueryPredicate?, to other: QueryPredicate?) -> Bool { + if one == nil && other == nil { + return true + } + if let one = one as? QueryPredicateOperation, let other = other as? QueryPredicateOperation { + return one == other + } + if let one = one as? QueryPredicateGroup, let other = other as? QueryPredicateGroup { + return one == other + } + return false +} + +extension QueryPredicateOperation: Equatable { + + public static func == (lhs: QueryPredicateOperation, rhs: QueryPredicateOperation) -> Bool { + return lhs.field == rhs.field && lhs.operator == rhs.operator + } + +} + +extension QueryPredicateGroup: Equatable { + + public static func == (lhs: QueryPredicateGroup, rhs: QueryPredicateGroup) -> Bool { + return lhs.type == rhs.type + && lhs.predicates.count == rhs.predicates.count + && lhs.predicates.enumerated().first { + !isEqual($0.element, to: rhs.predicates[$0.offset]) + } == nil + } + +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Query/QueryPredicate.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Query/QueryPredicate.swift new file mode 100644 index 0000000000..78bdf9f051 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Query/QueryPredicate.swift @@ -0,0 +1,188 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Protocol that indicates concrete types conforming to it can be used a predicate member. +public protocol QueryPredicate: Evaluable, Encodable {} + +public enum QueryPredicateGroupType: String, Encodable { + case and + case or + case not +} + +/// The `not` function is used to wrap a `QueryPredicate` in a `QueryPredicateGroup` of type `.not`. +/// - Parameter predicate: the `QueryPredicate` (either operation or group) +/// - Returns: `QueryPredicateGroup` of type `.not` +public func not(_ predicate: Predicate) -> QueryPredicateGroup { + return QueryPredicateGroup(type: .not, predicates: [predicate]) +} + +/// The case `.all` is a predicate used as an argument to select all of a single modeltype. We +/// chose `.all` instead of `nil` because we didn't want to use the implicit nature of `nil` to +/// specify an action applies to an entire data set. +public enum QueryPredicateConstant: QueryPredicate, Encodable { + case all + public func evaluate(target: Model) -> Bool { + return true + } +} + +public class QueryPredicateGroup: QueryPredicate, Encodable { + public internal(set) var type: QueryPredicateGroupType + public internal(set) var predicates: [QueryPredicate] + + public init(type: QueryPredicateGroupType = .and, + predicates: [QueryPredicate] = []) { + self.type = type + self.predicates = predicates + } + + public func and(_ predicate: QueryPredicate) -> QueryPredicateGroup { + if case .and = type { + predicates.append(predicate) + return self + } + return QueryPredicateGroup(type: .and, predicates: [self, predicate]) + } + + public func or(_ predicate: QueryPredicate) -> QueryPredicateGroup { + if case .or = type { + predicates.append(predicate) + return self + } + return QueryPredicateGroup(type: .or, predicates: [self, predicate]) + } + + public static func && (lhs: QueryPredicateGroup, rhs: QueryPredicate) -> QueryPredicateGroup { + return lhs.and(rhs) + } + + public static func || (lhs: QueryPredicateGroup, rhs: QueryPredicate) -> QueryPredicateGroup { + return lhs.or(rhs) + } + + public static prefix func ! (rhs: QueryPredicateGroup) -> QueryPredicateGroup { + return not(rhs) + } + + public func evaluate(target: Model) -> Bool { + switch type { + case .or: + for predicate in predicates { + if predicate.evaluate(target: target) { + return true + } + } + return false + case .and: + for predicate in predicates { + if !predicate.evaluate(target: target) { + return false + } + } + return true + case .not: + let predicate = predicates[0] + return !predicate.evaluate(target: target) + } + } + + // MARK: - Encodable conformance + + private enum CodingKeys: String, CodingKey { + case type + case predicates + } + + struct AnyQueryPredicate: Encodable { + private let _encode: (Encoder) throws -> Void + + init(_ base: QueryPredicate) { + _encode = base.encode + } + + func encode(to encoder: Encoder) throws { + try _encode(encoder) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(type.rawValue, forKey: .type) + + let anyPredicates = predicates.map(AnyQueryPredicate.init) + try container.encode(anyPredicates, forKey: .predicates) + } + +} + +public class QueryPredicateOperation: QueryPredicate, Encodable { + + public let field: String + public let `operator`: QueryOperator + + public init(field: String, operator: QueryOperator) { + self.field = field + self.operator = `operator` + } + + public func and(_ predicate: QueryPredicate) -> QueryPredicateGroup { + let group = QueryPredicateGroup(type: .and, predicates: [self, predicate]) + return group + } + + public func or(_ predicate: QueryPredicate) -> QueryPredicateGroup { + let group = QueryPredicateGroup(type: .or, predicates: [self, predicate]) + return group + } + + public static func && (lhs: QueryPredicateOperation, rhs: QueryPredicate) -> QueryPredicateGroup { + return lhs.and(rhs) + } + + public static func || (lhs: QueryPredicateOperation, rhs: QueryPredicate) -> QueryPredicateGroup { + return lhs.or(rhs) + } + + public static prefix func ! (rhs: QueryPredicateOperation) -> QueryPredicateGroup { + return not(rhs) + } + + public func evaluate(target: Model) -> Bool { + guard let fieldValue = target[field] else { + return false + } + + guard let value = fieldValue else { + return false + } + + if let booleanValue = value as? Bool { + return self.operator.evaluate(target: booleanValue) + } + + if let doubleValue = value as? Double { + return self.operator.evaluate(target: doubleValue) + } + + if let intValue = value as? Int { + return self.operator.evaluate(target: intValue) + } + + if let timeValue = value as? Temporal.Time { + return self.operator.evaluate(target: timeValue) + } + + if let enumValue = value as? EnumPersistable { + return self.operator.evaluate(target: enumValue.rawValue) + } + + return self.operator.evaluate(target: value) + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Query/QuerySortInput.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Query/QuerySortInput.swift new file mode 100644 index 0000000000..a08392c673 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Query/QuerySortInput.swift @@ -0,0 +1,48 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// A simple enum that holding a sorting direction for a field that can be applied to queries. +public enum QuerySortBy { + case ascending(CodingKey) + case descending(CodingKey) +} + +public struct QuerySortInput { + public let inputs: [QuerySortBy] + + public init(_ inputs: [QuerySortBy]) { + self.inputs = inputs + } + + /// Creates a `QuerySortInput` in an expressive way, enabling a short + /// and developer friendly access to an instance of `QuerySortInput`. + /// Simply by calling: + /// - Amplify.Datastore.query(model.self, sort: .ascending(model.keys.id)) + /// or + /// - Amplify.Datastore.query(model.self, sort: .descending(model.keys.id)) + /// or + /// - Amplify.Datastore.query(model.self, sort: .by(.ascending(model.keys.id), .descending(model.keys.createdAt)) + /// + /// - Parameters: + /// - inputs: a variadic parameters that take uncertain number of `QuerySortBy` + /// - Returns: a new instance of `QuerySortInput` + public static func by(_ inputs: QuerySortBy...) -> QuerySortInput { + return self.init(inputs) + } + + /// Returns an ascending sort specifier for `field` + public static func ascending(_ field: CodingKey) -> QuerySortInput { + return QuerySortInput([.ascending(field)]) + } + + /// Returns an descending sort specifier for `field` + public static func descending(_ field: CodingKey) -> QuerySortInput { + return QuerySortInput([.descending(field)]) + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Subscribe/DataStoreCategory+Subscribe.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Subscribe/DataStoreCategory+Subscribe.swift new file mode 100644 index 0000000000..ae669c73f9 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Subscribe/DataStoreCategory+Subscribe.swift @@ -0,0 +1,21 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Combine + +extension DataStoreCategory: DataStoreSubscribeBehavior { + public func observe(_ modelType: M.Type) -> AmplifyAsyncThrowingSequence { + return plugin.observe(modelType) + } + + public func observeQuery(for modelType: M.Type, + where predicate: QueryPredicate? = nil, + sort sortInput: QuerySortInput? = nil) + -> AmplifyAsyncThrowingSequence> { + return plugin.observeQuery(for: modelType, where: predicate, sort: sortInput) + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Subscribe/DataStoreQuerySnapshot.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Subscribe/DataStoreQuerySnapshot.swift new file mode 100644 index 0000000000..3f801cbcc8 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Subscribe/DataStoreQuerySnapshot.swift @@ -0,0 +1,26 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// A snapshot of the items from DataStore, the changes since last snapshot, and whether this model has +/// finished syncing and subscriptions are active +public struct DataStoreQuerySnapshot { + + /// All model instances from the local store + public let items: [M] + + /// Indicates whether all sync queries for this model are complete, and subscriptions are active + public let isSynced: Bool + + public init(items: [M], isSynced: Bool) { + self.items = items + self.isSynced = isSynced + } +} + +extension DataStoreQuerySnapshot: Sendable { } diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Subscribe/MutationEvent+Model.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Subscribe/MutationEvent+Model.swift new file mode 100644 index 0000000000..332376b096 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Subscribe/MutationEvent+Model.swift @@ -0,0 +1,39 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +extension MutationEvent { + + public init(untypedModel model: Model, + mutationType: MutationType, + version: Int? = nil) throws { + guard let modelType = ModelRegistry.modelType(from: model.modelName) else { + let dataStoreError = DataStoreError.invalidModelName(model.modelName) + throw dataStoreError + } + + try self.init(untypedModel: model, + modelName: modelType.schema.name, + mutationType: mutationType, + version: version) + } + + public init(untypedModel model: Model, + modelName: ModelName, + mutationType: MutationType, + version: Int? = nil) throws { + let json = try model.toJSON() + guard let modelSchema = ModelRegistry.modelSchema(from: modelName) else { + let dataStoreError = DataStoreError.invalidModelName(modelName) + throw dataStoreError + } + self.init(modelId: model.identifier(schema: modelSchema).stringValue, + modelName: modelName, + json: json, + mutationType: mutationType, + version: version) + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Subscribe/MutationEvent+MutationType.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Subscribe/MutationEvent+MutationType.swift new file mode 100644 index 0000000000..be383d374e --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Subscribe/MutationEvent+MutationType.swift @@ -0,0 +1,40 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public extension MutationEvent { + enum MutationType: String, Codable { + case create + case update + case delete + } +} + +public extension MutationEvent.MutationType { + var graphQLMutationType: GraphQLMutationType { + switch self { + case .create: + return .create + case .update: + return .update + case .delete: + return .delete + } + } + + init(graphQLMutationType: GraphQLMutationType) { + switch graphQLMutationType { + case .create: + self = .create + case .update: + self = .update + case .delete: + self = .delete + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Subscribe/MutationEvent+Schema.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Subscribe/MutationEvent+Schema.swift new file mode 100644 index 0000000000..7f6f92af7a --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Subscribe/MutationEvent+Schema.swift @@ -0,0 +1,46 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +extension MutationEvent { + // MARK: - CodingKeys + + public enum CodingKeys: String, ModelKey { + case id + case modelId + case modelName + case json + case mutationType + case createdAt + case version + case inProcess + case graphQLFilterJSON + } + + public static let keys = CodingKeys.self + + // MARK: - ModelSchema + + public static let schema = defineSchema { definition in + let mutation = MutationEvent.keys + + definition.listPluralName = "MutationEvents" + definition.syncPluralName = "MutationEvents" + definition.attributes(.isSystem) + + definition.fields( + .id(), + .field(mutation.modelId, is: .required, ofType: .string), + .field(mutation.modelName, is: .required, ofType: .string), + .field(mutation.json, is: .required, ofType: .string), + .field(mutation.mutationType, is: .required, ofType: .string), + .field(mutation.createdAt, is: .required, ofType: .dateTime), + .field(mutation.version, is: .optional, ofType: .int), + .field(mutation.inProcess, is: .required, ofType: .bool), + .field(mutation.graphQLFilterJSON, is: .optional, ofType: .string) + ) + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Subscribe/MutationEvent.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Subscribe/MutationEvent.swift new file mode 100644 index 0000000000..988ba31994 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Subscribe/MutationEvent.swift @@ -0,0 +1,99 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public struct MutationEvent: Model { + public typealias EventIdentifier = String + public typealias ModelId = String + + public let id: EventIdentifier + public let modelId: ModelId + public var modelName: String + public var json: String + public var mutationType: String + public var createdAt: Temporal.DateTime + public var version: Int? + public var inProcess: Bool + public var graphQLFilterJSON: String? + + public init(id: EventIdentifier = UUID().uuidString, + modelId: ModelId, + modelName: String, + json: String, + mutationType: MutationType, + createdAt: Temporal.DateTime = .now(), + version: Int? = nil, + inProcess: Bool = false, + graphQLFilterJSON: String? = nil) { + self.id = id + self.modelId = modelId + self.modelName = modelName + self.json = json + self.mutationType = mutationType.rawValue + self.createdAt = createdAt + self.version = version + self.inProcess = inProcess + self.graphQLFilterJSON = graphQLFilterJSON + } + + public init(model: M, + modelSchema: ModelSchema, + mutationType: MutationType, + version: Int? = nil, + graphQLFilterJSON: String? = nil) throws { + let json = try model.toJSON() + self.init(modelId: model.identifier(schema: modelSchema).stringValue, + modelName: modelSchema.name, + json: json, + mutationType: mutationType, + version: version, + graphQLFilterJSON: graphQLFilterJSON) + + } + + @available(*, deprecated, message: """ + Initializing from a model without a ModelSchema is deprecated. + Use init(model:modelSchema:mutationType:version:graphQLFilterJSON:) instead. + """) + public init(model: M, + mutationType: MutationType, + version: Int? = nil, + graphQLFilterJSON: String? = nil) throws { + try self.init(model: model, + modelSchema: model.schema, + mutationType: mutationType, + version: version, + graphQLFilterJSON: graphQLFilterJSON) + + } + + public func decodeModel() throws -> Model { + let model = try ModelRegistry.decode(modelName: modelName, from: json) + return model + } + + /// Decodes the model instance from the mutation event. + public func decodeModel(as modelType: M.Type) throws -> M { + let model = try ModelRegistry.decode(modelName: modelName, from: json) + + guard let typedModel = model as? M else { + throw DataStoreError.decodingError( + "Could not create '\(modelType.modelName)' from model", + """ + Review the data in the JSON string below and ensure it doesn't contain invalid UTF8 data, and that \ + it is a valid \(modelType.modelName) instance: + + \(json) + """) + } + + return typedModel + } +} + +extension MutationEvent: Sendable { } diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/GeoCategory+ClientBehavior.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/GeoCategory+ClientBehavior.swift new file mode 100644 index 0000000000..b435e6931c --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/GeoCategory+ClientBehavior.swift @@ -0,0 +1,78 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +extension GeoCategory: GeoCategoryBehavior { + + // MARK: - Search + + /// Search for places or points of interest. + /// - Parameters: + /// - text: The place name or address to be used in the search. (case insensitive) + /// - options: Optional parameters when searching for text. + /// - Returns: + /// It returns a Geo.Place array. + /// - Throws: + /// `Geo.Error.accessDenied` if request authorization issue + /// `Geo.Error.serviceError` if service is down/resource not found/throttling/validation error + /// `Geo.Error.invalidConfiguration` if invalid configuration + /// `Geo.Error.networkError` if request failed or network unavailable + /// `Geo.Error.pluginError` if encapsulated error received by a dependent plugin + /// `Geo.Error.unknown` if error is unknown + public func search(for text: String, + options: Geo.SearchForTextOptions? = nil) async throws -> [Geo.Place] { + return try await plugin.search(for: text, options: options) + } + + /// Reverse geocodes a given pair of coordinates and returns a list of Places + /// closest to the specified position. + /// - Parameters: + /// - coordinates: Specifies a coordinate for the query. + /// - options: Optional parameters when searching for coordinates. + /// - Returns: + /// It returns a Geo.Place array. + /// - Throws: + /// `Geo.Error.accessDenied` if request authorization issue + /// `Geo.Error.serviceError` if service is down/resource not found/throttling/validation error + /// `Geo.Error.invalidConfiguration` if invalid configuration + /// `Geo.Error.networkError` if request failed or network unavailable + /// `Geo.Error.pluginError` if encapsulated error received by a dependent plugin + /// `Geo.Error.unknown` if error is unknown + public func search(for coordinates: Geo.Coordinates, + options: Geo.SearchForCoordinatesOptions? = nil) async throws -> [Geo.Place] { + return try await plugin.search(for: coordinates, options: options) + } + + // MARK: - Maps + + /// Retrieves metadata for available Map resources. + /// - Returns: + /// It returns an array of available Map resources. + /// - Throws: + /// `Geo.Error.accessDenied` if request authorization issue + /// `Geo.Error.serviceError` if service is down/resource not found/throttling/validation error + /// `Geo.Error.invalidConfiguration` if invalid configuration + /// `Geo.Error.networkError` if request failed or network unavailable + /// `Geo.Error.pluginError` if encapsulated error received by a dependent plugin + /// `Geo.Error.unknown` if error is unknown + public func availableMaps() async throws -> [Geo.MapStyle] { + return try await plugin.availableMaps() + } + + /// Retrieves metadata for the default Map resource. + /// - Returns: + /// It returns the default Map resource. + /// - Throws: + /// `Geo.Error.accessDenied` if request authorization issue + /// `Geo.Error.serviceError` if service is down/resource not found/throttling/validation error + /// `Geo.Error.invalidConfiguration` if invalid configuration + /// `Geo.Error.networkError` if request failed or network unavailable + /// `Geo.Error.pluginError` if encapsulated error received by a dependent plugin + /// `Geo.Error.unknown` if error is unknown + public func defaultMap() async throws -> Geo.MapStyle { + return try await plugin.defaultMap() + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/GeoCategory+HubPayloadEventName.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/GeoCategory+HubPayloadEventName.swift new file mode 100644 index 0000000000..bd4e57f81d --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/GeoCategory+HubPayloadEventName.swift @@ -0,0 +1,11 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +public extension HubPayload.EventName { + /// Geo hub events + struct Geo { } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/GeoCategory.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/GeoCategory.swift new file mode 100644 index 0000000000..aa9579a9bb --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/GeoCategory.swift @@ -0,0 +1,107 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// The Geo category enables you to interact with geospacial services. +final public class GeoCategory: Category { + /// Geo category type + public let categoryType = CategoryType.geo + + /// Geo category plugins + var plugins = [PluginKey: GeoCategoryPlugin]() + + /// Returns the plugin added to the category, if only one plugin is added. Accessing this property if no plugins + /// are added, or if more than one plugin is added, will cause a preconditionFailure. + var plugin: GeoCategoryPlugin { + guard isConfigured else { + return Fatal.preconditionFailure( + """ + \(categoryType.displayName) category is not configured. Call Amplify.configure() before using \ + any methods on the category. + """ + ) + } + + guard !plugins.isEmpty else { + return Fatal.preconditionFailure("No plugins added to \(categoryType.displayName) category.") + } + + guard plugins.count == 1, let plugin = plugins.first?.value else { + return Fatal.preconditionFailure( + """ + More than 1 plugin added to \(categoryType.displayName) category. \ + You must invoke operations on this category by getting the plugin you want, as in: + #"Amplify.\(categoryType.displayName).getPlugin(for: "ThePluginKey").foo() + """ + ) + } + + return plugin + } + + var isConfigured = false + + // MARK: - Plugin handling + + /// Adds `plugin` to the list of Plugins that implement functionality for this category. + /// + /// - Parameter plugin: The Plugin to add + public func add(plugin: GeoCategoryPlugin) throws { + log.debug("Adding plugin: \(String(describing: plugin))") + let key = plugin.key + guard !key.isEmpty else { + let pluginDescription = String(describing: plugin) + let error = Geo.Error.invalidConfiguration( + "Plugin \(pluginDescription) has an empty `key`.", + "Set the `key` property for \(String(describing: plugin))") + throw error + } + + guard !isConfigured else { + let pluginDescription = String(describing: plugin) + let error = ConfigurationError.amplifyAlreadyConfigured( + "\(pluginDescription) cannot be added after `Amplify.configure()`.", + "Do not add plugins after calling `Amplify.configure()`." + ) + throw error + } + + plugins[plugin.key] = plugin + } + + /// Returns the added plugin with the specified `key` property. + /// + /// - Parameter key: The PluginKey (String) of the plugin to retrieve + /// - Returns: The wrapped plugin + public func getPlugin(for key: PluginKey) throws -> GeoCategoryPlugin { + guard let plugin = plugins[key] else { + let keys = plugins.keys.joined(separator: ", ") + let error = Geo.Error.invalidConfiguration( + "No plugin has been added for '\(key)'.", + "Either add a plugin for '\(key)', or use one of the known keys: \(keys)") + throw error + } + return plugin + } + + /// Removes the plugin registered for `key` from the list of Plugins that implement functionality for this category. + /// If no plugin has been added for `key`, no action is taken, making this method safe to call multiple times. + /// + /// - Parameter key: The key used to `add` the plugin + public func removePlugin(for key: PluginKey) { + plugins.removeValue(forKey: key) + } + +} + +extension GeoCategory: DefaultLogger { + public static var log: Logger { + Amplify.Logging.logger(forCategory: CategoryType.geo.displayName, forNamespace: String(describing: self)) + } + public var log: Logger { + Self.log + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/GeoCategoryBehavior.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/GeoCategoryBehavior.swift new file mode 100644 index 0000000000..29fe16bf57 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/GeoCategoryBehavior.swift @@ -0,0 +1,73 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Behavior of the Geo category that clients will use +public protocol GeoCategoryBehavior { + + // MARK: - Search + + /// Search for places or points of interest. + /// - Parameters: + /// - text: The place name or address to be used in the search. (case insensitive) + /// - options: Optional parameters when searching for text. + /// - Returns: + /// It returns a Geo.Place array. + /// - Throws: + /// `Geo.Error.accessDenied` if request authorization issue + /// `Geo.Error.serviceError` if service is down/resource not found/throttling/validation error + /// `Geo.Error.invalidConfiguration` if invalid configuration + /// `Geo.Error.networkError` if request failed or network unavailable + /// `Geo.Error.pluginError` if encapsulated error received by a dependent plugin + /// `Geo.Error.unknown` if error is unknown + func search(for text: String, + options: Geo.SearchForTextOptions?) async throws -> [Geo.Place] + + /// Reverse geocodes a given pair of coordinates and returns a list of Places + /// closest to the specified position. + /// - Parameters: + /// - coordinates: Specifies a coordinate for the query. + /// - options: Optional parameters when searching for coordinates. + /// - Returns: + /// It returns a Geo.Place array. + /// - Throws: + /// `Geo.Error.accessDenied` if request authorization issue + /// `Geo.Error.serviceError` if service is down/resource not found/throttling/validation error + /// `Geo.Error.invalidConfiguration` if invalid configuration + /// `Geo.Error.networkError` if request failed or network unavailable + /// `Geo.Error.pluginError` if encapsulated error received by a dependent plugin + /// `Geo.Error.unknown` if error is unknown + func search(for coordinates: Geo.Coordinates, + options: Geo.SearchForCoordinatesOptions?) async throws -> [Geo.Place] + + // MARK: - Maps + + /// Retrieves metadata for available Map resources. + /// - Returns: + /// It returns an array of available Map resources. + /// - Throws: + /// `Geo.Error.accessDenied` if request authorization issue + /// `Geo.Error.serviceError` if service is down/resource not found/throttling/validation error + /// `Geo.Error.invalidConfiguration` if invalid configuration + /// `Geo.Error.networkError` if request failed or network unavailable + /// `Geo.Error.pluginError` if encapsulated error received by a dependent plugin + /// `Geo.Error.unknown` if error is unknown + func availableMaps() async throws -> [Geo.MapStyle] + + /// Retrieves metadata for the default Map resource. + /// - Returns: + /// It returns the default Map resource. + /// - Throws: + /// `Geo.Error.accessDenied` if request authorization issue + /// `Geo.Error.serviceError` if service is down/resource not found/throttling/validation error + /// `Geo.Error.invalidConfiguration` if invalid configuration + /// `Geo.Error.networkError` if request failed or network unavailable + /// `Geo.Error.pluginError` if encapsulated error received by a dependent plugin + /// `Geo.Error.unknown` if error is unknown + func defaultMap() async throws -> Geo.MapStyle +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/GeoCategoryConfiguration.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/GeoCategoryConfiguration.swift new file mode 100644 index 0000000000..1fa8073b75 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/GeoCategoryConfiguration.swift @@ -0,0 +1,21 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Geo category configuration +public struct GeoCategoryConfiguration: CategoryConfiguration { + + /// Dictionary of plugin keys to plugin configurations + public let plugins: [String: JSONValue] + + /// Initializer + /// - Parameter plugins: Plugin configuration dictionary + public init(plugins: [String: JSONValue] = [:]) { + self.plugins = plugins + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/GeoCategoryPlugin.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/GeoCategoryPlugin.swift new file mode 100644 index 0000000000..457085aaaa --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/GeoCategoryPlugin.swift @@ -0,0 +1,16 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// Geo category plugin +public protocol GeoCategoryPlugin: Plugin, GeoCategoryBehavior { } + +public extension GeoCategoryPlugin { + /// Geo category type + var categoryType: CategoryType { + return .geo + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/Internal/GeoCategory+CategoryConfigurable.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/Internal/GeoCategory+CategoryConfigurable.swift new file mode 100644 index 0000000000..ce233726dd --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/Internal/GeoCategory+CategoryConfigurable.swift @@ -0,0 +1,34 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +extension GeoCategory: CategoryConfigurable { + + func configure(using configuration: CategoryConfiguration?) throws { + guard !isConfigured else { + let error = ConfigurationError.amplifyAlreadyConfigured( + "\(categoryType.displayName) has already been configured.", + "Remove the duplicate call to `Amplify.configure()`" + ) + throw error + } + + try Amplify.configure(plugins: Array(plugins.values), using: configuration) + + isConfigured = true + } + + func configure(using amplifyConfiguration: AmplifyConfiguration) throws { + try configure(using: categoryConfiguration(from: amplifyConfiguration)) + } + + func configure(using amplifyOutputs: AmplifyOutputsData) throws { + for plugin in Array(plugins.values) { + try plugin.configure(using: amplifyOutputs) + } + isConfigured = true + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/Internal/GeoCategory+Resettable.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/Internal/GeoCategory+Resettable.swift new file mode 100644 index 0000000000..5ce00d12c7 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/Internal/GeoCategory+Resettable.swift @@ -0,0 +1,25 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension GeoCategory: Resettable { + + public func reset() async { + await withTaskGroup(of: Void.self) { taskGroup in + for plugin in plugins.values { + taskGroup.addTask { [weak self] in + self?.log.verbose("Resetting \(String(describing: self?.categoryType)) plugin") + await plugin.reset() + self?.log.verbose("Resetting \(String(describing: self?.categoryType)) plugin: finished") + } + } + await taskGroup.waitForAll() + } + isConfigured = false + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/Types/CLocationCoordinate2D+Geo.Coordinates.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/Types/CLocationCoordinate2D+Geo.Coordinates.swift new file mode 100644 index 0000000000..a67ce4c3a1 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/Types/CLocationCoordinate2D+Geo.Coordinates.swift @@ -0,0 +1,18 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import CoreLocation + +public extension CLLocationCoordinate2D { + /// Initialize a Location from a CLLocationCoordinate2D + /// - Parameter location: The CLLocationCoordinate2D to use to initialize the + /// Location. + init(_ coordinates: Geo.Coordinates) { + self.init(latitude: coordinates.latitude, longitude: coordinates.longitude) + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/Types/Geo+BoundingBox.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/Types/Geo+BoundingBox.swift new file mode 100644 index 0000000000..acb0a42bd7 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/Types/Geo+BoundingBox.swift @@ -0,0 +1,24 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public extension Geo { + /// A bounding box defined by southwest and northeast corners. + struct BoundingBox { + /// The southwest corner of the bounding box. + public let southwest: Geo.Coordinates + /// The northeast corner of the bounding box. + public let northeast: Geo.Coordinates + + /// Initializer + public init(southwest: Geo.Coordinates, northeast: Geo.Coordinates) { + self.southwest = southwest + self.northeast = northeast + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/Types/Geo+Coordinates.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/Types/Geo+Coordinates.swift new file mode 100644 index 0000000000..b374b42e4f --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/Types/Geo+Coordinates.swift @@ -0,0 +1,34 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import CoreLocation + +public extension Geo { + /// A pair of coordinates to represent a location (point). + struct Coordinates { + /// The latitude of the location. + public let latitude: Double + /// The longitude of the location. + public let longitude: Double + + /// Initializer + public init(latitude: Double, longitude: Double) { + self.latitude = latitude + self.longitude = longitude + } + } +} + +public extension Geo.Coordinates { + /// Initialize a Location from a CLLocationCoordinate2D + /// - Parameter location: The CLLocationCoordinate2D to use to initialize the + /// Location. + init(_ coordinates: CLLocationCoordinate2D) { + self.init(latitude: coordinates.latitude, longitude: coordinates.longitude) + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/Types/Geo+Country.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/Types/Geo+Country.swift new file mode 100644 index 0000000000..baa30d1457 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/Types/Geo+Country.swift @@ -0,0 +1,517 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public extension Geo { + /// Country codes for use with Amplify Geo. + struct Country { + public let code: String + public let description: String + } +} + +public extension Geo.Country { + /// Afghanistan + static let afg = Geo.Country(code: "AFG", description: "Afghanistan") + /// Åland Islands + static let ala = Geo.Country(code: "ALA", description: "Åland Islands") + /// Albania + static let alb = Geo.Country(code: "ALB", description: "Albania") + /// Algeria + static let dza = Geo.Country(code: "DZA", description: "Algeria") + /// American Samoa + static let asm = Geo.Country(code: "ASM", description: "American Samoa") + /// Andorra + static let and = Geo.Country(code: "AND", description: "Andorra") + /// Angola + static let ago = Geo.Country(code: "AGO", description: "Angola") + /// Anguilla + static let aia = Geo.Country(code: "AIA", description: "Anguilla") + /// Antarctica + static let ata = Geo.Country(code: "ATA", description: "Antarctica") + /// Antigua and Barbuda + static let atg = Geo.Country(code: "ATG", description: "Antigua and Barbuda") + /// Argentina + static let arg = Geo.Country(code: "ARG", description: "Argentina") + /// Armenia + static let arm = Geo.Country(code: "ARM", description: "Armenia") + /// Aruba + static let abw = Geo.Country(code: "ABW", description: "Aruba") + /// Australia + static let aus = Geo.Country(code: "AUS", description: "Australia") + /// Austria + static let aut = Geo.Country(code: "AUT", description: "Austria") + /// Azerbaijan + static let aze = Geo.Country(code: "AZE", description: "Azerbaijan") + /// Bahamas (the) + static let bhs = Geo.Country(code: "BHS", description: "Bahamas (the)") + /// Bahrain + static let bhr = Geo.Country(code: "BHR", description: "Bahrain") + /// Bangladesh + static let bgd = Geo.Country(code: "BGD", description: "Bangladesh") + /// Barbados + static let brb = Geo.Country(code: "BRB", description: "Barbados") + /// Belarus + static let blr = Geo.Country(code: "BLR", description: "Belarus") + /// Belgium + static let bel = Geo.Country(code: "BEL", description: "Belgium") + /// Belize + static let blz = Geo.Country(code: "BLZ", description: "Belize") + /// Benin + static let ben = Geo.Country(code: "BEN", description: "Benin") + /// Bermuda + static let bmu = Geo.Country(code: "BMU", description: "Bermuda") + /// Bhutan + static let btn = Geo.Country(code: "BTN", description: "Bhutan") + /// Bolivia (Plurinational State of) + static let bol = Geo.Country(code: "BOL", description: "Bolivia (Plurinational State of)") + /// Bonaire, Sint Eustatius and Saba + static let bes = Geo.Country(code: "BES", description: "Bonaire, Sint Eustatius and Saba") + /// Bosnia and Herzegovina + static let bih = Geo.Country(code: "BIH", description: "Bosnia and Herzegovina") + /// Botswana + static let bwa = Geo.Country(code: "BWA", description: "Botswana") + /// Bouvet Island + static let bvt = Geo.Country(code: "BVT", description: "Bouvet Island") + /// Brazil + static let bra = Geo.Country(code: "BRA", description: "Brazil") + /// British Indian Ocean Territory (the) + static let iot = Geo.Country(code: "IOT", description: "British Indian Ocean Territory (the)") + /// Brunei Darussalam + static let brn = Geo.Country(code: "BRN", description: "Brunei Darussalam") + /// Bulgaria + static let bgr = Geo.Country(code: "BGR", description: "Bulgaria") + /// Burkina Faso + static let bfa = Geo.Country(code: "BFA", description: "Burkina Faso") + /// Burundi + static let bdi = Geo.Country(code: "BDI", description: "Burundi") + /// Cabo Verde + static let cpv = Geo.Country(code: "CPV", description: "Cabo Verde") + /// Cambodia + static let khm = Geo.Country(code: "KHM", description: "Cambodia") + /// Cameroon + static let cmr = Geo.Country(code: "CMR", description: "Cameroon") + /// Canada + static let can = Geo.Country(code: "CAN", description: "Canada") + /// Cayman Islands (the) + static let cym = Geo.Country(code: "CYM", description: "Cayman Islands (the)") + /// Central African Republic (the) + static let caf = Geo.Country(code: "CAF", description: "Central African Republic (the)") + /// Chad + static let tcd = Geo.Country(code: "TCD", description: "Chad") + /// Chile + static let chl = Geo.Country(code: "CHL", description: "Chile") + /// China + static let chn = Geo.Country(code: "CHN", description: "China") + /// Christmas Island + static let cxr = Geo.Country(code: "CXR", description: "Christmas Island") + /// Cocos (Keeling) Islands (the) + static let cck = Geo.Country(code: "CCK", description: "Cocos (Keeling) Islands (the)") + /// Colombia + static let col = Geo.Country(code: "COL", description: "Colombia") + /// Comoros (the) + static let com = Geo.Country(code: "COM", description: "Comoros (the)") + /// Congo (the Democratic Republic of the) + static let cod = Geo.Country(code: "COD", description: "Congo (the Democratic Republic of the)") + /// Congo (the) + static let cog = Geo.Country(code: "COG", description: "Congo (the)") + /// Cook Islands (the) + static let cok = Geo.Country(code: "COK", description: "Cook Islands (the)") + /// Costa Rica + static let cri = Geo.Country(code: "CRI", description: "Costa Rica") + /// Croatia + static let hrv = Geo.Country(code: "HRV", description: "Croatia") + /// Cuba + static let cub = Geo.Country(code: "CUB", description: "Cuba") + /// Curaçao + static let cuw = Geo.Country(code: "CUW", description: "Curaçao") + /// Cyprus + static let cyp = Geo.Country(code: "CYP", description: "Cyprus") + /// Czechia + static let cze = Geo.Country(code: "CZE", description: "Czechia") + /// Côte d'Ivoire + static let civ = Geo.Country(code: "CIV", description: "Côte d'Ivoire") + /// Denmark + static let dnk = Geo.Country(code: "DNK", description: "Denmark") + /// Djibouti + static let dji = Geo.Country(code: "DJI", description: "Djibouti") + /// Dominica + static let dma = Geo.Country(code: "DMA", description: "Dominica") + /// Dominican Republic (the) + static let dom = Geo.Country(code: "DOM", description: "Dominican Republic (the)") + /// Ecuador + static let ecu = Geo.Country(code: "ECU", description: "Ecuador") + /// Egypt + static let egy = Geo.Country(code: "EGY", description: "Egypt") + /// El Salvador + static let slv = Geo.Country(code: "SLV", description: "El Salvador") + /// Equatorial Guinea + static let gnq = Geo.Country(code: "GNQ", description: "Equatorial Guinea") + /// Eritrea + static let eri = Geo.Country(code: "ERI", description: "Eritrea") + /// Estonia + static let est = Geo.Country(code: "EST", description: "Estonia") + /// Eswatini + static let swz = Geo.Country(code: "SWZ", description: "Eswatini") + /// Ethiopia + static let eth = Geo.Country(code: "ETH", description: "Ethiopia") + /// Falkland Islands (the) [Malvinas] + static let flk = Geo.Country(code: "FLK", description: "Falkland Islands (the) [Malvinas]") + /// Faroe Islands (the) + static let fro = Geo.Country(code: "FRO", description: "Faroe Islands (the)") + /// Fiji + static let fji = Geo.Country(code: "FJI", description: "Fiji") + /// Finland + static let fin = Geo.Country(code: "FIN", description: "Finland") + /// France + static let fra = Geo.Country(code: "FRA", description: "France") + /// French Guiana + static let guf = Geo.Country(code: "GUF", description: "French Guiana") + /// French Polynesia + static let pyf = Geo.Country(code: "PYF", description: "French Polynesia") + /// French Southern Territories (the) + static let atf = Geo.Country(code: "ATF", description: "French Southern Territories (the)") + /// Gabon + static let gab = Geo.Country(code: "GAB", description: "Gabon") + /// Gambia (the) + static let gmb = Geo.Country(code: "GMB", description: "Gambia (the)") + /// Georgia + static let geo = Geo.Country(code: "GEO", description: "Georgia") + /// Germany + static let deu = Geo.Country(code: "DEU", description: "Germany") + /// Ghana + static let gha = Geo.Country(code: "GHA", description: "Ghana") + /// Gibraltar + static let gib = Geo.Country(code: "GIB", description: "Gibraltar") + /// Greece + static let grc = Geo.Country(code: "GRC", description: "Greece") + /// Greenland + static let grl = Geo.Country(code: "GRL", description: "Greenland") + /// Grenada + static let grd = Geo.Country(code: "GRD", description: "Grenada") + /// Guadeloupe + static let glp = Geo.Country(code: "GLP", description: "Guadeloupe") + /// Guam + static let gum = Geo.Country(code: "GUM", description: "Guam") + /// Guatemala + static let gtm = Geo.Country(code: "GTM", description: "Guatemala") + /// Guernsey + static let ggy = Geo.Country(code: "GGY", description: "Guernsey") + /// Guinea + static let gin = Geo.Country(code: "GIN", description: "Guinea") + /// Guinea-Bissau + static let gnb = Geo.Country(code: "GNB", description: "Guinea-Bissau") + /// Guyana + static let guy = Geo.Country(code: "GUY", description: "Guyana") + /// Haiti + static let hti = Geo.Country(code: "HTI", description: "Haiti") + /// Heard Island and McDonald Islands + static let hmd = Geo.Country(code: "HMD", description: "Heard Island and McDonald Islands") + /// Holy See (the) + static let vat = Geo.Country(code: "VAT", description: "Holy See (the)") + /// Honduras + static let hnd = Geo.Country(code: "HND", description: "Honduras") + /// Hong Kong + static let hkg = Geo.Country(code: "HKG", description: "Hong Kong") + /// Hungary + static let hun = Geo.Country(code: "HUN", description: "Hungary") + /// Iceland + static let isl = Geo.Country(code: "ISL", description: "Iceland") + /// India + static let ind = Geo.Country(code: "IND", description: "India") + /// Indonesia + static let idn = Geo.Country(code: "IDN", description: "Indonesia") + /// Iran (Islamic Republic of) + static let irn = Geo.Country(code: "IRN", description: "Iran (Islamic Republic of)") + /// Iraq + static let irq = Geo.Country(code: "IRQ", description: "Iraq") + /// Ireland + static let irl = Geo.Country(code: "IRL", description: "Ireland") + /// Isle of Man + static let imn = Geo.Country(code: "IMN", description: "Isle of Man") + /// Israel + static let isr = Geo.Country(code: "ISR", description: "Israel") + /// Italy + static let ita = Geo.Country(code: "ITA", description: "Italy") + /// Jamaica + static let jam = Geo.Country(code: "JAM", description: "Jamaica") + /// Japan + static let jpn = Geo.Country(code: "JPN", description: "Japan") + /// Jersey + static let jey = Geo.Country(code: "JEY", description: "Jersey") + /// Jordan + static let jor = Geo.Country(code: "JOR", description: "Jordan") + /// Kazakhstan + static let kaz = Geo.Country(code: "KAZ", description: "Kazakhstan") + /// Kenya + static let ken = Geo.Country(code: "KEN", description: "Kenya") + /// Kiribati + static let kir = Geo.Country(code: "KIR", description: "Kiribati") + /// Korea (the Democratic People's Republic of) + static let prk = Geo.Country(code: "PRK", description: "Korea (the Democratic People's Republic of)") + /// Korea (the Republic of) + static let kor = Geo.Country(code: "KOR", description: "Korea (the Republic of)") + /// Kuwait + static let kwt = Geo.Country(code: "KWT", description: "Kuwait") + /// Kyrgyzstan + static let kgz = Geo.Country(code: "KGZ", description: "Kyrgyzstan") + /// Lao People's Democratic Republic (the) + static let lao = Geo.Country(code: "LAO", description: "Lao People's Democratic Republic (the)") + /// Latvia + static let lva = Geo.Country(code: "LVA", description: "Latvia") + /// Lebanon + static let lbn = Geo.Country(code: "LBN", description: "Lebanon") + /// Lesotho + static let lso = Geo.Country(code: "LSO", description: "Lesotho") + /// Liberia + static let lbr = Geo.Country(code: "LBR", description: "Liberia") + /// Libya + static let lby = Geo.Country(code: "LBY", description: "Libya") + /// Liechtenstein + static let lie = Geo.Country(code: "LIE", description: "Liechtenstein") + /// Lithuania + static let ltu = Geo.Country(code: "LTU", description: "Lithuania") + /// Luxembourg + static let lux = Geo.Country(code: "LUX", description: "Luxembourg") + /// Macao + static let mac = Geo.Country(code: "MAC", description: "Macao") + /// Madagascar + static let mdg = Geo.Country(code: "MDG", description: "Madagascar") + /// Malawi + static let mwi = Geo.Country(code: "MWI", description: "Malawi") + /// Malaysia + static let mys = Geo.Country(code: "MYS", description: "Malaysia") + /// Maldives + static let mdv = Geo.Country(code: "MDV", description: "Maldives") + /// Mali + static let mli = Geo.Country(code: "MLI", description: "Mali") + /// Malta + static let mlt = Geo.Country(code: "MLT", description: "Malta") + /// Marshall Islands (the) + static let mhl = Geo.Country(code: "MHL", description: "Marshall Islands (the)") + /// Martinique + static let mtq = Geo.Country(code: "MTQ", description: "Martinique") + /// Mauritania + static let mrt = Geo.Country(code: "MRT", description: "Mauritania") + /// Mauritius + static let mus = Geo.Country(code: "MUS", description: "Mauritius") + /// Mayotte + static let myt = Geo.Country(code: "MYT", description: "Mayotte") + /// Mexico + static let mex = Geo.Country(code: "MEX", description: "Mexico") + /// Micronesia (Federated States of) + static let fsm = Geo.Country(code: "FSM", description: "Micronesia (Federated States of)") + /// Moldova (the Republic of) + static let mda = Geo.Country(code: "MDA", description: "Moldova (the Republic of)") + /// Monaco + static let mco = Geo.Country(code: "MCO", description: "Monaco") + /// Mongolia + static let mng = Geo.Country(code: "MNG", description: "Mongolia") + /// Montenegro + static let mne = Geo.Country(code: "MNE", description: "Montenegro") + /// Montserrat + static let msr = Geo.Country(code: "MSR", description: "Montserrat") + /// Morocco + static let mar = Geo.Country(code: "MAR", description: "Morocco") + /// Mozambique + static let moz = Geo.Country(code: "MOZ", description: "Mozambique") + /// Myanmar + static let mmr = Geo.Country(code: "MMR", description: "Myanmar") + /// Namibia + static let nam = Geo.Country(code: "NAM", description: "Namibia") + /// Nauru + static let nru = Geo.Country(code: "NRU", description: "Nauru") + /// Nepal + static let npl = Geo.Country(code: "NPL", description: "Nepal") + /// Netherlands (the) + static let nld = Geo.Country(code: "NLD", description: "Netherlands (the)") + /// New Caledonia + static let ncl = Geo.Country(code: "NCL", description: "New Caledonia") + /// New Zealand + static let nzl = Geo.Country(code: "NZL", description: "New Zealand") + /// Nicaragua + static let nic = Geo.Country(code: "NIC", description: "Nicaragua") + /// Niger (the) + static let ner = Geo.Country(code: "NER", description: "Niger (the)") + /// Nigeria + static let nga = Geo.Country(code: "NGA", description: "Nigeria") + /// Niue + static let niu = Geo.Country(code: "NIU", description: "Niue") + /// Norfolk Island + static let nfk = Geo.Country(code: "NFK", description: "Norfolk Island") + /// Northern Mariana Islands (the) + static let mnp = Geo.Country(code: "MNP", description: "Northern Mariana Islands (the)") + /// Norway + static let nor = Geo.Country(code: "NOR", description: "Norway") + /// Oman + static let omn = Geo.Country(code: "OMN", description: "Oman") + /// Pakistan + static let pak = Geo.Country(code: "PAK", description: "Pakistan") + /// Palau + static let plw = Geo.Country(code: "PLW", description: "Palau") + /// Palestine, State of + static let pse = Geo.Country(code: "PSE", description: "Palestine, State of") + /// Panama + static let pan = Geo.Country(code: "PAN", description: "Panama") + /// Papua New Guinea + static let png = Geo.Country(code: "PNG", description: "Papua New Guinea") + /// Paraguay + static let pry = Geo.Country(code: "PRY", description: "Paraguay") + /// Peru + static let per = Geo.Country(code: "PER", description: "Peru") + /// Philippines (the) + static let phl = Geo.Country(code: "PHL", description: "Philippines (the)") + /// Pitcairn + static let pcn = Geo.Country(code: "PCN", description: "Pitcairn") + /// Poland + static let pol = Geo.Country(code: "POL", description: "Poland") + /// Portugal + static let prt = Geo.Country(code: "PRT", description: "Portugal") + /// Puerto Rico + static let pri = Geo.Country(code: "PRI", description: "Puerto Rico") + /// Qatar + static let qat = Geo.Country(code: "QAT", description: "Qatar") + /// Republic of North Macedonia + static let mkd = Geo.Country(code: "MKD", description: "Republic of North Macedonia") + /// Romania + static let rou = Geo.Country(code: "ROU", description: "Romania") + /// Russian Federation (the) + static let rus = Geo.Country(code: "RUS", description: "Russian Federation (the)") + /// Rwanda + static let rwa = Geo.Country(code: "RWA", description: "Rwanda") + /// Réunion + static let reu = Geo.Country(code: "REU", description: "Réunion") + /// Saint Barthélemy + static let blm = Geo.Country(code: "BLM", description: "Saint Barthélemy") + /// Saint Helena, Ascension and Tristan da Cunha + static let shn = Geo.Country(code: "SHN", description: "Saint Helena, Ascension and Tristan da Cunha") + /// Saint Kitts and Nevis + static let kna = Geo.Country(code: "KNA", description: "Saint Kitts and Nevis") + /// Saint Lucia + static let lca = Geo.Country(code: "LCA", description: "Saint Lucia") + /// Saint Martin (French part) + static let maf = Geo.Country(code: "MAF", description: "Saint Martin (French part)") + /// Saint Pierre and Miquelon + static let spm = Geo.Country(code: "SPM", description: "Saint Pierre and Miquelon") + /// Saint Vincent and the Grenadines + static let vct = Geo.Country(code: "VCT", description: "Saint Vincent and the Grenadines") + /// Samoa + static let wsm = Geo.Country(code: "WSM", description: "Samoa") + /// San Marino + static let smr = Geo.Country(code: "SMR", description: "San Marino") + /// Sao Tome and Principe + static let stp = Geo.Country(code: "STP", description: "Sao Tome and Principe") + /// Saudi Arabia + static let sau = Geo.Country(code: "SAU", description: "Saudi Arabia") + /// Senegal + static let sen = Geo.Country(code: "SEN", description: "Senegal") + /// Serbia + static let srb = Geo.Country(code: "SRB", description: "Serbia") + /// Seychelles + static let syc = Geo.Country(code: "SYC", description: "Seychelles") + /// Sierra Leone + static let sle = Geo.Country(code: "SLE", description: "Sierra Leone") + /// Singapore + static let sgp = Geo.Country(code: "SGP", description: "Singapore") + /// Sint Maarten (Dutch part) + static let sxm = Geo.Country(code: "SXM", description: "Sint Maarten (Dutch part)") + /// Slovakia + static let svk = Geo.Country(code: "SVK", description: "Slovakia") + /// Slovenia + static let svn = Geo.Country(code: "SVN", description: "Slovenia") + /// Solomon Islands + static let slb = Geo.Country(code: "SLB", description: "Solomon Islands") + /// Somalia + static let som = Geo.Country(code: "SOM", description: "Somalia") + /// South Africa + static let zaf = Geo.Country(code: "ZAF", description: "South Africa") + /// South Georgia and the South Sandwich Islands + static let sgs = Geo.Country(code: "SGS", description: "South Georgia and the South Sandwich Islands") + /// South Sudan + static let ssd = Geo.Country(code: "SSD", description: "South Sudan") + /// Spain + static let esp = Geo.Country(code: "ESP", description: "Spain") + /// Sri Lanka + static let lka = Geo.Country(code: "LKA", description: "Sri Lanka") + /// Sudan (the) + static let sdn = Geo.Country(code: "SDN", description: "Sudan (the)") + /// Suriname + static let sur = Geo.Country(code: "SUR", description: "Suriname") + /// Svalbard and Jan Mayen + static let sjm = Geo.Country(code: "SJM", description: "Svalbard and Jan Mayen") + /// Sweden + static let swe = Geo.Country(code: "SWE", description: "Sweden") + /// Switzerland + static let che = Geo.Country(code: "CHE", description: "Switzerland") + /// Syrian Arab Republic + static let syr = Geo.Country(code: "SYR", description: "Syrian Arab Republic") + /// Taiwan (Province of China) + static let twn = Geo.Country(code: "TWN", description: "Taiwan (Province of China)") + /// Tajikistan + static let tjk = Geo.Country(code: "TJK", description: "Tajikistan") + /// Tanzania, United Republic of + static let tza = Geo.Country(code: "TZA", description: "Tanzania, United Republic of") + /// Thailand + static let tha = Geo.Country(code: "THA", description: "Thailand") + /// Timor-Leste + static let tls = Geo.Country(code: "TLS", description: "Timor-Leste") + /// Togo + static let tgo = Geo.Country(code: "TGO", description: "Togo") + /// Tokelau + static let tkl = Geo.Country(code: "TKL", description: "Tokelau") + /// Tonga + static let ton = Geo.Country(code: "TON", description: "Tonga") + /// Trinidad and Tobago + static let tto = Geo.Country(code: "TTO", description: "Trinidad and Tobago") + /// Tunisia + static let tun = Geo.Country(code: "TUN", description: "Tunisia") + /// Turkey + static let tur = Geo.Country(code: "TUR", description: "Turkey") + /// Turkmenistan + static let tkm = Geo.Country(code: "TKM", description: "Turkmenistan") + /// Turks and Caicos Islands (the) + static let tca = Geo.Country(code: "TCA", description: "Turks and Caicos Islands (the)") + /// Tuvalu + static let tuv = Geo.Country(code: "TUV", description: "Tuvalu") + /// Uganda + static let uga = Geo.Country(code: "UGA", description: "Uganda") + /// Ukraine + static let ukr = Geo.Country(code: "UKR", description: "Ukraine") + /// United Arab Emirates (the) + static let are = Geo.Country(code: "ARE", description: "United Arab Emirates (the)") + /// United Kingdom of Great Britain and Northern Ireland (the) + static let gbr = Geo.Country(code: "GBR", description: "United Kingdom of Great Britain and Northern Ireland (the)") + /// United States Minor Outlying Islands (the) + static let umi = Geo.Country(code: "UMI", description: "United States Minor Outlying Islands (the)") + /// United States of America (the) + static let usa = Geo.Country(code: "USA", description: "United States of America (the)") + /// Uruguay + static let ury = Geo.Country(code: "URY", description: "Uruguay") + /// Uzbekistan + static let uzb = Geo.Country(code: "UZB", description: "Uzbekistan") + /// Vanuatu + static let vut = Geo.Country(code: "VUT", description: "Vanuatu") + /// Venezuela (Bolivarian Republic of) + static let ven = Geo.Country(code: "VEN", description: "Venezuela (Bolivarian Republic of)") + /// Viet Nam + static let vnm = Geo.Country(code: "VNM", description: "Viet Nam") + /// Virgin Islands (British) + static let vgb = Geo.Country(code: "VGB", description: "Virgin Islands (British)") + /// Virgin Islands (U.S.) + static let vir = Geo.Country(code: "VIR", description: "Virgin Islands (U.S.)") + /// Wallis and Futuna + static let wlf = Geo.Country(code: "WLF", description: "Wallis and Futuna") + /// Western Sahara + static let esh = Geo.Country(code: "ESH", description: "Western Sahara") + /// Yemen + static let yem = Geo.Country(code: "YEM", description: "Yemen") + /// Zambia + static let zmb = Geo.Country(code: "ZMB", description: "Zambia") + /// Zimbabwe + static let zwe = Geo.Country(code: "ZWE", description: "Zimbabwe") +} // swiftlint:disable:this file_length diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/Types/Geo+Error.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/Types/Geo+Error.swift new file mode 100644 index 0000000000..d7e6b81615 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/Types/Geo+Error.swift @@ -0,0 +1,83 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public extension Geo { + /// Geo Error + enum Error { + /// Invalid Configuration. + case invalidConfiguration(ErrorDescription, RecoverySuggestion, Swift.Error? = nil) + /// Network Error - Request failed or network unavailable. + case networkError(ErrorDescription, RecoverySuggestion, Swift.Error? = nil) + /// Access Denied - request authorization issue + case accessDenied(ErrorDescription, RecoverySuggestion, Swift.Error? = nil) + /// Service Error - Service may be down [500, 503] + case serviceError(ErrorDescription, RecoverySuggestion, Swift.Error? = nil) + /// Encapsulated error received by a dependent plugin + case pluginError(AmplifyError) + /// Unknown Error + case unknown(ErrorDescription, RecoverySuggestion, Swift.Error? = nil) + } +} + +extension Geo.Error: AmplifyError { + /// Initializer + public init(errorDescription: ErrorDescription = "An unknown error occurred", + recoverySuggestion: RecoverySuggestion = "See `underlyingError` for more details", + error: Error) { + if let error = error as? Self { + self = error + } else if error.isOperationCancelledError { + self = .unknown("Operation cancelled", "", error) + } else { + self = .unknown(errorDescription, recoverySuggestion, error) + } + } + + /// Error Description + public var errorDescription: ErrorDescription { + switch self { + case .invalidConfiguration(let errorDescription, _, _), + .networkError(let errorDescription, _, _), + .accessDenied(let errorDescription, _, _), + .serviceError(let errorDescription, _, _), + .unknown(let errorDescription, _, _): + return errorDescription + case .pluginError(let error): + return error.errorDescription + } + } + + /// Recovery Suggestion + public var recoverySuggestion: RecoverySuggestion { + switch self { + case .invalidConfiguration(_, let recoverySuggestion, _), + .networkError(_, let recoverySuggestion, _), + .accessDenied(_, let recoverySuggestion, _), + .serviceError(_, let recoverySuggestion, _), + .unknown(_, let recoverySuggestion, _): + return recoverySuggestion + case .pluginError(let error): + return error.recoverySuggestion + } + } + + /// Underlying Error + public var underlyingError: Error? { + switch self { + case .invalidConfiguration(_, _, let underlyingError), + .networkError(_, _, let underlyingError), + .accessDenied(_, _, let underlyingError), + .serviceError(_, _, let underlyingError), + .unknown(_, _, let underlyingError): + return underlyingError + case .pluginError(let error): + return error.underlyingError + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/Types/Geo+MapStyle.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/Types/Geo+MapStyle.swift new file mode 100644 index 0000000000..05d4ed00a7 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/Types/Geo+MapStyle.swift @@ -0,0 +1,27 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public extension Geo { + /// Identifies the name and style for a map resource. + struct MapStyle: Equatable { + /// The name of the map resource. + public let mapName: String + /// The map style selected from an available provider. + public let style: String + /// The URL to retrieve the style descriptor of the map resource. + public let styleURL: URL + + /// Initializer + public init(mapName: String, style: String, styleURL: URL) { + self.mapName = mapName + self.style = style + self.styleURL = styleURL + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/Types/Geo+Place.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/Types/Geo+Place.swift new file mode 100644 index 0000000000..18d6e4c2dd --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/Types/Geo+Place.swift @@ -0,0 +1,59 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public extension Geo { + /// A place defined by coordinates and optional additional locality information. + struct Place { + /// The coordinates of the place. (required) + public let coordinates: Geo.Coordinates + /// The full name and address of the place. + public let label: String? + /// The numerical portion of the address of the place, such as a building number. + public let addressNumber: String? + /// The name for the street or road of the place. For example, Main Street. + public let street: String? + /// The name of the local area of the place, such as a city or town name. For example, Toronto. + public let municipality: String? + /// The name of a community district. + public let neighborhood: String? + /// The name for the area or geographical division, such as a province or state + /// name, of the place. For example, British Columbia. + public let region: String? + /// An area that's part of a larger region for the place. For example, Metro Vancouver. + public let subRegion: String? + /// A group of numbers and letters in a country-specific format, which accompanies + /// the address for the purpose of identifying the place. + public let postalCode: String? + /// The country of the place. + public let country: String? + + /// Initializer + public init(coordinates: Coordinates, + label: String?, + addressNumber: String?, + street: String?, + municipality: String?, + neighborhood: String?, + region: String?, + subRegion: String?, + postalCode: String?, + country: String?) { + self.coordinates = coordinates + self.label = label + self.addressNumber = addressNumber + self.street = street + self.municipality = municipality + self.neighborhood = neighborhood + self.region = region + self.subRegion = subRegion + self.postalCode = postalCode + self.country = country + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/Types/Geo+ResultsHandler.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/Types/Geo+ResultsHandler.swift new file mode 100644 index 0000000000..fa83bc7467 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/Types/Geo+ResultsHandler.swift @@ -0,0 +1,13 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public extension Geo { + /// Results handler for Amplify Geo. + typealias ResultsHandler = (Result) -> Void +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/Types/Geo+SearchArea.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/Types/Geo+SearchArea.swift new file mode 100644 index 0000000000..c0a3b3c124 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/Types/Geo+SearchArea.swift @@ -0,0 +1,29 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import CoreLocation + +public extension Geo { + /// The area to search. + enum SearchArea { + /// Searches for results closest to the given coordinates. + case near(Geo.Coordinates) + /// Filters the results by returning only Places within the provided bounding box. + case within(Geo.BoundingBox) + } +} + +public extension Geo.SearchArea { + /// Creates a SearchArea that returns results closest to the given + /// CLLocationCoordinate2D. + /// - Parameter coordinates: The coordinates for the search area. + /// - Returns: The SearchArea. + static func near(_ coordinates: CLLocationCoordinate2D) -> Geo.SearchArea { + .near(Geo.Coordinates(coordinates)) + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/Types/Geo+SearchOptions.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/Types/Geo+SearchOptions.swift new file mode 100644 index 0000000000..88f683c5b9 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/Types/Geo+SearchOptions.swift @@ -0,0 +1,52 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public extension Geo { + /// Optional parameters when searching for text. + struct SearchForTextOptions { + /// The area (.near or .boundingBox) for the search. + public var area: Geo.SearchArea? + /// Limits the search to the given a list of countries/regions. + public var countries: [Geo.Country]? + /// The maximum number of results returned per request. + public var maxResults: Int? + /// Extra plugin-specific options, only used in special circumstances when the + /// existing options do not provide a way to utilize the underlying Geo plugin + /// functionality. See plugin documentation for expected key/values. + public var pluginOptions: Any? + + public init(area: Geo.SearchArea? = nil, + countries: [Geo.Country]? = nil, + maxResults: Int? = nil, + pluginOptions: Any? = nil) { + self.area = area + self.countries = countries + self.maxResults = maxResults + self.pluginOptions = pluginOptions + } + } +} + +public extension Geo { + /// Optional parameters when searching for coordinates. + struct SearchForCoordinatesOptions { + /// The maximum number of results returned per request. + public var maxResults: Int? + /// Extra plugin-specific options, only used in special circumstances when the + /// existing options do not provide a way to utilize the underlying Geo plugin + /// functionality. See plugin documentation for expected key/values. + public var pluginOptions: Any? + + public init(maxResults: Int? = nil, + pluginOptions: Any? = nil) { + self.maxResults = maxResults + self.pluginOptions = pluginOptions + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/Types/Geo.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/Types/Geo.swift new file mode 100644 index 0000000000..63a53f2761 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/Types/Geo.swift @@ -0,0 +1,11 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Namespace for GeoCategory Types +public enum Geo {} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Hub/HubCategory+ClientBehavior.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Hub/HubCategory+ClientBehavior.swift new file mode 100644 index 0000000000..14afd3581d --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Hub/HubCategory+ClientBehavior.swift @@ -0,0 +1,37 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +#if canImport(Combine) +import Combine +#endif + +extension HubCategory: HubCategoryBehavior { + public func dispatch(to channel: HubChannel, payload: HubPayload) { +#if canImport(Combine) + Amplify.Hub.subject(for: channel).send(payload) +#endif + plugin.dispatch(to: channel, payload: payload) + } + + public func listen(to channel: HubChannel, + eventName: HubPayloadEventName, + listener: @escaping HubListener) -> UnsubscribeToken { + plugin.listen(to: channel, eventName: eventName, listener: listener) + } + + public func listen(to channel: HubChannel, + isIncluded filter: HubFilter? = nil, + listener: @escaping HubListener) -> UnsubscribeToken { + plugin.listen(to: channel, isIncluded: filter, listener: listener) + } + + public func removeListener(_ token: UnsubscribeToken) { + plugin.removeListener(token) + } + +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Hub/HubCategory.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Hub/HubCategory.swift new file mode 100644 index 0000000000..d12e45323a --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Hub/HubCategory.swift @@ -0,0 +1,129 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// Amplify has a local eventing system called Hub. It is a lightweight +/// implementation of Publisher-Subscriber pattern, and is used to share data +/// between modules and components in your app. Hub enables different categories to +/// communicate with one another when specific events occur, such as authentication +/// events like a user sign-in or notification of a file download. +final public class HubCategory: Category { + + enum ConfigurationState { + /// Default configuration at initialization + case `default` + + /// After a custom plugin is added, but before `configure` was invoked + case pendingConfiguration + + /// After a custom plugin is added and `configure` is invoked + case configured + } + + public let categoryType = CategoryType.hub + + // Upon creation, the LoggingCategory will have a default plugin and a configuration state reflecting that. + // Customers can still add custom plugins; doing so will remove the default plugin. + var plugins: [PluginKey: HubCategoryPlugin] = [ + AWSHubPlugin.key: AWSHubPlugin() + ] + + var configurationState = AtomicValue(initialValue: .default) + + var isConfigured: Bool { + configurationState.get() != .pendingConfiguration + } + + /// Returns the plugin added to the category, if only one plugin is added. Accessing this property if no plugins + /// are added, or if more than one plugin is added, will cause a preconditionFailure. + var plugin: HubCategoryPlugin { + guard configurationState.get() != .pendingConfiguration else { + return Fatal.preconditionFailure( + """ + \(categoryType.displayName) category is not configured. Call Amplify.configure() before using \ + any methods on the category. + """ + ) + } + + guard !plugins.isEmpty else { + return Fatal.preconditionFailure("No plugins added to \(categoryType.displayName) category.") + } + + guard plugins.count == 1 else { + return Fatal.preconditionFailure( + """ + More than 1 plugin added to \(categoryType.displayName) category. \ + You must invoke operations on this category by getting the plugin you want, as in: + #"Amplify.\(categoryType.displayName).getPlugin(for: "ThePluginKey").foo() + """ + ) + } + + return plugins.first!.value + } + + // MARK: - Plugin handling + + /// Adds `plugin` to the list of Plugins that implement functionality for this category. + /// + /// The default plugin that is assigned at initialization will function without an explicit call to `configure`. + /// However, adding a plugin removes the default plugin, and will also cause the Hub category to + /// require `configure` be invoked before using any logging APIs. + /// + /// Note: It is a programmer error to use `Amplify.Hub` APIs during the initialization and configuration phases + /// of a custom Hub category plugin. + /// + /// - Parameter plugin: The Plugin to add + public func add(plugin: HubCategoryPlugin) throws { + if configurationState.get() == .default { + configurationState.set(.pendingConfiguration) + plugins[AWSHubPlugin.key] = nil + } + + let key = plugin.key + guard !key.isEmpty else { + let pluginDescription = String(describing: plugin) + let error = HubError.configuration("Plugin \(pluginDescription) has an empty `key`.", + "Set the `key` property for \(String(describing: plugin))") + throw error + } + + plugins[plugin.key] = plugin + } + + /// Returns the added plugin with the specified `key` property. + /// + /// - Parameter key: The PluginKey (String) of the plugin to retrieve + /// - Returns: The wrapped plugin + public func getPlugin(for key: PluginKey) throws -> HubCategoryPlugin { + guard let plugin = plugins[key] else { + let keys = plugins.keys.joined(separator: ", ") + let error = HubError.configuration("No plugin has been added for '\(key)'.", + "Either add a plugin for '\(key)', or use one of the known keys: \(keys)") + throw error + } + return plugin + } + + /// Removes the plugin registered for `key` from the list of Plugins that implement functionality for this category. + /// If no plugin has been added for `key`, no action is taken, making this method safe to call multiple times. + /// + /// - Parameter key: The key used to `add` the plugin + public func removePlugin(for key: PluginKey) { + plugins.removeValue(forKey: key) + } + +} + +extension HubCategory: DefaultLogger { + public static var log: Logger { + Amplify.Logging.logger(forCategory: CategoryType.hub.displayName, forNamespace: String(describing: self)) + } + public var log: Logger { + Self.log + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Hub/HubCategoryBehavior+Combine.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Hub/HubCategoryBehavior+Combine.swift new file mode 100644 index 0000000000..f3ae451dd5 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Hub/HubCategoryBehavior+Combine.swift @@ -0,0 +1,52 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#if canImport(Combine) +import Combine + +public typealias HubPublisher = AnyPublisher + +typealias HubSubject = PassthroughSubject + +/// Maintains a map of Subjects by Hub Channel. All downstream subscribers will +/// attach to the same Subject. +private struct HubSubjectMap { + static var `default` = HubSubjectMap() + var subjectsByChannel = AtomicValue<[HubChannel: HubSubject]>(initialValue: [:]) +} + +extension HubCategoryBehavior { + /// Returns a publisher for all Hub messages sent to `channel` + /// + /// - Parameter channel: The channel to listen for messages on + public func publisher(for channel: HubChannel) -> HubPublisher { + subject(for: channel).eraseToAnyPublisher() + } + + /// Returns a HubSubject for `channel` + /// + /// - Parameter channel: the channel to retrieve the subject + /// - Returns: a HubSubject used to send events on `channel` + func subject(for channel: HubChannel) -> HubSubject { + var sharedSubject: HubSubject! + + HubSubjectMap.default.subjectsByChannel.with { subjects in + if let subject = subjects[channel] { + sharedSubject = subject + return + } + + let subject = PassthroughSubject() + subjects[channel] = subject + sharedSubject = subject + } + + return sharedSubject + } + +} +#endif diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Hub/HubCategoryBehavior.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Hub/HubCategoryBehavior.swift new file mode 100644 index 0000000000..45b577f670 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Hub/HubCategoryBehavior.swift @@ -0,0 +1,43 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Convenience typealias defining a closure that can be used to listen to Hub messages +public typealias HubListener = (HubPayload) -> Void + +/// Behavior of the Hub category that clients will use +public protocol HubCategoryBehavior { + + /// Dispatch a Hub message on the specified channel + /// - Parameter channel: The channel to send the message on + /// - Parameter payload: The payload to send + func dispatch(to channel: HubChannel, payload: HubPayload) + + /// Listen to Hub messages with a particular event name on a particular channel + /// + /// - Parameter channel: The channel to listen for messages on + /// - Parameter eventName: Only hub payloads with this event name will be dispatched to the listener + /// - Parameter listener: The closure to invoke with the received message + func listen(to channel: HubChannel, + eventName: HubPayloadEventName, + listener: @escaping HubListener) -> UnsubscribeToken + + /// Listen to Hub messages on a particular channel, optionally filtering message prior to dispatching them + /// + /// - Parameter channel: The channel to listen for messages on + /// - Parameter filter: If specified, candidate messages will be passed to this closure prior to dispatching to + /// the `listener`. Only messages for which the filter returns `true` will be dispatched. + /// - Parameter listener: The closure to invoke with the received message + func listen(to channel: HubChannel, + isIncluded filter: HubFilter?, + listener: @escaping HubListener) -> UnsubscribeToken + + /// Removes the listener identified by `token` + /// - Parameter token: The UnsubscribeToken returned by `listen` + func removeListener(_ token: UnsubscribeToken) +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Hub/HubCategoryConfiguration.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Hub/HubCategoryConfiguration.swift new file mode 100644 index 0000000000..b4fab49bcc --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Hub/HubCategoryConfiguration.swift @@ -0,0 +1,16 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public struct HubCategoryConfiguration: CategoryConfiguration { + public let plugins: [String: JSONValue] + + public init(plugins: [String: JSONValue] = [:]) { + self.plugins = plugins + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Hub/HubCategoryPlugin.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Hub/HubCategoryPlugin.swift new file mode 100644 index 0000000000..5c1242bd23 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Hub/HubCategoryPlugin.swift @@ -0,0 +1,14 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +public protocol HubCategoryPlugin: Plugin, HubCategoryBehavior { } + +public extension HubCategoryPlugin { + var categoryType: CategoryType { + return .hub + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Hub/HubChannel.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Hub/HubChannel.swift new file mode 100644 index 0000000000..0979ac95c3 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Hub/HubChannel.swift @@ -0,0 +1,105 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// HubChannel represents the channels on which Amplify category messages will be dispatched. Apps can define their own +/// channels for intra-app communication. Internally, Amplify uses the Hub for dispatching notifications about events +/// associated with different categories. +public enum HubChannel { + + case analytics + + case api + + case auth + + case dataStore + + case geo + + case hub + + case logging + + case predictions + + case pushNotifications + + case storage + + case custom(String) + + /// Convenience property to return an array of all non-`custom` channels + static var amplifyChannels: [HubChannel] = { + let categoryChannels = CategoryType + .allCases + .sorted { $0.displayName < $1.displayName } + .map { HubChannel(from: $0) } + .compactMap { $0 } + + return categoryChannels + }() +} + +extension HubChannel: Equatable { + public static func == (lhs: HubChannel, rhs: HubChannel) -> Bool { + switch (lhs, rhs) { + case (.analytics, .analytics): + return true + case (.api, .api): + return true + case (.auth, .auth): + return true + case (.dataStore, .dataStore): + return true + case (.geo, .geo): + return true + case (.hub, .hub): + return true + case (.logging, .logging): + return true + case (.predictions, .predictions): + return true + case (.pushNotifications, .pushNotifications): + return true + case (.storage, .storage): + return true + case (.custom(let lhsValue), .custom(let rhsValue)): + return lhsValue == rhsValue + default: + return false + } + } +} + +extension HubChannel { + public init(from categoryType: CategoryType) { + switch categoryType { + case .analytics: + self = .analytics + case .api: + self = .api + case .auth: + self = .auth + case .dataStore: + self = .dataStore + case .geo: + self = .geo + case .hub: + self = .hub + case .logging: + self = .logging + case .predictions: + self = .predictions + case .pushNotifications: + self = .pushNotifications + case .storage: + self = .storage + } + } +} + +extension HubChannel: Hashable { } diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Hub/HubError.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Hub/HubError.swift new file mode 100644 index 0000000000..2e892573d3 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Hub/HubError.swift @@ -0,0 +1,51 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// Errors associated with configuring and inspecting Amplify Categories +public enum HubError { + case configuration(ErrorDescription, RecoverySuggestion, Error? = nil) + case unknownError(ErrorDescription, RecoverySuggestion, Error? = nil) +} + +extension HubError: AmplifyError { + public var errorDescription: ErrorDescription { + switch self { + case .configuration(let description, _, _), + .unknownError(let description, _, _): + return description + } + } + + public var recoverySuggestion: RecoverySuggestion { + switch self { + case .configuration(_, let recoverySuggestion, _), + .unknownError(_, let recoverySuggestion, _): + return recoverySuggestion + } + } + + public var underlyingError: Error? { + switch self { + case .configuration(_, _, let underlyingError), + .unknownError(_, _, let underlyingError): + return underlyingError + } + } + + public init( + errorDescription: ErrorDescription = "An unknown error occurred", + recoverySuggestion: RecoverySuggestion = "See `underlyingError` for more details", + error: Error + ) { + if let error = error as? Self { + self = error + } else { + self = .unknownError(errorDescription, recoverySuggestion, error) + } + } + +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Hub/HubFilter.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Hub/HubFilter.swift new file mode 100644 index 0000000000..afaff5ba77 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Hub/HubFilter.swift @@ -0,0 +1,55 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Convenience typealias defining a closure that can be used to filter Hub messages +public typealias HubFilter = (HubPayload) -> Bool + +/// Convenience filters for common filtering use cases +public struct HubFilters { + + /// True if all filters evaluate to true + public static func all(filters: HubFilter...) -> HubFilter { + let filter: HubFilter = { payload -> Bool in + return filters.allSatisfy { $0(payload) } + } + return filter + } + + /// True if any of the filters evaluate to true + public static func any(filters: HubFilter...) -> HubFilter { + let filter: HubFilter = { payload -> Bool in + return filters.contains { $0(payload) } + } + return filter + } + + /// Returns a HubFilter that is `true` if the event's `context` property has a UUID that matches `operation.id` + /// - Parameter operation: The operation to match + public static func forOperation + (_ operation: AmplifyOperation) -> HubFilter { + let operationId = operation.id + let filter: HubFilter = { payload in + guard let context = payload.context as? AmplifyOperationContext else { + return false + } + + return context.operationId == operationId + } + + return filter + } + + public static func forEventName(_ eventName: HubPayloadEventName) -> HubFilter { + let filter: HubFilter = { payload in + payload.eventName == eventName + } + return filter + } + +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Hub/HubPayload.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Hub/HubPayload.swift new file mode 100644 index 0000000000..3c89b0d73c --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Hub/HubPayload.swift @@ -0,0 +1,38 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// The payload of a Hub message +public struct HubPayload { + + /// Event names registered by Amplify Operations + public struct EventName {} + + /// The name, tag, or grouping of the HubPayload. Recommended to be a small string without spaces, + /// such as `signIn` or `hang_up`. For AmplifyOperations, this will be a concatenation of the category display name + /// and a short name of the operation type, as in "Storage.getURL" or "Storage.downloadFile". + public let eventName: HubPayloadEventName + + /// A structure used to pass the source, or context, of the HubPayload. For HubPayloads that are + /// generated from AmplifyOperations, this field will be the Operation's associated AmplifyOperationContext. + public let context: Any? + + /// A freeform structure used to pass objects or custom data. For HubPayloads that are generated from + /// AmplifyOperations, this field will be the Operation's associated OperationResult. + public let data: Any? + + public init(eventName: String, + context: Any? = nil, + data: Any? = nil) { + self.eventName = eventName + self.context = context + self.data = data + } +} + +protocol HubPayloadEventNameConvertible { + var hubEventName: String { get } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Hub/HubPayloadEventName.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Hub/HubPayloadEventName.swift new file mode 100644 index 0000000000..1cc36ac7bd --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Hub/HubPayloadEventName.swift @@ -0,0 +1,12 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +public typealias HubPayloadEventName = String + +public protocol HubPayloadEventNameable { + var eventName: HubPayloadEventName { get } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Hub/Internal/HubCategory+CategoryConfigurable.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Hub/Internal/HubCategory+CategoryConfigurable.swift new file mode 100644 index 0000000000..878abf30de --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Hub/Internal/HubCategory+CategoryConfigurable.swift @@ -0,0 +1,45 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +extension HubCategory: CategoryConfigurable { + + /// Configures the HubCategory using the incoming CategoryConfiguration. If the incoming configuration does not + /// specify a Hub plugin, then we will inject the AWSHubPlugin. + func configure(using configuration: CategoryConfiguration?) throws { + guard configurationState.get() != .configured else { + let error = ConfigurationError.amplifyAlreadyConfigured( + "\(categoryType.displayName) has already been configured.", + "Remove the duplicate call to `Amplify.configure()`" + ) + throw error + } + + try Amplify.configure(plugins: Array(plugins.values), using: configuration) + + configurationState.set(.configured) + } + + func configure(using amplifyConfiguration: AmplifyConfiguration) throws { + try configure(using: categoryConfiguration(from: amplifyConfiguration)) + } + + func configure(using amplifyOutputs: AmplifyOutputsData) throws { + guard configurationState.get() != .configured else { + let error = ConfigurationError.amplifyAlreadyConfigured( + "\(categoryType.displayName) has already been configured.", + "Remove the duplicate call to `Amplify.configure()`" + ) + throw error + } + + for plugin in Array(plugins.values) { + try plugin.configure(using: amplifyOutputs) + } + configurationState.set(.configured) + } + +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Hub/Internal/HubCategory+Resettable.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Hub/Internal/HubCategory+Resettable.swift new file mode 100644 index 0000000000..6cc890261c --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Hub/Internal/HubCategory+Resettable.swift @@ -0,0 +1,25 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension HubCategory: Resettable { + + public func reset() async { + await withTaskGroup(of: Void.self) { taskGroup in + for plugin in plugins.values { + taskGroup.addTask { [weak self] in + self?.log.verbose("Resetting \(String(describing: self?.categoryType)) plugin") + await plugin.reset() + self?.log.verbose("Resetting \(String(describing: self?.categoryType)) plugin: finished") + } + } + await taskGroup.waitForAll() + } + configurationState.set(.pendingConfiguration) + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Hub/UnsubscribeToken.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Hub/UnsubscribeToken.swift new file mode 100644 index 0000000000..0cda0ac73b --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Hub/UnsubscribeToken.swift @@ -0,0 +1,31 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Convenience typealias defining a closure that can be used to unsubscribe a Hub listener. Although UnsubscribeToken +/// conforms to Hashable, only the `id` property is considered for equality and hash value; `channel` is used only for +/// routing an unsubscribe request to the correct HubChannel. +public struct UnsubscribeToken { + let channel: HubChannel + let id: UUID + + public init(channel: HubChannel, id: UUID) { + self.channel = channel + self.id = id + } +} + +extension UnsubscribeToken: Hashable { + public static func == (lhs: UnsubscribeToken, rhs: UnsubscribeToken) -> Bool { + return lhs.id == rhs.id + } + + public func hash(into hasher: inout Hasher) { + return id.hash(into: &hasher) + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Logging/DefaultLogger.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Logging/DefaultLogger.swift new file mode 100644 index 0000000000..62eb56b50b --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Logging/DefaultLogger.swift @@ -0,0 +1,22 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// Defines a `log` convenience property, and provides a default implementation that returns a Logger for a category +/// name of `String(describing: self)` +public protocol DefaultLogger { + static var log: Logger { get } + var log: Logger { get } +} + +public extension DefaultLogger { + static var log: Logger { + Amplify.Logging.logger(forCategory: String(describing: self)) + } + var log: Logger { + type(of: self).log + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Logging/Internal/BroadcastLogger.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Logging/Internal/BroadcastLogger.swift new file mode 100644 index 0000000000..849c93533b --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Logging/Internal/BroadcastLogger.swift @@ -0,0 +1,69 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// As its name suggests, the `BroadcastLogger` class acts as a layer of +/// indirection that conforms to Amplify's [Logger](x-source-tag://Logger) +/// protocol that delegates all work to its targets. +/// +/// - Tag: BroadcastLogger +final class BroadcastLogger { + + /// The default LogLevel used when no targets are available. + /// + /// - Tag: LogProxy.defaultLogLevel + var defaultLogLevel: Amplify.LogLevel = .error + + private let targets: [Logger] + + /// - Tag: BroadcastLogger.init + init(targets: [Logger]) { + self.targets = targets + } + +} + +extension BroadcastLogger: Logger { + + var logLevel: Amplify.LogLevel { + get { + if let logger = targets.first { + return logger.logLevel + } + return defaultLogLevel + } + set(newValue) { + for logger in targets { + var updated = logger + updated.logLevel = newValue + } + } + } + + func error(_ message: @autoclosure () -> String) { + targets.forEach { $0.error(message()) } + } + + func error(error: Error) { + targets.forEach { $0.error(error: error) } + } + + func warn(_ message: @autoclosure () -> String) { + targets.forEach { $0.warn(message()) } + } + + func info(_ message: @autoclosure () -> String) { + targets.forEach { $0.info(message()) } + } + + func debug(_ message: @autoclosure () -> String) { + targets.forEach { $0.debug(message()) } + } + + func verbose(_ message: @autoclosure () -> String) { + targets.forEach { $0.verbose(message()) } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Logging/Internal/LoggingCategory+CategoryConfigurable.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Logging/Internal/LoggingCategory+CategoryConfigurable.swift new file mode 100644 index 0000000000..67a9c69899 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Logging/Internal/LoggingCategory+CategoryConfigurable.swift @@ -0,0 +1,68 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +extension LoggingCategory: CategoryConfigurable { + + /// Configures the LoggingCategory using the incoming CategoryConfiguration. + func configure(using configuration: CategoryConfiguration?) throws { + let plugin: LoggingCategoryPlugin + switch configurationState { + case .default: + // Default plugin is already assigned, and no configuration is applicable, exit early + configurationState = .configured + return + case .pendingConfiguration(let pendingPlugin): + plugin = pendingPlugin + case .configured: + let error = ConfigurationError.amplifyAlreadyConfigured( + "\(categoryType.displayName) has already been configured.", + "Remove the duplicate call to `Amplify.configure()`" + ) + throw error + } + + try plugin.configure(using: configuration?.plugins[plugin.key]) + self.plugins[plugin.key] = plugin + + if plugin.key != AWSUnifiedLoggingPlugin.key, let consolePlugin = try? self.getPlugin(for: AWSUnifiedLoggingPlugin.key) { + try consolePlugin.configure(using: configuration?.plugins[consolePlugin.key]) + } + + configurationState = .configured + } + + func configure(using amplifyConfiguration: AmplifyConfiguration) throws { + try configure(using: categoryConfiguration(from: amplifyConfiguration)) + } + + func configure(using amplifyOutputs: AmplifyOutputsData) throws { + let plugin: LoggingCategoryPlugin + switch configurationState { + case .default: + // Default plugin is already assigned, and no configuration is applicable, exit early + configurationState = .configured + return + case .pendingConfiguration(let pendingPlugin): + plugin = pendingPlugin + case .configured: + let error = ConfigurationError.amplifyAlreadyConfigured( + "\(categoryType.displayName) has already been configured.", + "Remove the duplicate call to `Amplify.configure()`" + ) + throw error + } + + try plugin.configure(using: amplifyOutputs) + self.plugins[plugin.key] = plugin + + if plugin.key != AWSUnifiedLoggingPlugin.key, let consolePlugin = try? self.getPlugin(for: AWSUnifiedLoggingPlugin.key) { + try consolePlugin.configure(using: amplifyOutputs) + } + + configurationState = .configured + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Logging/Internal/LoggingCategory+Resettable.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Logging/Internal/LoggingCategory+Resettable.swift new file mode 100644 index 0000000000..1c5487f8e0 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Logging/Internal/LoggingCategory+Resettable.swift @@ -0,0 +1,20 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension LoggingCategory: Resettable { + + public func reset() async { + log.verbose("Resetting \(categoryType) plugin") + for (_, plugin) in plugins { + await plugin.reset() + } + log.verbose("Resetting \(categoryType) plugin: finished") + configurationState = .default + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Logging/LogLevel.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Logging/LogLevel.swift new file mode 100644 index 0000000000..cee8acfff4 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Logging/LogLevel.swift @@ -0,0 +1,55 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// Log levels are modeled as Ints to allow for easy comparison of levels +/// +public extension Amplify { + enum LogLevel: Int, Codable { + case error + case warn + case info + case debug + case verbose + case none + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let rawString = try? container.decode(String.self).lowercased() { + switch rawString { + case "error": + self = .error + case "warn": + self = .warn + case "info": + self = .info + case "debug": + self = .debug + case "verbose": + self = .verbose + case "none": + self = .none + default: + let context = DecodingError.Context(codingPath: [], debugDescription: "No matching LogLevel found") + throw DecodingError.valueNotFound(LogLevel.self, context) + } + } else if let rawInt = try? container.decode(Int.self), let value = LogLevel(rawValue: rawInt) { + self = value + } else { + let context = DecodingError.Context(codingPath: [], debugDescription: "Unable to decode LogLevel") + throw DecodingError.dataCorrupted(context) + } + + } + } +} +public typealias LogLevel = Amplify.LogLevel + +extension LogLevel: Comparable { + public static func < (lhs: LogLevel, rhs: LogLevel) -> Bool { + lhs.rawValue < rhs.rawValue + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Logging/LoggingCategory+ClientBehavior.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Logging/LoggingCategory+ClientBehavior.swift new file mode 100644 index 0000000000..082b38ee67 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Logging/LoggingCategory+ClientBehavior.swift @@ -0,0 +1,69 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +extension LoggingCategory: LoggingCategoryClientBehavior { + public var `default`: Logger { + var targets = [Logger]() + for plugin in plugins.values { + targets.append(plugin.default) + } + return BroadcastLogger(targets: targets) + } + + public func logger(forCategory category: String) -> Logger { + var targets = [Logger]() + for plugin in plugins.values { + targets.append(plugin.logger(forCategory: category)) + } + return BroadcastLogger(targets: targets) + } + + public func logger(forCategory category: CategoryType) -> Logger { + var targets = [Logger]() + for plugin in plugins.values { + targets.append(plugin.logger(forCategory: category.displayName)) + } + return BroadcastLogger(targets: targets) + } + + public func logger(forCategory category: String, logLevel: LogLevel) -> Logger { + var targets = [Logger]() + for plugin in plugins.values { + targets.append(plugin.logger(forCategory: category, logLevel: logLevel)) + } + return BroadcastLogger(targets: targets) + } + + public func enable() { + for plugin in plugins.values { + plugin.enable() + } + } + + public func disable() { + for plugin in plugins.values { + plugin.disable() + } + } + + public func logger(forNamespace namespace: String) -> Logger { + var targets = [Logger]() + for plugin in plugins.values { + targets.append(plugin.logger(forNamespace: namespace)) + } + return BroadcastLogger(targets: targets) + + } + + public func logger(forCategory category: String, forNamespace namespace: String) -> Logger { + var targets = [Logger]() + for plugin in plugins.values { + targets.append(plugin.logger(forCategory: category, forNamespace: namespace)) + } + return BroadcastLogger(targets: targets) + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Logging/LoggingCategory+HubPayloadEventName.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Logging/LoggingCategory+HubPayloadEventName.swift new file mode 100644 index 0000000000..7a3cec2cfe --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Logging/LoggingCategory+HubPayloadEventName.swift @@ -0,0 +1,16 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +public extension HubPayload.EventName { + /// Logging hub events + struct Logging { } +} + +public extension HubPayload.EventName.Logging { + static let writeLogFailure = "Logging.writeLogFailure" + static let flushLogFailure = "Logging.flushLogFailure" +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Logging/LoggingCategory+Logger.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Logging/LoggingCategory+Logger.swift new file mode 100644 index 0000000000..29f1e95c16 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Logging/LoggingCategory+Logger.swift @@ -0,0 +1,53 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +extension LoggingCategory: Logger { + + /// Logs a message at `error` level + public func error(_ message: @autoclosure () -> String) { + for (_, plugin) in plugins { + plugin.default.error(message()) + } + } + + /// Logs the error at `error` level + public func error(error: Error) { + for (_, plugin) in plugins { + plugin.default.error(error: error) + } + } + + /// Logs a message at `warn` level + public func warn(_ message: @autoclosure () -> String) { + for (_, plugin) in plugins { + plugin.default.warn(message()) + } + } + + /// Logs a message at `info` level + public func info(_ message: @autoclosure () -> String) { + for (_, plugin) in plugins { + plugin.default.info(message()) + } + } + + /// Logs a message at `debug` level + public func debug(_ message: @autoclosure () -> String) { + for (_, plugin) in plugins { + plugin.default.debug(message()) + } + } + + /// Logs a message at `verbose` level + public func verbose(_ message: @autoclosure () -> String) { + for (_, plugin) in plugins { + plugin.default.verbose(message()) + } + + } + +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Logging/LoggingCategory.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Logging/LoggingCategory.swift new file mode 100644 index 0000000000..b1dbd0e159 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Logging/LoggingCategory.swift @@ -0,0 +1,121 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// AWS Amplify writes console logs through Logger. You can use Logger in your apps for the same purpose. +final public class LoggingCategory: Category { + enum ConfigurationState { + /// Default configuration at initialization + case `default` + + /// After a custom plugin is added, but before `configure` was invoked + case pendingConfiguration(LoggingCategoryPlugin) + + /// After a custom plugin is added and `configure` is invoked + case configured + } + + let lock: NSLocking = NSLock() + + public let categoryType = CategoryType.logging + + private var _logLevel = LogLevel.error + + /// The global logLevel. Messages logged at a priority less than or equal to this value will be logged (e.g., if + /// `logLevel` is set to `.info`, then messages sent at `.error`, `.warn`, and `.info` will be logged, but messages + /// at `.debug` and `.verbose` will not be logged. The global log level is also used for the default logger + /// e.g., `Amplify.Log.debug("debug message")`. Defaults to `.error`. + /// + /// Developers can override log levels per log category, as in + /// ``` + /// let viewLogger = Amplify.Logging.logger(forCategory: "views", level: .error) + /// let networkLogger = Amplify.Logging.logger(forCategory: "network", level: .info) + /// + /// viewLogger.info("A view loaded") // Will not be logged + /// networkLogger.info("A network operation started") // Will be logged + /// ``` + public var logLevel: LogLevel { + get { + lock.execute { + _logLevel + } + } + set { + lock.execute { + _logLevel = newValue + } + } + } + + var configurationState = ConfigurationState.default + + /// For any external cases, Logging is always ready to be used. Internal configuration state is tracked via a + /// different mechanism + var isConfigured: Bool { + return !plugins.isEmpty + } + + var plugins: [PluginKey: LoggingCategoryPlugin] = Amplify.getLoggingCategoryPluginLookup(loggingPlugin: AWSUnifiedLoggingPlugin()) + // MARK: - Plugin handling + + /// Sets `plugin` as the sole provider of functionality for this category. **Note: this is different from other + /// category behaviors, which allow multiple plugins to be used to implement functionality.** + /// + /// The default plugin that is assigned at initialization will function without an explicit call to `configure`. + /// However, adding a plugin will cause the Logging category to require `configure` be invoked, and will remove the + /// default-configured Logging plugin during configuration. The result is, during initialization and configuration, + /// calls to any Logging APIs will be handled by the default plugin, until after `configure` is invoked on logging, + /// at which point calls will be handled by the plugin. + /// + /// Code that invokes Logging APIs should not cache references to the logger, since the underlying plugin may be + /// disposed between calls. Instead, use the `Amplify.Logging.logger(for:)` method to get a logger for the specified + /// tag. + /// + /// - Parameter plugin: The Plugin to add + public func add(plugin: LoggingCategoryPlugin) throws { + let key = plugin.key + guard !key.isEmpty else { + let pluginDescription = String(describing: plugin) + let error = LoggingError.configuration("Plugin \(pluginDescription) has an empty `key`.", + "Set the `key` property for \(String(describing: plugin))") + throw error + } + + configurationState = .pendingConfiguration(Amplify.getLoggingCategoryPlugin(loggingPlugin: plugin)) + } + + /// Returns the added plugin with the specified `key` property. + /// + /// - Parameter key: The PluginKey (String) of the plugin to retrieve + /// - Returns: The wrapped plugin + public func getPlugin(for key: PluginKey) throws -> LoggingCategoryPlugin { + guard let plugin = plugins.first(where: { $0.key == key})?.value else { + let error = LoggingError.configuration("No plugin has been added for '\(key)'.", + "Either add a plugin for '\(key)', or use the installed plugin, which has the key '\(key)'") + throw error + } + return plugin + } + + /// Removes the current plugin if its key property matches the provided `key`, and reinstalls the default plugin. If + /// the key property of the current plugin is not `key`, takes no action. + /// + /// - Parameter key: The key used to `add` the plugin + public func removePlugin(for key: PluginKey) { + plugins.removeValue(forKey: key) + } +} + +extension LoggingCategory: DefaultLogger { + public static var log: Logger { + Amplify.Logging.logger(forCategory: CategoryType.logging.displayName, forNamespace: String(describing: self)) + } + public var log: Logger { + Self.log + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Logging/LoggingCategoryClientBehavior.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Logging/LoggingCategoryClientBehavior.swift new file mode 100644 index 0000000000..44a089635f --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Logging/LoggingCategoryClientBehavior.swift @@ -0,0 +1,54 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +public protocol LoggingCategoryClientBehavior { + /// A reference to the `default` logger accessed by method calls directly to `Amplify.Logging`, as in + /// `Amplify.Logging.debug("message")`. Default loggers must use the Logging category's `logLevel`. + var `default`: Logger { get } + + /// Returns a category-specific logger at the specified level. + func logger(forCategory category: String, logLevel: LogLevel) -> Logger + + /// Returns a category-specific logger. Defaults to using `Amplify.Logging.logLevel`. + func logger(forCategory category: String) -> Logger + + /// enable plugin + func enable() + + /// disable plugin + func disable() + + /// adding namespace to match Android implementation + func logger(forNamespace namespace: String) -> Logger + + /// new api to support category and namespace + func logger(forCategory category: String, forNamespace namespace: String) -> Logger +} + +public protocol Logger { + + /// The log level of the logger. + var logLevel: LogLevel { get set } + + /// Logs a message at `error` level + func error(_ message: @autoclosure () -> String) + + /// Logs the error at `error` level + func error(error: Error) + + /// Logs a message at `warn` level + func warn(_ message: @autoclosure () -> String) + + /// Logs a message at `info` level + func info(_ message: @autoclosure () -> String) + + /// Logs a message at `debug` level + func debug(_ message: @autoclosure () -> String) + + /// Logs a message at `verbose` level + func verbose(_ message: @autoclosure () -> String) +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Logging/LoggingCategoryConfiguration.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Logging/LoggingCategoryConfiguration.swift new file mode 100644 index 0000000000..b30019abec --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Logging/LoggingCategoryConfiguration.swift @@ -0,0 +1,16 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public struct LoggingCategoryConfiguration: CategoryConfiguration { + public let plugins: [String: JSONValue] + + public init(plugins: [String: JSONValue] = [:]) { + self.plugins = plugins + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Logging/LoggingCategoryPlugin.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Logging/LoggingCategoryPlugin.swift new file mode 100644 index 0000000000..6506db4cde --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Logging/LoggingCategoryPlugin.swift @@ -0,0 +1,15 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// The behavior that all LoggingPlugins provide +public protocol LoggingCategoryPlugin: Plugin, LoggingCategoryClientBehavior { } + +public extension LoggingCategoryPlugin { + var categoryType: CategoryType { + return .logging + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Logging/LoggingError.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Logging/LoggingError.swift new file mode 100644 index 0000000000..047249ab4e --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Logging/LoggingError.swift @@ -0,0 +1,52 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +public enum LoggingError { + case configuration(ErrorDescription, RecoverySuggestion, Error? = nil) + case unknown(ErrorDescription, Error?) +} + +extension LoggingError: AmplifyError { + public var errorDescription: ErrorDescription { + switch self { + case .configuration(let errorDescription, _, _): + return errorDescription + case .unknown(let errorDescription, _): + return "Unexpected error occurred with message: \(errorDescription)" + } + } + + public var recoverySuggestion: RecoverySuggestion { + switch self { + case .configuration(_, let recoverySuggestion, _): + return recoverySuggestion + case .unknown: + return AmplifyErrorMessages.shouldNotHappenReportBugToAWS() + } + } + + public var underlyingError: Error? { + switch self { + case .configuration(_, _, let underlyingError), + .unknown(_, let underlyingError): + return underlyingError + } + } + + public init( + errorDescription: ErrorDescription = "An unknown error occurred", + recoverySuggestion: RecoverySuggestion = "(Ignored)", + error: Error + ) { + if let error = error as? Self { + self = error + } else { + self = .unknown(errorDescription, error) + } + } + +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Notifications/Notifications.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Notifications/Notifications.swift new file mode 100644 index 0000000000..d067f66ff3 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Notifications/Notifications.swift @@ -0,0 +1,10 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public enum Notifications {} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Notifications/NotificationsCategory+HubPayloadEventName.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Notifications/NotificationsCategory+HubPayloadEventName.swift new file mode 100644 index 0000000000..2b6e791a20 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Notifications/NotificationsCategory+HubPayloadEventName.swift @@ -0,0 +1,11 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +public extension HubPayload.EventName { + /// Notifications hub events + struct Notifications {} +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Notifications/NotificationsCategory.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Notifications/NotificationsCategory.swift new file mode 100644 index 0000000000..02c4122b6f --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Notifications/NotificationsCategory.swift @@ -0,0 +1,24 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// The Notifications parent category +public final class NotificationsCategory { + + /// The Push Notifications category + public internal(set) var Push = PushNotificationsCategory() // swiftlint:disable:this identifier_name + + /// The current available subcategories that have been configured + var subcategories: [NotificationsSubcategoryBehaviour] { + let allSubcategories = [ + Push + ] + + return allSubcategories.filter { $0.isConfigured } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Notifications/NotificationsCategoryConfiguration.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Notifications/NotificationsCategoryConfiguration.swift new file mode 100644 index 0000000000..7d6da59e62 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Notifications/NotificationsCategoryConfiguration.swift @@ -0,0 +1,20 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// The configuration for the Notifications category +public struct NotificationsCategoryConfiguration: CategoryConfiguration { + /// Plugins + public let plugins: [String: JSONValue] + + /// Initializer + /// - Parameter plugins: Plugins + public init(plugins: [String: JSONValue] = [:]) { + self.plugins = plugins + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Notifications/NotificationsSubcategoryBehaviour.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Notifications/NotificationsSubcategoryBehaviour.swift new file mode 100644 index 0000000000..8922be476b --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Notifications/NotificationsSubcategoryBehaviour.swift @@ -0,0 +1,11 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Defines the behaviour of a Notifications subcategory +public protocol NotificationsSubcategoryBehaviour {} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Notifications/PushNotifications/Error/PushNotificationsError.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Notifications/PushNotifications/Error/PushNotificationsError.swift new file mode 100644 index 0000000000..8f7694c69d --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Notifications/PushNotifications/Error/PushNotificationsError.swift @@ -0,0 +1,68 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Push Notifications Error +public enum PushNotificationsError { + /// Configuration Error + case configuration(ErrorDescription, RecoverySuggestion, Error? = nil) + /// Network Error + case network(ErrorDescription, RecoverySuggestion, Error? = nil) + /// Service Error + case service(ErrorDescription, RecoverySuggestion, Error? = nil) + /// Unknown Error + case unknown(ErrorDescription, Error? = nil) +} + +extension PushNotificationsError: AmplifyError { + public var errorDescription: ErrorDescription { + switch self { + case .configuration(let description, _, _), + .network(let description, _, _), + .service(let description, _, _): + return description + case .unknown(let description, _): + return "Unexpected error occurred with message: \(description)" + } + } + + public var recoverySuggestion: RecoverySuggestion { + switch self { + case .configuration(_, let recoverySuggestion, _), + .network(_, let recoverySuggestion, _), + .service(_, let recoverySuggestion, _): + return recoverySuggestion + case .unknown: + return AmplifyErrorMessages.shouldNotHappenReportBugToAWS() + } + } + + public var underlyingError: Error? { + switch self { + case .configuration(_, _, let error), + .network(_, _, let error), + .service(_, _, let error), + .unknown(_, let error): + return error + } + } + + public init( + errorDescription: ErrorDescription = "An unknown error occurred", + recoverySuggestion: RecoverySuggestion = "(Ignored)", + error: Error + ) { + if let error = error as? Self { + self = error + } else if error.isOperationCancelledError { + self = .unknown("Operation cancelled", error) + } else { + self = .unknown(errorDescription, error) + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Notifications/PushNotifications/Internal/PushNotificationsCategory+CategoryConfigurable.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Notifications/PushNotifications/Internal/PushNotificationsCategory+CategoryConfigurable.swift new file mode 100644 index 0000000000..59d80d72aa --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Notifications/PushNotifications/Internal/PushNotificationsCategory+CategoryConfigurable.swift @@ -0,0 +1,33 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +extension PushNotificationsCategory: CategoryConfigurable { + func configure(using configuration: CategoryConfiguration?) throws { + guard !isConfigured else { + let error = ConfigurationError.amplifyAlreadyConfigured( + "\(categoryType.displayName) has already been configured.", + "Remove the duplicate call to `Amplify.configure()`" + ) + throw error + } + + try Amplify.configure(plugins: Array(plugins.values), using: configuration) + + isConfigured = true + } + + func configure(using amplifyConfiguration: AmplifyConfiguration) throws { + try configure(using: categoryConfiguration(from: amplifyConfiguration)) + } + + func configure(using amplifyOutputs: AmplifyOutputsData) throws { + for plugin in Array(plugins.values) { + try plugin.configure(using: amplifyOutputs) + } + isConfigured = true + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Notifications/PushNotifications/Internal/PushNotificationsCategory+Resettable.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Notifications/PushNotifications/Internal/PushNotificationsCategory+Resettable.swift new file mode 100644 index 0000000000..ee084ed0b6 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Notifications/PushNotifications/Internal/PushNotificationsCategory+Resettable.swift @@ -0,0 +1,24 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension PushNotificationsCategory: Resettable { + public func reset() async { + await withTaskGroup(of: Void.self) { taskGroup in + for plugin in plugins.values { + taskGroup.addTask { [weak self] in + self?.log.verbose("Resetting \(String(describing: self?.categoryType)) plugin") + await plugin.reset() + self?.log.verbose("Resetting \(String(describing: self?.categoryType)) plugin: finished") + } + } + await taskGroup.waitForAll() + } + isConfigured = false + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Notifications/PushNotifications/PushNotificationsCategory+ClientBehaviour.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Notifications/PushNotifications/PushNotificationsCategory+ClientBehaviour.swift new file mode 100644 index 0000000000..4eff36dc84 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Notifications/PushNotifications/PushNotificationsCategory+ClientBehaviour.swift @@ -0,0 +1,29 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import UserNotifications + +extension PushNotificationsCategory: PushNotificationsCategoryBehaviour { + public func identifyUser(userId: String, userProfile: UserProfile? = nil) async throws { + try await plugin.identifyUser(userId: userId, userProfile: userProfile) + } + + public func registerDevice(apnsToken: Data) async throws { + try await plugin.registerDevice(apnsToken: apnsToken) + } + + public func recordNotificationReceived(_ userInfo: Notifications.Push.UserInfo) async throws { + try await plugin.recordNotificationReceived(userInfo) + } + +#if !os(tvOS) + public func recordNotificationOpened(_ response: UNNotificationResponse) async throws { + try await plugin.recordNotificationOpened(response) + } +#endif +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Notifications/PushNotifications/PushNotificationsCategory+HubPayloadEventName.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Notifications/PushNotifications/PushNotificationsCategory+HubPayloadEventName.swift new file mode 100644 index 0000000000..6e725eb70f --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Notifications/PushNotifications/PushNotificationsCategory+HubPayloadEventName.swift @@ -0,0 +1,16 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +public extension HubPayload.EventName.Notifications { + /// Push Notifications hub events + struct Push { } +} + +public extension HubPayload.EventName.Notifications.Push { + /// Event triggered when notifications permissions are requested to the user + static let requestNotificationsPermissions = "Notifications.Push.requestNotificationsPermissions" +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Notifications/PushNotifications/PushNotificationsCategory.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Notifications/PushNotifications/PushNotificationsCategory.swift new file mode 100644 index 0000000000..2b81b046d7 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Notifications/PushNotifications/PushNotificationsCategory.swift @@ -0,0 +1,105 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// The Push Notifications category allows you to receive and report push notifications. +public final class PushNotificationsCategory: Category { + /// Push Notifications category type + public let categoryType = CategoryType.pushNotifications + + var plugins = [PluginKey: PushNotificationsCategoryPlugin]() + + /// Returns the plugin added to the category, if only one plugin is added. Accessing this property if no plugins + /// are added, or if more than one plugin is added, will cause a preconditionFailure. + var plugin: PushNotificationsCategoryPlugin { + guard isConfigured else { + return Fatal.preconditionFailure( + """ + \(categoryType.displayName) category is not configured. Call Amplify.configure() before using \ + any methods on the category. + """ + ) + } + + guard !plugins.isEmpty else { + return Fatal.preconditionFailure("No plugins added to \(categoryType.displayName) category.") + } + + guard plugins.count == 1, let plugin = plugins.first?.value else { + return Fatal.preconditionFailure( + """ + More than 1 plugin added to \(categoryType.displayName) category. \ + You must invoke operations on this category by getting the plugin you want, as in: + #"Amplify.\(categoryType.displayName).getPlugin(for: "ThePluginKey").foo() + """ + ) + } + + return plugin + } + + var isConfigured = false + + // MARK: - Plugin handling + + /// Adds `plugin` to the list of Plugins that implement functionality for this category. + /// + /// - Parameter plugin: The Plugin to add + public func add(plugin: PushNotificationsCategoryPlugin) throws { + log.debug("Adding plugin: \(String(describing: plugin))") + let key = plugin.key + guard !key.isEmpty else { + let pluginDescription = String(describing: plugin) + let error = PushNotificationsError.configuration( + "Plugin \(pluginDescription) has an empty `key`.", + "Set the `key` property for \(String(describing: plugin))") + throw error + } + + guard !isConfigured else { + let pluginDescription = String(describing: plugin) + let error = ConfigurationError.amplifyAlreadyConfigured( + "\(pluginDescription) cannot be added after `Amplify.configure()`.", + "Do not add plugins after calling `Amplify.configure()`." + ) + throw error + } + + plugins[plugin.key] = plugin + } + + /// Returns the added plugin with the specified `key` property. + /// + /// - Parameter key: The PluginKey (String) of the plugin to retrieve + /// - Returns: The wrapped plugin + public func getPlugin(for key: PluginKey) throws -> PushNotificationsCategoryPlugin { + guard let plugin = plugins[key] else { + let keys = plugins.keys.joined(separator: ", ") + let error = PushNotificationsError.configuration( + "No plugin has been added for '\(key)'.", + "Either add a plugin for '\(key)', or use one of the known keys: \(keys)") + throw error + } + return plugin + } + + /// Removes the plugin registered for `key` from the list of Plugins that implement functionality for this category. + /// If no plugin has been added for `key`, no action is taken, making this method safe to call multiple times. + /// + /// - Parameter key: The key used to `add` the plugin + public func removePlugin(for key: PluginKey) { + plugins.removeValue(forKey: key) + } +} + +extension PushNotificationsCategory: DefaultLogger { + public static var log: Logger { + Amplify.Logging.logger(forCategory: CategoryType.pushNotifications.displayName, forNamespace: String(describing: self)) + } + public var log: Logger { + Self.log + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Notifications/PushNotifications/PushNotificationsCategoryBehaviour.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Notifications/PushNotifications/PushNotificationsCategoryBehaviour.swift new file mode 100644 index 0000000000..9349ea33f8 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Notifications/PushNotifications/PushNotificationsCategoryBehaviour.swift @@ -0,0 +1,35 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import UserNotifications + +/// Defines the behaviour of the Push Notifications category that clients will use +public protocol PushNotificationsCategoryBehaviour: NotificationsSubcategoryBehaviour { + /// Associates a given user ID with the current device + /// + /// - Parameter userId: The unique identifier for the user + /// - Parameter userProfile: Additional specific data for the user + func identifyUser(userId: String, userProfile: UserProfile?) async throws + + /// Registers the given APNs token for this device, allowing it to receive Push Notifications + /// + /// - Parameter apnsToken: A globally unique token that identifies this device to APNs + func registerDevice(apnsToken: Data) async throws + + /// Records that a notification has been received. + /// + /// - Parameter userInfo: A dictionary that contains information related to the remote notification + func recordNotificationReceived(_ userInfo: Notifications.Push.UserInfo) async throws + +#if !os(tvOS) + /// Records that a notification was opened, i.e. the user tapped on it + /// + /// - Parameter response: The user’s response to the notification + func recordNotificationOpened(_ response: UNNotificationResponse) async throws +#endif +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Notifications/PushNotifications/PushNotificationsCategoryPlugin.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Notifications/PushNotifications/PushNotificationsCategoryPlugin.swift new file mode 100644 index 0000000000..c1fce041b5 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Notifications/PushNotifications/PushNotificationsCategoryPlugin.swift @@ -0,0 +1,15 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// Defines a plugin that implements the behaviour of the Push Notifications category +public protocol PushNotificationsCategoryPlugin: Plugin, PushNotificationsCategoryBehaviour { } + +public extension PushNotificationsCategoryPlugin { + var categoryType: CategoryType { + return .pushNotifications + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Notifications/PushNotifications/Types/PushNotificationsCategory+Types.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Notifications/PushNotifications/Types/PushNotificationsCategory+Types.swift new file mode 100644 index 0000000000..5f2cf0966f --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Notifications/PushNotifications/Types/PushNotificationsCategory+Types.swift @@ -0,0 +1,12 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension Notifications { + public enum Push {} +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Notifications/PushNotifications/Types/PushNotificationsCategory+UserInfo.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Notifications/PushNotifications/Types/PushNotificationsCategory+UserInfo.swift new file mode 100644 index 0000000000..4f513bdfbf --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Notifications/PushNotifications/Types/PushNotificationsCategory+UserInfo.swift @@ -0,0 +1,18 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension Notifications.Push { + #if canImport(UIKit) + /// A dictionary that contains information related to the remote notification + public typealias UserInfo = [AnyHashable: Any] + #elseif canImport(AppKit) + /// A dictionary that contains information related to the remote notification + public typealias UserInfo = [String: Any] + #endif +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Error/PredictionsError+ClientError.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Error/PredictionsError+ClientError.swift new file mode 100644 index 0000000000..d801ad26ff --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Error/PredictionsError+ClientError.swift @@ -0,0 +1,78 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension PredictionsError { + public struct ClientError: Equatable { + public static func == (lhs: PredictionsError.ClientError, rhs: PredictionsError.ClientError) -> Bool { + lhs.description == rhs.description + && lhs.recoverySuggestion == rhs.recoverySuggestion + } + + public let description: ErrorDescription + public let recoverySuggestion: RecoverySuggestion + public let underlyingError: Error? + + public init( + description: ErrorDescription, + recoverySuggestion: RecoverySuggestion, + underlyingError: Error? = nil + ) { + self.description = description + self.recoverySuggestion = recoverySuggestion + self.underlyingError = underlyingError + } + } +} + +extension PredictionsError.ClientError { + public static let imageNotFound = Self( + description: "Something was wrong with the image file, make sure it exists.", + recoverySuggestion: "Try choosing an image and sending it again." + ) + + public static let invalidRegion = Self( + description: "Invalid region", + recoverySuggestion: "Ensure that you provide a valid region in your configuration" + ) + + public static let missingSourceLanguage = Self( + description: "Source language is not provided", + recoverySuggestion: "Provide a supported source language" + ) + + public static let missingTargetLanguage = Self( + description: "Target language is not provided", + recoverySuggestion: "Provide a supported target language" + ) + + public static let onlineIdentityServiceUnavailable = Self( + description: "Online identify service is not available", + recoverySuggestion: "Please check if the values are proprely initialized" + ) + + public static let offlineIdentityServiceUnavailable = Self( + description: "Offline identify service is not available", + recoverySuggestion: "Please check if the values are proprely initialized" + ) + + public static let onlineInterpretServiceUnavailable = Self( + description: "Online interpret service is not available", + recoverySuggestion: "Please check if the values are proprely initialized" + ) + + public static let offlineInterpretServiceUnavailable = Self( + description: "Offline interpret service is not available", + recoverySuggestion: "Please check if the values are proprely initialized" + ) + + public static let unableToInterpretText = Self( + description: "No result found for the text", + recoverySuggestion: "Interpret text did not produce any result" + ) +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Error/PredictionsError+ServiceError.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Error/PredictionsError+ServiceError.swift new file mode 100644 index 0000000000..173705b27d --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Error/PredictionsError+ServiceError.swift @@ -0,0 +1,105 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension PredictionsError { + public struct ServiceError: Equatable { + public static func == (lhs: PredictionsError.ServiceError, rhs: PredictionsError.ServiceError) -> Bool { + lhs.description == rhs.description + && lhs.recoverySuggestion == rhs.recoverySuggestion + } + + public let description: ErrorDescription + public let recoverySuggestion: RecoverySuggestion + public let httpStatusCode: Int? + public let underlyingError: Error? + + public init( + description: ErrorDescription, + recoverySuggestion: RecoverySuggestion, + httpStatusCode: Int? = nil, + underlyingError: Error? = nil + ) { + self.description = description + self.recoverySuggestion = recoverySuggestion + self.httpStatusCode = httpStatusCode + self.underlyingError = underlyingError + } + } +} + +extension PredictionsError.ServiceError { + public static let translationFailed = Self( + description: "No result was found.", + recoverySuggestion: """ + Please make sure a text string was sent over and + that the target language was different from the language sent. + """ + ) + + public static let internalServerError = Self( + description: "An internal server error occurred.", + recoverySuggestion: """ + This shouldn't never happen. There is a possibility that there is a bug if this error persists. + Please take a look at https://github.com/aws-amplify/amplify-ios/issues to see if there are any + existing issues that match your scenario, and file an issue with the details of the bug if there isn't. + """ + ) + + public static let detectedLanguageLowConfidence = Self( + description: "A language was detected but with very low confidence.", + recoverySuggestion: "Please make sure you use one of the available languages." + ) + + public static let invalidRequest = Self( + description: "An invalid request was sent.", + recoverySuggestion: "Please check your request and try again." + ) + + public static let resourceNotFound = Self( + description: "The specified resource doesn't exist.", + recoverySuggestion: "Please make sure you configured the resource properly." + ) + + public static let textSizeLimitExceeded = Self( + description: "The size of the text string exceeded the limit.", + recoverySuggestion: "Please send a shorter text string." + ) + + public static let unsupportedLanguagePair = Self( + description: "Your target language and source language are an unsupported language pair.", + recoverySuggestion: """ + Please ensure the service supports translating from the specified source + language to the specified target language. + """ + ) + + public static let throttling = Self( + description: "Your rate of request increase is too fast.", + recoverySuggestion: "Slow down your request rate and gradually increase it." + ) + + public static let unsupportedLanguage = Self( + description: "The language specified is not currently supported by the service.", + recoverySuggestion: "Choose a new language that is supported." + ) + + public static let invalidSampleRate = Self( + description: "The specified sample rate is not valid.", + recoverySuggestion: "" + ) + + public static let accessDenied = Self( + description: "Access denied", + recoverySuggestion: """ + Please check that your Cognito IAM role has permissions + to access this service and check to make sure the user + is authenticated properly. + """ + ) +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Error/PredictionsError.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Error/PredictionsError.swift new file mode 100644 index 0000000000..76a0e23c5b --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Error/PredictionsError.swift @@ -0,0 +1,64 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Error occured while using Prediction category +public enum PredictionsError { + /// Access denied while executing the operation + case client(ClientError) + case service(ServiceError) + case unknown(ErrorDescription, RecoverySuggestion, Error? = nil) +} + +extension PredictionsError: AmplifyError { + public var errorDescription: ErrorDescription { + switch self { + case .client(let clientError): + return "A client error occurred with message:\(clientError.description)" + case .service(let serviceError): + return "A service error occurred with message:\(serviceError.description)" + case .unknown(let errorDescription, _, _): + return "Unexpected error occurred with message: \(errorDescription)" + } + + } + + public var recoverySuggestion: RecoverySuggestion { + switch self { + case .client(let clientError): + return clientError.recoverySuggestion + case .service(let serviceError): + return serviceError.recoverySuggestion + case .unknown: + return AmplifyErrorMessages.shouldNotHappenReportBugToAWS() + } + } + + public var underlyingError: Error? { + switch self { + case .client(let clientError): + return clientError.underlyingError + case .service(let serviceError): + return serviceError.underlyingError + case .unknown(_, _, let underlyingError): + return underlyingError + } + } + + public init( + errorDescription: ErrorDescription = "An unknown error occurred", + recoverySuggestion: RecoverySuggestion = "See `underlyingError` for more details", + error: Error + ) { + if let error = error as? Self { + self = error + } else { + self = .unknown(errorDescription, recoverySuggestion, error) + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Internal/DefaultNetworkPolicy.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Internal/DefaultNetworkPolicy.swift new file mode 100644 index 0000000000..946274606b --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Internal/DefaultNetworkPolicy.swift @@ -0,0 +1,24 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// DefaultNetworkPolicy of the operation +public enum DefaultNetworkPolicy { + /// `offline` operations do not make network calls. + /// + /// Specificy `offline` if you only want to leverage CoreML local procession. + /// - Important: Some functionality isn't available in CoreML. Invoking `offline` + /// in these cases will result in an error. + case offline + + /// `online` operations only invoke network requests to the applicable services. + case online + + /// `auto` operations make use of online and offline calls. + case auto +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Internal/PredictionsCategory+CategoryConfigurable.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Internal/PredictionsCategory+CategoryConfigurable.swift new file mode 100644 index 0000000000..1551e9aa79 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Internal/PredictionsCategory+CategoryConfigurable.swift @@ -0,0 +1,35 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +extension PredictionsCategory: CategoryConfigurable { + + func configure(using configuration: CategoryConfiguration?) throws { + guard !isConfigured else { + let error = ConfigurationError.amplifyAlreadyConfigured( + "\(categoryType.displayName) has already been configured.", + "Remove the duplicate call to `Amplify.configure()`" + ) + throw error + } + + try Amplify.configure(plugins: Array(plugins.values), using: configuration) + + isConfigured = true + } + + func configure(using amplifyConfiguration: AmplifyConfiguration) throws { + try configure(using: categoryConfiguration(from: amplifyConfiguration)) + } + + func configure(using amplifyOutputs: AmplifyOutputsData) throws { + for plugin in Array(plugins.values) { + try plugin.configure(using: amplifyOutputs) + } + isConfigured = true + } + +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Internal/PredictionsCategory+Resettable.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Internal/PredictionsCategory+Resettable.swift new file mode 100644 index 0000000000..2fb48ceeda --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Internal/PredictionsCategory+Resettable.swift @@ -0,0 +1,25 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension PredictionsCategory: Resettable { + + public func reset() async { + await withTaskGroup(of: Void.self) { taskGroup in + for plugin in plugins.values { + taskGroup.addTask { [weak self] in + self?.log.verbose("Resetting \(String(describing: self?.categoryType)) plugin") + await plugin.reset() + self?.log.verbose("Resetting \(String(describing: self?.categoryType)) plugin: finished") + } + } + await taskGroup.waitForAll() + } + isConfigured = false + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Attribute.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Attribute.swift new file mode 100644 index 0000000000..0948c46ae5 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Attribute.swift @@ -0,0 +1,27 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension Predictions { + /// Attribute of an entity identified as a result of identify() API + public struct Attribute { + public let name: String + public let value: Bool + public let confidence: Double + + public init( + name: String, + value: Bool, + confidence: Double + ) { + self.name = name + self.value = value + self.confidence = confidence + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/BoundedKeyValue.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/BoundedKeyValue.swift new file mode 100644 index 0000000000..2bfc072ebb --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/BoundedKeyValue.swift @@ -0,0 +1,35 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import CoreGraphics + +extension Predictions { + /// Describes the data extracted as key-value pair in + /// an image/document resulting from identify() API + /// e.g The text "Name: John Doe" present in an image/document + public struct BoundedKeyValue { + public let key: String + public let value: String + public let isSelected: Bool + public let boundingBox: CGRect + public let polygon: Polygon + + public init( + key: String, + value: String, + isSelected: Bool, + boundingBox: CGRect, + polygon: Polygon + ) { + self.key = key + self.value = value + self.isSelected = isSelected + self.boundingBox = boundingBox + self.polygon = polygon + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Celebrity+Metadata.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Celebrity+Metadata.swift new file mode 100644 index 0000000000..9ec8bb99b4 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Celebrity+Metadata.swift @@ -0,0 +1,30 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension Predictions.Celebrity { + /// Celebrity metadata identified as a result of identify() API + public struct Metadata { + public let name: String + public let identifier: String + public let urls: [URL] + public let pose: Predictions.Pose + + public init( + name: String, + identifier: String, + urls: [URL], + pose: Predictions.Pose + ) { + self.name = name + self.identifier = identifier + self.urls = urls + self.pose = pose + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Celebrity.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Celebrity.swift new file mode 100644 index 0000000000..300955d199 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Celebrity.swift @@ -0,0 +1,30 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import CoreGraphics + +extension Predictions { + /// Describes a celebrity identified in an image + /// with information about its location(bounding box) and + /// facial features(landmarks) + public struct Celebrity { + public let metadata: Metadata + public let boundingBox: CGRect + public let landmarks: [Landmark] + + public init( + metadata: Metadata, + boundingBox: CGRect, + landmarks: [Landmark] + ) { + self.metadata = metadata + self.boundingBox = boundingBox + self.landmarks = landmarks + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Emotion+Kind.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Emotion+Kind.swift new file mode 100644 index 0000000000..543d497456 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Emotion+Kind.swift @@ -0,0 +1,24 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +extension Predictions.Emotion { + /// Different emotion types returned as a result of + /// identify() API call + public struct Kind: Equatable { + let id: UInt8 + + public static let unknown = Self(id: 0) + public static let angry = Self(id: 1) + public static let calm = Self(id: 2) + public static let confused = Self(id: 3) + public static let disgusted = Self(id: 4) + public static let fear = Self(id: 5) + public static let happy = Self(id: 6) + public static let sad = Self(id: 7) + public static let surprised = Self(id: 8) + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Emotion.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Emotion.swift new file mode 100644 index 0000000000..1f593ccbc4 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Emotion.swift @@ -0,0 +1,24 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +extension Predictions { + /// Emotion identified in an entity(faces/celebrities) + /// as a result of identify() API with associated `EmotionType` + /// and confidence value + public struct Emotion { + public let emotion: Kind + public let confidence: Double + + public init( + emotion: Kind, + confidence: Double + ) { + self.emotion = emotion + self.confidence = confidence + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Entity+DetectionResult.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Entity+DetectionResult.swift new file mode 100644 index 0000000000..c61656c954 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Entity+DetectionResult.swift @@ -0,0 +1,29 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +extension Predictions.Entity { + /// Describes the result of interpret() API when the analyzed text + /// contains a person/place + public struct DetectionResult { + public let type: Predictions.Entity.Kind + public let targetText: String + public let score: Float? + public let range: Range + + public init( + type: Predictions.Entity.Kind, + targetText: String, + score: Float?, + range: Range + ) { + self.type = type + self.targetText = targetText + self.score = score + self.range = range + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Entity+Kind.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Entity+Kind.swift new file mode 100644 index 0000000000..1d66a50ac7 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Entity+Kind.swift @@ -0,0 +1,25 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +extension Predictions.Entity { + /// Different entity types detected in a text as a result of + /// interpret() API + public struct Kind: Equatable, Hashable { + let id: UInt8 + + public static let unknown = Self(id: 0) + public static let commercialItem = Self(id: 1) + public static let date = Self(id: 2) + public static let event = Self(id: 3) + public static let location = Self(id: 4) + public static let organization = Self(id: 5) + public static let other = Self(id: 6) + public static let person = Self(id: 7) + public static let quantity = Self(id: 8) + public static let title = Self(id: 9) + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Entity+Match.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Entity+Match.swift new file mode 100644 index 0000000000..c101004b9b --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Entity+Match.swift @@ -0,0 +1,39 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import CoreGraphics + +extension Predictions.Entity { + /// Describes the result for an entity matched in an entity collection + /// created on AWS Rekogniton and detected from identify() API call + public struct Match { + public let boundingBox: CGRect + public let metadata: Metadata + + public init( + boundingBox: CGRect, + metadata: Metadata + ) { + self.boundingBox = boundingBox + self.metadata = metadata + } + } +} +extension Predictions.Entity.Match { + public struct Metadata { + public let externalImageId: String? + public let similarity: Double + + public init( + externalImageId: String?, + similarity: Double + ) { + self.externalImageId = externalImageId + self.similarity = similarity + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Entity+Metadata.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Entity+Metadata.swift new file mode 100644 index 0000000000..6c91db35b8 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Entity+Metadata.swift @@ -0,0 +1,20 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension Predictions.Entity { + public struct Metadata { + public let confidence: Double + public let pose: Predictions.Pose + + public init(confidence: Double, pose: Predictions.Pose) { + self.confidence = confidence + self.pose = pose + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Entity.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Entity.swift new file mode 100644 index 0000000000..273ec38fbf --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Entity.swift @@ -0,0 +1,40 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import CoreGraphics + +extension Predictions { + /// Result returned as part of identify() API call with + /// `IdentifyAction.detectEntities` type parameter + public struct Entity { + public let boundingBox: CGRect + public let landmarks: [Landmark] + public let ageRange: ClosedRange? + public let attributes: [Attribute]? + public let gender: GenderAttribute? + public let metadata: Metadata + public let emotions: [Emotion]? + + public init( + boundingBox: CGRect, + landmarks: [Landmark], + ageRange: ClosedRange?, + attributes: [Attribute]?, + gender: GenderAttribute?, + metadata: Metadata, + emotions: [Emotion]? + ) { + self.boundingBox = boundingBox + self.landmarks = landmarks + self.ageRange = ageRange + self.attributes = attributes + self.gender = gender + self.metadata = metadata + self.emotions = emotions + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Gender.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Gender.swift new file mode 100644 index 0000000000..0889c9b767 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Gender.swift @@ -0,0 +1,18 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +extension Predictions { + /// Describes gender of an entity identified as a result of + /// identify() API + public struct Gender { + let id: UInt8 + + public static let unknown = Self(id: 0) + public static let female = Self(id: 1) + public static let male = Self(id: 2) + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/GenderAttribute.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/GenderAttribute.swift new file mode 100644 index 0000000000..c7c6849efb --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/GenderAttribute.swift @@ -0,0 +1,25 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension Predictions { + /// Gender of an entity(face/celebrity) identified with + /// associated confidence value + public struct GenderAttribute { + public var gender: Gender + public var confidence: Double + + public init( + gender: Gender, + confidence: Double + ) { + self.gender = gender + self.confidence = confidence + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/IdentifiedLine.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/IdentifiedLine.swift new file mode 100644 index 0000000000..eaabadcd72 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/IdentifiedLine.swift @@ -0,0 +1,31 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import CoreGraphics + +extension Predictions { + /// Describes a line of text identified in an image as a result of + /// identify() API call + public struct IdentifiedLine: IdentifiedText { + public let text: String + public let boundingBox: CGRect + public let polygon: Polygon? + public let page: Int? + + public init( + text: String, + boundingBox: CGRect, + polygon: Polygon? = nil, + page: Int? = nil + ) { + self.text = text + self.boundingBox = boundingBox + self.polygon = polygon + self.page = page + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/IdentifiedText.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/IdentifiedText.swift new file mode 100644 index 0000000000..c788a20f20 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/IdentifiedText.swift @@ -0,0 +1,16 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import CoreGraphics + +/// protocol describing identified text in an image +public protocol IdentifiedText { + var text: String { get } + var boundingBox: CGRect { get } + var polygon: Predictions.Polygon? { get } + var page: Int? { get } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/IdentifiedWord.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/IdentifiedWord.swift new file mode 100644 index 0000000000..12a3b9d5ef --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/IdentifiedWord.swift @@ -0,0 +1,26 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import CoreGraphics + +extension Predictions { + /// Describes a word identified in an image as a result of + /// identify() API call + public struct IdentifiedWord: IdentifiedText { + public let text: String + public let boundingBox: CGRect + public let polygon: Polygon? + public let page: Int? + + public init(text: String, boundingBox: CGRect, polygon: Polygon? = nil, page: Int? = nil) { + self.text = text + self.boundingBox = boundingBox + self.polygon = polygon + self.page = page + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/KeyPhrase.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/KeyPhrase.swift new file mode 100644 index 0000000000..656125ddb4 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/KeyPhrase.swift @@ -0,0 +1,22 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +extension Predictions { + /// Describes a key phrase identified in a text as + /// a result of interpret() API call + public struct KeyPhrase { + public let score: Float? + public let text: String + public let range: Range + + public init(text: String, range: Range, score: Float?) { + self.text = text + self.range = range + self.score = score + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/LabelType.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/LabelType.swift new file mode 100644 index 0000000000..0aff10defa --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/LabelType.swift @@ -0,0 +1,16 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +extension Predictions { + public struct LabelType: Equatable { + let id: UInt8 + + public static let all = Self(id: 0) + public static let moderation = Self(id: 1) + public static let labels = Self(id: 2) + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Landmark.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Landmark.swift new file mode 100644 index 0000000000..4a00fdeba6 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Landmark.swift @@ -0,0 +1,46 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import CoreGraphics + +extension Predictions { + /// Describes the facial feature in a celebrity/entity + /// identified as a result of identify() API + public struct Landmark { + public let kind: Kind + public let points: [CGPoint] + + public init( + kind: Kind, + points: [CGPoint] + ) { + self.kind = kind + self.points = points + } + } +} + +extension Predictions.Landmark { + /// different types of facial features + public struct Kind { + let id: UInt8 + + public static let allPoints = Self(id: 0) + public static let leftEye = Self(id: 1) + public static let rightEye = Self(id: 2) + public static let leftEyebrow = Self(id: 3) + public static let rightEyebrow = Self(id: 4) + public static let nose = Self(id: 5) + public static let noseCrest = Self(id: 6) + public static let medianLine = Self(id: 7) + public static let outerLips = Self(id: 8) + public static let innerLips = Self(id: 9) + public static let leftPupil = Self(id: 10) + public static let rightPupil = Self(id: 11) + public static let faceContour = Self(id: 12) + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Language+DetectionResult.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Language+DetectionResult.swift new file mode 100644 index 0000000000..d31598288e --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Language+DetectionResult.swift @@ -0,0 +1,23 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +extension Predictions.Language { + /// Result describing language identified in a text + /// from interpret() API call + public struct DetectionResult { + public let languageCode: Predictions.Language + public let score: Double? + + public init( + languageCode: Predictions.Language, + score: Double? + ) { + self.languageCode = languageCode + self.score = score + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Language.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Language.swift new file mode 100644 index 0000000000..484ef1ac1e --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Language.swift @@ -0,0 +1,2408 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension Predictions { + // swiftlint:disable file_length type_body_length + public struct Language: Equatable, Decodable { + public let code: String + + public init(code: String) { + self.code = code + } + /// Afar language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let afar = Self(code: "aa") + /// Abkhazian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let abkhazian = Self(code: "ab") + /// Achinese language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let achinese = Self(code: "ace") + /// Acoli language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let acoli = Self(code: "ach") + /// Adangme language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let adangme = Self(code: "ada") + /// Adyghe language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let adyghe = Self(code: "ady") + /// Avestan language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let avestan = Self(code: "ae") + /// TunisianArabic language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let tunisianArabic = Self(code: "aeb") + /// Afrikaans language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let afrikaans = Self(code: "af") + /// Afrihili language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let afrihili = Self(code: "afh") + /// Aghem language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let aghem = Self(code: "agq") + /// Ainu language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let ainu = Self(code: "ain") + /// Akan language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let akan = Self(code: "ak") + /// Akkadian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let akkadian = Self(code: "akk") + /// Alabama language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let alabama = Self(code: "akz") + /// Aleut language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let aleut = Self(code: "ale") + /// GhegAlbanian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let ghegAlbanian = Self(code: "aln") + /// SouthernAltai language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let southernAltai = Self(code: "alt") + /// Amharic language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let amharic = Self(code: "am") + /// Aragonese language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let aragonese = Self(code: "an") + /// OldEnglish language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let oldEnglish = Self(code: "ang") + /// Angika language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let angika = Self(code: "anp") + /// Arabic language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let arabic = Self(code: "ar") + /// Aramaic language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let aramaic = Self(code: "arc") + /// Mapuche language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let mapuche = Self(code: "arn") + /// Araona language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let araona = Self(code: "aro") + /// Arapaho language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let arapaho = Self(code: "arp") + /// AlgerianArabic language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let algerianArabic = Self(code: "arq") + /// NajdiArabic language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let najdiArabic = Self(code: "ars") + /// Arawak language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let arawak = Self(code: "arw") + /// MoroccanArabic language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let moroccanArabic = Self(code: "ary") + /// EgyptianArabic language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let egyptianArabic = Self(code: "arz") + /// Assamese language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let assamese = Self(code: "as") + /// Asu language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let asu = Self(code: "asa") + /// AmericanSignLanguage language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let americanSignLanguage = Self(code: "ase") + /// Asturian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let asturian = Self(code: "ast") + /// Avaric language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let avaric = Self(code: "av") + /// Kotava language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let kotava = Self(code: "avk") + /// Awadhi language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let awadhi = Self(code: "awa") + /// Aymara language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let aymara = Self(code: "ay") + /// Azerbaijani language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let azerbaijani = Self(code: "az") + /// Bashkir language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let bashkir = Self(code: "ba") + /// Baluchi language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let baluchi = Self(code: "bal") + /// Balinese language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let balinese = Self(code: "ban") + /// Bavarian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let bavarian = Self(code: "bar") + /// Basaa language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let basaa = Self(code: "bas") + /// Bamun language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let bamun = Self(code: "bax") + /// BatakToba language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let batakToba = Self(code: "bbc") + /// Ghomala language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let ghomala = Self(code: "bbj") + /// Belarusian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let belarusian = Self(code: "be") + /// Beja language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let beja = Self(code: "bej") + /// Bemba language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let bemba = Self(code: "bem") + /// Betawi language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let betawi = Self(code: "bew") + /// Bena language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let bena = Self(code: "bez") + /// Bafut language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let bafut = Self(code: "bfd") + /// Badaga language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let badaga = Self(code: "bfq") + /// Bulgarian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let bulgarian = Self(code: "bg") + /// WesternBalochi language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let westernBalochi = Self(code: "bgn") + /// Bhojpuri language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let bhojpuri = Self(code: "bho") + /// Bislama language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let bislama = Self(code: "bi") + /// Bikol language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let bikol = Self(code: "bik") + /// Bini language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let bini = Self(code: "bin") + /// Banjar language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let banjar = Self(code: "bjn") + /// Kom language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let kom = Self(code: "bkm") + /// Siksika language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let siksika = Self(code: "bla") + /// Bambara language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let bambara = Self(code: "bm") + /// Bangla language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let bangla = Self(code: "bn") + /// Tibetan language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let tibetan = Self(code: "bo") + /// Bishnupriya language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let bishnupriya = Self(code: "bpy") + /// Bakhtiari language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let bakhtiari = Self(code: "bqi") + /// Breton language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let breton = Self(code: "br") + /// Braj language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let braj = Self(code: "bra") + /// Brahui language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let brahui = Self(code: "brh") + /// Bodo language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let bodo = Self(code: "brx") + /// Bosnian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let bosnian = Self(code: "bs") + /// Akoose language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let akoose = Self(code: "bss") + /// Buriat language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let buriat = Self(code: "bua") + /// Buginese language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let buginese = Self(code: "bug") + /// Bulu language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let bulu = Self(code: "bum") + /// Blin language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let blin = Self(code: "byn") + /// Medumba language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let medumba = Self(code: "byv") + /// Catalan language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let catalan = Self(code: "ca") + /// Caddo language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let caddo = Self(code: "cad") + /// Carib language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let carib = Self(code: "car") + /// Cayuga language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let cayuga = Self(code: "cay") + /// Atsam language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let atsam = Self(code: "cch") + /// Chakma language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let chakma = Self(code: "ccp") + /// Chechen language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let chechen = Self(code: "ce") + /// Cebuano language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let cebuano = Self(code: "ceb") + /// Chiga language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let chiga = Self(code: "cgg") + /// Chamorro language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let chamorro = Self(code: "ch") + /// Chibcha language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let chibcha = Self(code: "chb") + /// Chagatai language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let chagatai = Self(code: "chg") + /// Chuukese language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let chuukese = Self(code: "chk") + /// Mari language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let mari = Self(code: "chm") + /// ChinookJargon language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let chinookJargon = Self(code: "chn") + /// Choctaw language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let choctaw = Self(code: "cho") + /// Chipewyan language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let chipewyan = Self(code: "chp") + /// Cherokee language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let cherokee = Self(code: "chr") + /// Cheyenne language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let cheyenne = Self(code: "chy") + /// CentralKurdish language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let centralKurdish = Self(code: "ckb") + /// Corsican language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let corsican = Self(code: "co") + /// Coptic language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let coptic = Self(code: "cop") + /// Capiznon language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let capiznon = Self(code: "cps") + /// Cree language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let cree = Self(code: "cr") + /// CrimeanTurkish language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let crimeanTurkish = Self(code: "crh") + /// Czech language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let czech = Self(code: "cs") + /// Kashubian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let kashubian = Self(code: "csb") + /// ChurchSlavic language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let churchSlavic = Self(code: "cu") + /// Chuvash language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let chuvash = Self(code: "cv") + /// Welsh language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let welsh = Self(code: "cy") + /// Danish language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let danish = Self(code: "da") + /// Dakota language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let dakota = Self(code: "dak") + /// Dargwa language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let dargwa = Self(code: "dar") + /// Taita language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let taita = Self(code: "dav") + /// German language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let german = Self(code: "de") + /// Delaware language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let delaware = Self(code: "del") + /// Slave language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let slave = Self(code: "den") // swiftlint:disable:this inclusive_language + /// Dogrib language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let dogrib = Self(code: "dgr") + /// Dinka language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let dinka = Self(code: "din") + /// Zarma language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let zarma = Self(code: "dje") + /// Dogri language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let dogri = Self(code: "doi") + /// LowerSorbian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let lowerSorbian = Self(code: "dsb") + /// CentralDusun language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let centralDusun = Self(code: "dtp") + /// Duala language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let duala = Self(code: "dua") + /// MiddleDutch language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let middleDutch = Self(code: "dum") + /// Dhivehi language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let dhivehi = Self(code: "dv") + /// JolaFonyi language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let jolaFonyi = Self(code: "dyo") + /// Dyula language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let dyula = Self(code: "dyu") + /// Dzongkha language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let dzongkha = Self(code: "dz") + /// Dazaga language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let dazaga = Self(code: "dzg") + /// Embu language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let embu = Self(code: "ebu") + /// Ewe language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let ewe = Self(code: "ee") + /// Efik language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let efik = Self(code: "efi") + /// Emilian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let emilian = Self(code: "egl") + /// AncientEgyptian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let ancientEgyptian = Self(code: "egy") + /// Ekajuk language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let ekajuk = Self(code: "eka") + /// Greek language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let greek = Self(code: "el") + /// Elamite language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let elamite = Self(code: "elx") + /// English language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let english = Self(code: "en") + /// AustralianEnglish language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let australianEnglish = Self(code: "en-AU") + /// BritishEnglish language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let britishEnglish = Self(code: "en-GB") + /// UsEnglish language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let usEnglish = Self(code: "en-US") + /// MiddleEnglish language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let middleEnglish = Self(code: "enm") + /// Esperanto language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let esperanto = Self(code: "eo") + /// Spanish language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let spanish = Self(code: "es") + /// UsSpanish language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let usSpanish = Self(code: "es-US") + /// CentralYupik language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let centralYupik = Self(code: "esu") + /// Estonian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let estonian = Self(code: "et") + /// Basque language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let basque = Self(code: "eu") + /// Ewondo language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let ewondo = Self(code: "ewo") + /// Extremaduran language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let extremaduran = Self(code: "ext") + /// Persian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let persian = Self(code: "fa") + /// Fang language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let fang = Self(code: "fan") + /// Fanti language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let fanti = Self(code: "fat") + /// Fulah language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let fulah = Self(code: "ff") + /// Finnish language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let finnish = Self(code: "fi") + /// Filipino language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let filipino = Self(code: "fil") + /// TornedalenFinnish language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let tornedalenFinnish = Self(code: "fit") + /// Fijian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let fijian = Self(code: "fj") + /// Faroese language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let faroese = Self(code: "fo") + /// Fon language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let fon = Self(code: "fon") + /// French language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let french = Self(code: "fr") + /// CanadianFrench language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let canadianFrench = Self(code: "fr-CA") + /// CajunFrench language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let cajunFrench = Self(code: "frc") + /// MiddleFrench language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let middleFrench = Self(code: "frm") + /// OldFrench language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let oldFrench = Self(code: "fro") + /// Arpitan language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let arpitan = Self(code: "frp") + /// NorthernFrisian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let northernFrisian = Self(code: "frr") + /// EasternFrisian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let easternFrisian = Self(code: "frs") + /// Friulian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let friulian = Self(code: "fur") + /// WesternFrisian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let westernFrisian = Self(code: "fy") + /// Irish language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let irish = Self(code: "ga") + /// Ga language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let ga = Self(code: "gaa") // swiftlint:disable:this identifier_name + /// Gagauz language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let gagauz = Self(code: "gag") + /// GanChinese language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let ganChinese = Self(code: "gan") + /// Gayo language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let gayo = Self(code: "gay") + /// Gbaya language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let gbaya = Self(code: "gba") + /// ZoroastrianDari language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let zoroastrianDari = Self(code: "gbz") + /// ScottishGaelic language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let scottishGaelic = Self(code: "gd") + /// Geez language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let geez = Self(code: "gez") + /// Gilbertese language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let gilbertese = Self(code: "gil") + /// Galician language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let galician = Self(code: "gl") + /// Gilaki language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let gilaki = Self(code: "glk") + /// MiddleHighGerman language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let middleHighGerman = Self(code: "gmh") + /// Guarani language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let guarani = Self(code: "gn") + /// OldHighGerman language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let oldHighGerman = Self(code: "goh") + /// GoanKonkani language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let goanKonkani = Self(code: "gom") + /// Gondi language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let gondi = Self(code: "gon") + /// Gorontalo language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let gorontalo = Self(code: "gor") + /// Gothic language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let gothic = Self(code: "got") + /// Grebo language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let grebo = Self(code: "grb") + /// AncientGreek language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let ancientGreek = Self(code: "grc") + /// SwissGerman language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let swissGerman = Self(code: "gsw") + /// Gujarati language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let gujarati = Self(code: "gu") + /// Wayuu language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let wayuu = Self(code: "guc") + /// Frafra language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let frafra = Self(code: "gur") + /// Gusii language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let gusii = Self(code: "guz") + /// Manx language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let manx = Self(code: "gv") + /// Gwichʼin language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let gwichʼin = Self(code: "gwi") + /// Hausa language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let hausa = Self(code: "ha") + /// Haida language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let haida = Self(code: "hai") + /// HakkaChinese language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let hakkaChinese = Self(code: "hak") + /// Hawaiian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let hawaiian = Self(code: "haw") + /// Hebrew language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let hebrew = Self(code: "he") + /// Hindi language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let hindi = Self(code: "hi") + /// FijiHindi language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let fijiHindi = Self(code: "hif") + /// Hiligaynon language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let hiligaynon = Self(code: "hil") + /// Hittite language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let hittite = Self(code: "hit") + /// Hmong language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let hmong = Self(code: "hmn") + /// HiriMotu language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let hiriMotu = Self(code: "ho") + /// Croatian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let croatian = Self(code: "hr") + /// UpperSorbian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let upperSorbian = Self(code: "hsb") + /// XiangChinese language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let xiangChinese = Self(code: "hsn") + /// HaitianCreole language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let haitianCreole = Self(code: "ht") + /// Hungarian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let hungarian = Self(code: "hu") + /// Hupa language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let hupa = Self(code: "hup") + /// Armenian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let armenian = Self(code: "hy") + /// Herero language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let herero = Self(code: "hz") + /// Interlingua language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let interlingua = Self(code: "ia") + /// Iban language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let iban = Self(code: "iba") + /// Ibibio language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let ibibio = Self(code: "ibb") + /// Indonesian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let indonesian = Self(code: "id") + /// Interlingue language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let interlingue = Self(code: "ie") + /// Igbo language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let igbo = Self(code: "ig") + /// SichuanYi language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let sichuanYi = Self(code: "ii") + /// Inupiaq language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let inupiaq = Self(code: "ik") + /// Iloko language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let iloko = Self(code: "ilo") + /// Ingush language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let ingush = Self(code: "inh") + /// Ido language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let ido = Self(code: "io") + /// Icelandic language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let icelandic = Self(code: "is") + /// Italian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let italian = Self(code: "it") + /// Inuktitut language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let inuktitut = Self(code: "iu") + /// Ingrian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let ingrian = Self(code: "izh") + /// Japanese language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let japanese = Self(code: "ja") + /// JamaicanCreoleEnglish language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let jamaicanCreoleEnglish = Self(code: "jam") + /// Lojban language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let lojban = Self(code: "jbo") + /// Ngomba language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let ngomba = Self(code: "jgo") + /// Machame language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let machame = Self(code: "jmc") + /// JudeoPersian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let judeoPersian = Self(code: "jpr") + /// JudeoArabic language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let judeoArabic = Self(code: "jrb") + /// Jutish language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let jutish = Self(code: "jut") + /// Javanese language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let javanese = Self(code: "jv") + /// Georgian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let georgian = Self(code: "ka") + /// KaraKalpak language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let karaKalpak = Self(code: "kaa") + /// Kabyle language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let kabyle = Self(code: "kab") + /// Kachin language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let kachin = Self(code: "kac") + /// Jju language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let jju = Self(code: "kaj") + /// Kamba language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let kamba = Self(code: "kam") + /// Kawi language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let kawi = Self(code: "kaw") + /// Kabardian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let kabardian = Self(code: "kbd") + /// Kanembu language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let kanembu = Self(code: "kbl") + /// Tyap language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let tyap = Self(code: "kcg") + /// Makonde language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let makonde = Self(code: "kde") + /// Kabuverdianu language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let kabuverdianu = Self(code: "kea") + /// Kenyang language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let kenyang = Self(code: "ken") + /// Koro language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let koro = Self(code: "kfo") + /// Kongo language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let kongo = Self(code: "kg") + /// Kaingang language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let kaingang = Self(code: "kgp") + /// Khasi language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let khasi = Self(code: "kha") + /// Khotanese language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let khotanese = Self(code: "kho") + /// KoyraChiini language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let koyraChiini = Self(code: "khq") + /// Khowar language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let khowar = Self(code: "khw") + /// Kikuyu language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let kikuyu = Self(code: "ki") + /// Kirmanjki language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let kirmanjki = Self(code: "kiu") + /// Kuanyama language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let kuanyama = Self(code: "kj") + /// Kazakh language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let kazakh = Self(code: "kk") + /// Kako language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let kako = Self(code: "kkj") + /// Kalaallisut language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let kalaallisut = Self(code: "kl") + /// Kalenjin language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let kalenjin = Self(code: "kln") + /// Khmer language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let khmer = Self(code: "km") + /// Kimbundu language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let kimbundu = Self(code: "kmb") + /// Kannada language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let kannada = Self(code: "kn") + /// Korean language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let korean = Self(code: "ko") + /// KomiPermyak language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let komiPermyak = Self(code: "koi") + /// Konkani language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let konkani = Self(code: "kok") + /// Kosraean language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let kosraean = Self(code: "kos") + /// Kpelle language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let kpelle = Self(code: "kpe") + /// Kanuri language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let kanuri = Self(code: "kr") + /// KarachayBalkar language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let karachayBalkar = Self(code: "krc") + /// Krio language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let krio = Self(code: "kri") + /// KinarayA language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let kinarayA = Self(code: "krj") + /// Karelian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let karelian = Self(code: "krl") + /// Kurukh language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let kurukh = Self(code: "kru") + /// Kashmiri language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let kashmiri = Self(code: "ks") + /// Shambala language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let shambala = Self(code: "ksb") + /// Bafia language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let bafia = Self(code: "ksf") + /// Colognian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let colognian = Self(code: "ksh") + /// Kurdish language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let kurdish = Self(code: "ku") + /// Kumyk language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let kumyk = Self(code: "kum") + /// Kutenai language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let kutenai = Self(code: "kut") + /// Komi language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let komi = Self(code: "kv") + /// Cornish language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let cornish = Self(code: "kw") + /// Kyrgyz language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let kyrgyz = Self(code: "ky") + /// Latin language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let latin = Self(code: "la") + /// Ladino language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let ladino = Self(code: "lad") + /// Langi language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let langi = Self(code: "lag") + /// Lahnda language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let lahnda = Self(code: "lah") + /// Lamba language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let lamba = Self(code: "lam") + /// Luxembourgish language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let luxembourgish = Self(code: "lb") + /// Lezghian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let lezghian = Self(code: "lez") + /// LinguaFrancaNova language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let linguaFrancaNova = Self(code: "lfn") + /// Ganda language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let ganda = Self(code: "lg") + /// Limburgish language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let limburgish = Self(code: "li") + /// Ligurian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let ligurian = Self(code: "lij") + /// Livonian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let livonian = Self(code: "liv") + /// Lakota language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let lakota = Self(code: "lkt") + /// Lombard language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let lombard = Self(code: "lmo") + /// Lingala language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let lingala = Self(code: "ln") + /// Lao language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let lao = Self(code: "lo") + /// Mongo language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let mongo = Self(code: "lol") + /// Lozi language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let lozi = Self(code: "loz") + /// NorthernLuri language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let northernLuri = Self(code: "lrc") + /// Lithuanian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let lithuanian = Self(code: "lt") + /// Latgalian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let latgalian = Self(code: "ltg") + /// LubaKatanga language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let lubaKatanga = Self(code: "lu") + /// LubaLulua language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let lubaLulua = Self(code: "lua") + /// Luiseno language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let luiseno = Self(code: "lui") + /// Lunda language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let lunda = Self(code: "lun") + /// Luo language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let luo = Self(code: "luo") + /// Mizo language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let mizo = Self(code: "lus") + /// Luyia language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let luyia = Self(code: "luy") + /// Latvian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let latvian = Self(code: "lv") + /// LiteraryChinese language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let literaryChinese = Self(code: "lzh") + /// Laz language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let laz = Self(code: "lzz") + /// Madurese language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let madurese = Self(code: "mad") + /// Mafa language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let mafa = Self(code: "maf") + /// Magahi language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let magahi = Self(code: "mag") + /// Maithili language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let maithili = Self(code: "mai") + /// Makasar language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let makasar = Self(code: "mak") + /// Mandingo language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let mandingo = Self(code: "man") + /// Masai language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let masai = Self(code: "mas") + /// Maba language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let maba = Self(code: "mde") + /// Moksha language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let moksha = Self(code: "mdf") + /// Mandar language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let mandar = Self(code: "mdr") + /// Mende language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let mende = Self(code: "men") + /// Meru language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let meru = Self(code: "mer") + /// Morisyen language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let morisyen = Self(code: "mfe") + /// Malagasy language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let malagasy = Self(code: "mg") + /// MiddleIrish language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let middleIrish = Self(code: "mga") + /// MakhuwaMeetto language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let makhuwaMeetto = Self(code: "mgh") + /// Meta language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let meta = Self(code: "mgo") + /// Marshallese language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let marshallese = Self(code: "mh") + /// Maori language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let maori = Self(code: "mi") + /// Mikmaq language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let mikmaq = Self(code: "mic") + /// Minangkabau language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let minangkabau = Self(code: "min") + /// Macedonian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let macedonian = Self(code: "mk") + /// Malayalam language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let malayalam = Self(code: "ml") + /// Mongolian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let mongolian = Self(code: "mn") + /// Manchu language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let manchu = Self(code: "mnc") + /// Manipuri language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let manipuri = Self(code: "mni") + /// Mohawk language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let mohawk = Self(code: "moh") + /// Mossi language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let mossi = Self(code: "mos") + /// Marathi language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let marathi = Self(code: "mr") + /// WesternMari language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let westernMari = Self(code: "mrj") + /// Malay language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let malay = Self(code: "ms") + /// Maltese language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let maltese = Self(code: "mt") + /// Mundang language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let mundang = Self(code: "mua") + /// Creek language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let creek = Self(code: "mus") + /// Mirandese language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let mirandese = Self(code: "mwl") + /// Marwari language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let marwari = Self(code: "mwr") + /// Mentawai language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let mentawai = Self(code: "mwv") + /// Burmese language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let burmese = Self(code: "my") + /// Myene language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let myene = Self(code: "mye") + /// Erzya language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let erzya = Self(code: "myv") + /// Mazanderani language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let mazanderani = Self(code: "mzn") + /// Nauru language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let nauru = Self(code: "na") + /// MinnanChinese language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let minnanChinese = Self(code: "nan") + /// Neapolitan language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let neapolitan = Self(code: "nap") + /// Nama language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let nama = Self(code: "naq") + /// NorwegianBokmål language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let norwegianBokmål = Self(code: "nb") + /// NorthNdebele language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let northNdebele = Self(code: "nd") + /// LowGerman language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let lowGerman = Self(code: "nds") + /// Nepali language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let nepali = Self(code: "ne") + /// Newari language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let newari = Self(code: "new") + /// Ndonga language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let ndonga = Self(code: "ng") + /// Nias language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let nias = Self(code: "nia") + /// Niuean language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let niuean = Self(code: "niu") + /// AoNaga language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let aoNaga = Self(code: "njo") + /// Dutch language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let dutch = Self(code: "nl") + /// Kwasio language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let kwasio = Self(code: "nmg") + /// NorwegianNynorsk language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let norwegianNynorsk = Self(code: "nn") + /// Ngiemboon language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let ngiemboon = Self(code: "nnh") + /// Norwegian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let norwegian = Self(code: "no") + /// Nogai language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let nogai = Self(code: "nog") + /// OldNorse language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let oldNorse = Self(code: "non") + /// Novial language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let novial = Self(code: "nov") + /// Nko language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let nko = Self(code: "nqo") + /// SouthNdebele language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let southNdebele = Self(code: "nr") + /// NorthernSotho language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let northernSotho = Self(code: "nso") + /// Nuer language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let nuer = Self(code: "nus") + /// Navajo language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let navajo = Self(code: "nv") + /// ClassicalNewari language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let classicalNewari = Self(code: "nwc") + /// Nyanja language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let nyanja = Self(code: "ny") + /// Nyamwezi language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let nyamwezi = Self(code: "nym") + /// Nyankole language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let nyankole = Self(code: "nyn") + /// Nyoro language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let nyoro = Self(code: "nyo") + /// Nzima language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let nzima = Self(code: "nzi") + /// Occitan language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let occitan = Self(code: "oc") + /// Ojibwa language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let ojibwa = Self(code: "oj") + /// Oromo language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let oromo = Self(code: "om") + /// Odia language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let odia = Self(code: "or") + /// Ossetic language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let ossetic = Self(code: "os") + /// Osage language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let osage = Self(code: "osa") + /// OttomanTurkish language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let ottomanTurkish = Self(code: "ota") + /// Punjabi language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let punjabi = Self(code: "pa") + /// Pangasinan language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let pangasinan = Self(code: "pag") + /// Pahlavi language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let pahlavi = Self(code: "pal") + /// Pampanga language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let pampanga = Self(code: "pam") + /// Papiamento language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let papiamento = Self(code: "pap") + /// Palauan language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let palauan = Self(code: "pau") + /// Picard language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let picard = Self(code: "pcd") + /// PennsylvaniaGerman language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let pennsylvaniaGerman = Self(code: "pdc") + /// Plautdietsch language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let plautdietsch = Self(code: "pdt") + /// OldPersian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let oldPersian = Self(code: "peo") + /// PalatineGerman language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let palatineGerman = Self(code: "pfl") + /// Phoenician language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let phoenician = Self(code: "phn") + /// Pali language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let pali = Self(code: "pi") + /// Polish language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let polish = Self(code: "pl") + /// Piedmontese language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let piedmontese = Self(code: "pms") + /// Pontic language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let pontic = Self(code: "pnt") + /// Pohnpeian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let pohnpeian = Self(code: "pon") + /// Prussian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let prussian = Self(code: "prg") + /// OldProvençal language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let oldProvençal = Self(code: "pro") + /// Pashto language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let pashto = Self(code: "ps") + /// Portuguese language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let portuguese = Self(code: "pt") + /// Quechua language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let quechua = Self(code: "qu") + /// Kʼicheʼ language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let kʼicheʼ = Self(code: "quc") + /// ChimborazoHighlandQuichua language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let chimborazoHighlandQuichua = Self(code: "qug") + /// Rajasthani language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let rajasthani = Self(code: "raj") + /// Rapanui language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let rapanui = Self(code: "rap") + /// Rarotongan language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let rarotongan = Self(code: "rar") + /// Romagnol language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let romagnol = Self(code: "rgn") + /// Riffian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let riffian = Self(code: "rif") + /// Romansh language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let romansh = Self(code: "rm") + /// Rundi language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let rundi = Self(code: "rn") + /// Romanian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let romanian = Self(code: "ro") + /// Rombo language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let rombo = Self(code: "rof") + /// Romany language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let romany = Self(code: "rom") + /// Rotuman language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let rotuman = Self(code: "rtm") + /// Russian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let russian = Self(code: "ru") + /// Rusyn language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let rusyn = Self(code: "rue") + /// Roviana language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let roviana = Self(code: "rug") + /// Aromanian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let aromanian = Self(code: "rup") + /// Kinyarwanda language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let kinyarwanda = Self(code: "rw") + /// Rwa language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let rwa = Self(code: "rwk") + /// Sanskrit language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let sanskrit = Self(code: "sa") + /// Sandawe language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let sandawe = Self(code: "sad") + /// Sakha language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let sakha = Self(code: "sah") + /// SamaritanAramaic language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let samaritanAramaic = Self(code: "sam") + /// Samburu language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let samburu = Self(code: "saq") + /// Sasak language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let sasak = Self(code: "sas") + /// Santali language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let santali = Self(code: "sat") + /// Saurashtra language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let saurashtra = Self(code: "saz") + /// Ngambay language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let ngambay = Self(code: "sba") + /// Sangu language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let sangu = Self(code: "sbp") + /// Sardinian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let sardinian = Self(code: "sc") + /// Sicilian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let sicilian = Self(code: "scn") + /// Scots language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let scots = Self(code: "sco") + /// Sindhi language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let sindhi = Self(code: "sd") + /// SassareseSardinian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let sassareseSardinian = Self(code: "sdc") + /// SouthernKurdish language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let southernKurdish = Self(code: "sdh") + /// NorthernSami language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let northernSami = Self(code: "se") + /// Seneca language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let seneca = Self(code: "see") + /// Sena language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let sena = Self(code: "seh") + /// Seri language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let seri = Self(code: "sei") + /// Selkup language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let selkup = Self(code: "sel") + /// KoyraboroSenni language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let koyraboroSenni = Self(code: "ses") + /// Sango language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let sango = Self(code: "sg") + /// OldIrish language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let oldIrish = Self(code: "sga") + /// Samogitian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let samogitian = Self(code: "sgs") + /// Tachelhit language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let tachelhit = Self(code: "shi") + /// Shan language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let shan = Self(code: "shn") + /// ChadianArabic language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let chadianArabic = Self(code: "shu") + /// Sinhala language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let sinhala = Self(code: "si") + /// Sidamo language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let sidamo = Self(code: "sid") + /// Slovak language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let slovak = Self(code: "sk") + /// Slovenian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let slovenian = Self(code: "sl") + /// LowerSilesian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let lowerSilesian = Self(code: "sli") + /// Selayar language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let selayar = Self(code: "sly") + /// Samoan language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let samoan = Self(code: "sm") + /// SouthernSami language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let southernSami = Self(code: "sma") + /// LuleSami language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let luleSami = Self(code: "smj") + /// InariSami language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let inariSami = Self(code: "smn") + /// SkoltSami language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let skoltSami = Self(code: "sms") + /// Shona language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let shona = Self(code: "sn") + /// Soninke language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let soninke = Self(code: "snk") + /// Somali language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let somali = Self(code: "so") + /// Sogdien language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let sogdien = Self(code: "sog") + /// Albanian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let albanian = Self(code: "sq") + /// Serbian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let serbian = Self(code: "sr") + /// SrananTongo language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let srananTongo = Self(code: "srn") + /// Serer language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let serer = Self(code: "srr") + /// Swati language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let swati = Self(code: "ss") + /// Saho language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let saho = Self(code: "ssy") + /// SouthernSotho language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let southernSotho = Self(code: "st") + /// SaterlandFrisian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let saterlandFrisian = Self(code: "stq") + /// Sundanese language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let sundanese = Self(code: "su") + /// Sukuma language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let sukuma = Self(code: "suk") + /// Susu language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let susu = Self(code: "sus") + /// Sumerian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let sumerian = Self(code: "sux") + /// Swedish language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let swedish = Self(code: "sv") + /// Swahili language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let swahili = Self(code: "sw") + /// Comorian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let comorian = Self(code: "swb") + /// ClassicalSyriac language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let classicalSyriac = Self(code: "syc") + /// Syriac language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let syriac = Self(code: "syr") + /// Silesian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let silesian = Self(code: "szl") + /// Tamil language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let tamil = Self(code: "ta") + /// Tulu language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let tulu = Self(code: "tcy") + /// Telugu language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let telugu = Self(code: "te") + /// Timne language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let timne = Self(code: "tem") + /// Teso language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let teso = Self(code: "teo") + /// Tereno language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let tereno = Self(code: "ter") + /// Tetum language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let tetum = Self(code: "tet") + /// Tajik language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let tajik = Self(code: "tg") + /// Thai language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let thai = Self(code: "th") + /// Tigrinya language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let tigrinya = Self(code: "ti") + /// Tigre language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let tigre = Self(code: "tig") + /// Tiv language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let tiv = Self(code: "tiv") + /// Turkmen language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let turkmen = Self(code: "tk") + /// Tokelau language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let tokelau = Self(code: "tkl") + /// Tsakhur language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let tsakhur = Self(code: "tkr") + /// Tagalog language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let tagalog = Self(code: "tl") + /// Klingon language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let klingon = Self(code: "tlh") + /// Tlingit language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let tlingit = Self(code: "tli") + /// Talysh language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let talysh = Self(code: "tly") + /// Tamashek language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let tamashek = Self(code: "tmh") + /// Tswana language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let tswana = Self(code: "tn") + /// Tongan language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let tongan = Self(code: "to") + /// NyasaTonga language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let nyasaTonga = Self(code: "tog") + /// TokPisin language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let tokPisin = Self(code: "tpi") + /// Turkish language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let turkish = Self(code: "tr") + /// Turoyo language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let turoyo = Self(code: "tru") + /// Taroko language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let taroko = Self(code: "trv") + /// Tsonga language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let tsonga = Self(code: "ts") + /// Tsakonian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let tsakonian = Self(code: "tsd") + /// Tsimshian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let tsimshian = Self(code: "tsi") + /// Tatar language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let tatar = Self(code: "tt") + /// MuslimTat language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let muslimTat = Self(code: "ttt") + /// Tumbuka language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let tumbuka = Self(code: "tum") + /// Tuvalu language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let tuvalu = Self(code: "tvl") + /// Twi language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let twi = Self(code: "tw") + /// Tasawaq language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let tasawaq = Self(code: "twq") + /// Tahitian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let tahitian = Self(code: "ty") + /// Tuvinian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let tuvinian = Self(code: "tyv") + /// CentralAtlasTamazight language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let centralAtlasTamazight = Self(code: "tzm") + /// Udmurt language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let udmurt = Self(code: "udm") + /// Uyghur language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let uyghur = Self(code: "ug") + /// Ugaritic language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let ugaritic = Self(code: "uga") + /// Ukrainian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let ukrainian = Self(code: "uk") + /// Umbundu language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let umbundu = Self(code: "umb") + /// Urdu language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let urdu = Self(code: "ur") + /// Uzbek language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let uzbek = Self(code: "uz") + /// Vai language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let vai = Self(code: "vai") + /// Venda language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let venda = Self(code: "ve") + /// Venetian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let venetian = Self(code: "vec") + /// Veps language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let veps = Self(code: "vep") + /// Vietnamese language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let vietnamese = Self(code: "vi") + /// WestFlemish language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let westFlemish = Self(code: "vls") + /// MainFranconian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let mainFranconian = Self(code: "vmf") + /// Volapük language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let volapük = Self(code: "vo") + /// Votic language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let votic = Self(code: "vot") + /// Võro language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let võro = Self(code: "vro") + /// Vunjo language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let vunjo = Self(code: "vun") + /// Walloon language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let walloon = Self(code: "wa") + /// Walser language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let walser = Self(code: "wae") + /// Wolaytta language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let wolaytta = Self(code: "wal") + /// Waray language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let waray = Self(code: "war") + /// Washo language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let washo = Self(code: "was") + /// Warlpiri language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let warlpiri = Self(code: "wbp") + /// Wolof language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let wolof = Self(code: "wo") + /// Shanghainese language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let shanghainese = Self(code: "wuu") + /// Kalmyk language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let kalmyk = Self(code: "xal") + /// Xhosa language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let xhosa = Self(code: "xh") + /// Mingrelian language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let mingrelian = Self(code: "xmf") + /// Soga language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let soga = Self(code: "xog") + /// Yao language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let yao = Self(code: "yao") + /// Yapese language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let yapese = Self(code: "yap") + /// Yangben language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let yangben = Self(code: "yav") + /// Yemba language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let yemba = Self(code: "ybb") + /// Yiddish language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let yiddish = Self(code: "yi") + /// Yoruba language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let yoruba = Self(code: "yo") + /// Nheengatu language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let nheengatu = Self(code: "yrl") + /// Cantonese language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let cantonese = Self(code: "yue") + /// Zhuang language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let zhuang = Self(code: "za") + /// Zapotec language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let zapotec = Self(code: "zap") + /// Blissymbols language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let blissymbols = Self(code: "zbl") + /// Zeelandic language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let zeelandic = Self(code: "zea") + /// Zenaga language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let zenaga = Self(code: "zen") + /// StandardMoroccanTamazight language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let standardMoroccanTamazight = Self(code: "zgh") + /// Chinese language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let chinese = Self(code: "zh") + /// Zulu language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let zulu = Self(code: "zu") + /// Zuni language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let zuni = Self(code: "zun") + /// Zaza language type supported by Predictions category + /// + /// The associated value represents the iso language code. + public static let zaza = Self(code: "zza") + + public static let undetermined = Self(code: "") + } +} + +extension Predictions.Language { + public init(locale: Locale) { + guard let languageCode = locale.languageCode else { + self = .undetermined + return + } + self = .init(code: languageCode) + } +} +// swiftlint:enable file_length type_body_length diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/PartOfSpeech+DetectionResult.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/PartOfSpeech+DetectionResult.swift new file mode 100644 index 0000000000..4104e9db10 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/PartOfSpeech+DetectionResult.swift @@ -0,0 +1,21 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension Predictions.PartOfSpeech { + /// Part of speech identified in a text from interpret() API + public struct DetectionResult { + public let partOfSpeech: Predictions.PartOfSpeech + public let score: Float? + + public init(partOfSpeech: Predictions.PartOfSpeech, score: Float?) { + self.partOfSpeech = partOfSpeech + self.score = score + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/PartOfSpeech.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/PartOfSpeech.swift new file mode 100644 index 0000000000..a7db69909c --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/PartOfSpeech.swift @@ -0,0 +1,36 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension Predictions { + /// Part of speech identified in a text from interpret() API + public struct PartOfSpeech: Equatable { + let description: String + + public static let adjective = Self(description: "adjective") + public static let adposition = Self(description: "adposition") + public static let adverb = Self(description: "adverb") + public static let auxiliary = Self(description: "auxiliary") + public static let conjunction = Self(description: "conjunction") + public static let coordinatingConjunction = Self(description: "coordinatingConjunction") + public static let determiner = Self(description: "determiner") + public static let interjection = Self(description: "interjection") + public static let noun = Self(description: "noun") + public static let numeral = Self(description: "numeral") + public static let other = Self(description: "other") + public static let particle = Self(description: "particle") + public static let pronoun = Self(description: "pronoun") + public static let properNoun = Self(description: "properNoun") + public static let punctuation = Self(description: "punctuation") + public static let preposition = Self(description: "preposition") + public static let subordinatingConjunction = Self(description: "subordinatingConjunction") + public static let symbol = Self(description: "symbol") + public static let verb = Self(description: "verb") + public static let unknown = Self(description: "unknown") + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Polygon.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Polygon.swift new file mode 100644 index 0000000000..bb38da8f64 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Polygon.swift @@ -0,0 +1,18 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import CoreGraphics + +extension Predictions { + public struct Polygon { + public let points: [CGPoint] + + public init(points: [CGPoint]) { + self.points = points + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Pose.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Pose.swift new file mode 100644 index 0000000000..8aa116ba7b --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Pose.swift @@ -0,0 +1,25 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +extension Predictions { + /// Describes the pose of a person identified in an image from identify() API + public struct Pose { + public let pitch: Double + public let roll: Double + public let yaw: Double + + public init( + pitch: Double, + roll: Double, + yaw: Double + ) { + self.pitch = pitch + self.roll = roll + self.yaw = yaw + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Selection.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Selection.swift new file mode 100644 index 0000000000..d80fe53343 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Selection.swift @@ -0,0 +1,26 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import CoreGraphics + +extension Predictions { + public struct Selection { + public let boundingBox: CGRect + public let polygon: Polygon + public let isSelected: Bool + + public init( + boundingBox: CGRect, + polygon: Polygon, + isSelected: Bool + ) { + self.boundingBox = boundingBox + self.polygon = polygon + self.isSelected = isSelected + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Sentiment+Kind.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Sentiment+Kind.swift new file mode 100644 index 0000000000..dbe46443db --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Sentiment+Kind.swift @@ -0,0 +1,20 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension Predictions.Sentiment { + public struct Kind: Equatable, Hashable { + let id: UInt8 + + public static let unknown = Self(id: 0) + public static let positive = Self(id: 1) + public static let negative = Self(id: 2) + public static let neutral = Self(id: 3) + public static let mixed = Self(id: 4) + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Sentiment.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Sentiment.swift new file mode 100644 index 0000000000..58a6e2dc70 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Sentiment.swift @@ -0,0 +1,22 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +extension Predictions { + /// Sentiment Analysis result for Predictions category + public struct Sentiment { + public let predominantSentiment: Kind + public let sentimentScores: [Kind: Double]? + + public init( + predominantSentiment: Kind, + sentimentScores: [Kind: Double]? + ) { + self.predominantSentiment = predominantSentiment + self.sentimentScores = sentimentScores + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/SyntaxToken.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/SyntaxToken.swift new file mode 100644 index 0000000000..56163d7960 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/SyntaxToken.swift @@ -0,0 +1,29 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +extension Predictions { + /// Describes syntactical information resulting from text interpretation as + /// a result of interpret() API + public struct SyntaxToken { + public let tokenId: Int + public let text: String + public let range: Range + public let detectedPartOfSpeech: PartOfSpeech.DetectionResult + + public init( + tokenId: Int, + text: String, + range: Range, + detectedPartOfSpeech: PartOfSpeech.DetectionResult + ) { + self.tokenId = tokenId + self.text = text + self.range = range + self.detectedPartOfSpeech = detectedPartOfSpeech + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Table.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Table.swift new file mode 100644 index 0000000000..82e9a812dd --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Table.swift @@ -0,0 +1,61 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import CoreGraphics + +extension Predictions { + public struct Table { + public var rows: Int + public var columns: Int + public var cells: [Cell] + + public init() { + self.rows = 0 + self.columns = 0 + self.cells = [Cell]() + } + } +} + +extension Predictions.Table { + public struct Cell { + public let text: String + + /// The location of the recognized text on the image. It includes an axis-aligned, + /// coarse bounding box that surrounds the text in the table + public let boundingBox: CGRect + + /// The location of the recognized text on the image in a finer-grain polygon than + /// the bounding box for more accurate spatial information of where the text is in the table + public let polygon: Predictions.Polygon + public let isSelected: Bool + public let rowIndex: Int + public let columnIndex: Int + public let rowSpan: Int + public let columnSpan: Int + + public init( + text: String, + boundingBox: CGRect, + polygon: Predictions.Polygon, + isSelected: Bool, + rowIndex: Int, + columnIndex: Int, + rowSpan: Int, + columnSpan: Int + ) { + self.text = text + self.boundingBox = boundingBox + self.polygon = polygon + self.isSelected = isSelected + self.rowIndex = rowIndex + self.columnIndex = columnIndex + self.rowSpan = rowSpan + self.columnSpan = columnSpan + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/TextFormatType.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/TextFormatType.swift new file mode 100644 index 0000000000..7fa380acf9 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/TextFormatType.swift @@ -0,0 +1,19 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +extension Predictions { + /// Describes different text formats passed a type parameter + /// to identify(). + public struct TextFormatType: Equatable { + let id: UInt8 + + public static let all = Self(id: 0) + public static let form = Self(id: 1) + public static let table = Self(id: 2) + public static let plain = Self(id: 3) + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Voice.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Voice.swift new file mode 100644 index 0000000000..5cc73e87f4 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Models/Voice.swift @@ -0,0 +1,16 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +extension Predictions { + public struct Voice { + public let id: String + + public init(id: String) { + self.id = id + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/PredictionsCategory+ClientBehavior.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/PredictionsCategory+ClientBehavior.swift new file mode 100644 index 0000000000..f9628e0b7d --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/PredictionsCategory+ClientBehavior.swift @@ -0,0 +1,35 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension PredictionsCategory: PredictionsCategoryBehavior { + public func identify( + _ request: Predictions.Identify.Request, + in image: URL, + options: Predictions.Identify.Options? = nil + ) async throws -> Output { + try await plugin.identify(request, in: image, options: options) + } + + public func convert( + _ request: Predictions.Convert.Request, + options: Options? = nil + ) async throws -> Output { + try await plugin.convert(request, options: options) + } + + public func interpret( + text: String, + options: Predictions.Interpret.Options? = nil + ) async throws -> Predictions.Interpret.Result { + try await plugin.interpret( + text: text, + options: options + ) + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/PredictionsCategory+HubPayloadEventName.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/PredictionsCategory+HubPayloadEventName.swift new file mode 100644 index 0000000000..31125ab929 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/PredictionsCategory+HubPayloadEventName.swift @@ -0,0 +1,12 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public extension HubPayload.EventName { + struct Predictions {} +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/PredictionsCategory.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/PredictionsCategory.swift new file mode 100644 index 0000000000..7a47cdfd82 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/PredictionsCategory.swift @@ -0,0 +1,114 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +public enum Predictions {} + +final public class PredictionsCategory: Category { + + public let categoryType = CategoryType.predictions + + var plugins = [PluginKey: PredictionsCategoryPlugin]() + + /// Returns the plugin added to the category, if only one plugin is added. Accessing this property if no plugins + /// are added, or if more than one plugin is added, will cause a preconditionFailure. + var plugin: PredictionsCategoryPlugin { + guard isConfigured else { + return Fatal.preconditionFailure( + """ + \(categoryType.displayName) category is not configured. Call Amplify.configure() before using \ + any methods on the category. + """ + ) + } + + guard !plugins.isEmpty else { + return Fatal.preconditionFailure("No plugins added to \(categoryType.displayName) category.") + } + + guard plugins.count == 1 else { + return Fatal.preconditionFailure( + """ + More than 1 plugin added to \(categoryType.displayName) category. \ + You must invoke operations on this category by getting the plugin you want, as in: + #"Amplify.\(categoryType.displayName).getPlugin(for: "ThePluginKey").foo() + """ + ) + } + + return plugins.first!.value + } + + var isConfigured = false + + // MARK: - Plugin handling + + /// Adds `plugin` to the list of Plugins that implement functionality for this category. + /// + /// - Parameter plugin: The Plugin to add + public func add(plugin: PredictionsCategoryPlugin) throws { + let key = plugin.key + guard !key.isEmpty else { + let pluginDescription = String(describing: plugin) + throw PredictionsError.client( + .init( + description: "Plugin \(pluginDescription) has an empty `key`.", + recoverySuggestion: "Set the `key` property for \(pluginDescription)", + underlyingError: nil + ) + ) + } + + guard !isConfigured else { + let pluginDescription = String(describing: plugin) + throw PredictionsError.client( + .init( + description: "\(pluginDescription) cannot be added after `Amplify.configure()`.", + recoverySuggestion: "Do not add plugins after calling `Amplify.configure()`.", + underlyingError: nil + ) + ) + } + + plugins[plugin.key] = plugin + } + + /// Returns the added plugin with the specified `key` property. + /// + /// - Parameter key: The PluginKey (String) of the plugin to retrieve + /// - Returns: The wrapped plugin + public func getPlugin(for key: PluginKey) throws -> PredictionsCategoryPlugin { + guard let plugin = plugins[key] else { + let keys = plugins.keys.joined(separator: ", ") + throw PredictionsError.client( + .init( + description: "No plugin has been added for '\(key)'.", + recoverySuggestion: "Either add a plugin for '\(key)', or use one of the known keys: \(keys)", + underlyingError: nil + ) + ) + } + return plugin + } + + /// Removes the plugin registered for `key` from the list of Plugins that implement functionality for this category. + /// If no plugin has been added for `key`, no action is taken, making this method safe to call multiple times. + /// + /// - Parameter key: The key used to `add` the plugin + public func removePlugin(for key: PluginKey) { + plugins.removeValue(forKey: key) + } + +} + +extension PredictionsCategory: DefaultLogger { + public static var log: Logger { + Amplify.Logging.logger(forCategory: CategoryType.predictions.displayName, forNamespace: String(describing: self)) + } + public var log: Logger { + Self.log + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/PredictionsCategoryBehavior.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/PredictionsCategoryBehavior.swift new file mode 100644 index 0000000000..c5639603fa --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/PredictionsCategoryBehavior.swift @@ -0,0 +1,43 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Behavior of the Predictions category that clients will use +public protocol PredictionsCategoryBehavior { + + /// Detect contents of an image based on `IdentifyAction` + /// - Parameter type: The type of image detection you want to perform + /// - Parameter image: The image you are sending + /// - Parameter options: Parameters to specific plugin behavior + /// - Parameter listener: Triggered when the event occurs + func identify( + _ request: Predictions.Identify.Request, + in image: URL, + options: Predictions.Identify.Options? + ) async throws -> Output + + /// + /// - Parameters: + /// - request: + /// - options: + /// - Returns: + func convert( + _ request: Predictions.Convert.Request, + options: Options? + ) async throws -> Output + + /// Interpret the text and return sentiment analysis, entity detection, language detection, + /// syntax detection, key phrases detection + /// - Parameter text: Text to interpret + /// - Parameter options:Parameters to specific plugin behavior + /// - Parameter options:Parameters to specific plugin behavior + func interpret( + text: String, + options: Predictions.Interpret.Options? + ) async throws -> Predictions.Interpret.Result +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/PredictionsCategoryConfiguration.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/PredictionsCategoryConfiguration.swift new file mode 100644 index 0000000000..b84288356c --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/PredictionsCategoryConfiguration.swift @@ -0,0 +1,17 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public struct PredictionsCategoryConfiguration: CategoryConfiguration { + public let plugins: [String: JSONValue] + + /// Initialize `plugins` map + public init(plugins: [String: JSONValue] = [:]) { + self.plugins = plugins + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/PredictionsCategoryPlugin.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/PredictionsCategoryPlugin.swift new file mode 100644 index 0000000000..6b21259a67 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/PredictionsCategoryPlugin.swift @@ -0,0 +1,14 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +public protocol PredictionsCategoryPlugin: Plugin, PredictionsCategoryBehavior { } + +public extension PredictionsCategoryPlugin { + var categoryType: CategoryType { + return .predictions + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Convert/Convert+Lift.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Convert/Convert+Lift.swift new file mode 100644 index 0000000000..9f450dc17c --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Convert/Convert+Lift.swift @@ -0,0 +1,43 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension Predictions.Convert.Request.Kind { + @_spi(PredictionsConvertRequestKind) + public struct Lift< + SpecificInput, + GenericInput, + SpecificOptions, + GenericOptions, + SpecificOutput, + GenericOutput + > { + public let inputSpecificToGeneric: (SpecificInput) -> GenericInput + public let inputGenericToSpecific: (GenericInput) -> SpecificInput + public let optionsSpecificToGeneric: (SpecificOptions) -> GenericOptions + public let optionsGenericToSpecific: (GenericOptions) -> SpecificOptions + public let outputSpecificToGeneric: (SpecificOutput) -> GenericOutput + public let outputGenericToSpecific: (GenericOutput) -> SpecificOutput + } +} + +extension Predictions.Convert.Request.Kind.Lift where +GenericInput == SpecificInput, +GenericOptions == SpecificOptions, +GenericOutput == SpecificOutput { + static var lift: Self { + .init( + inputSpecificToGeneric: { $0 }, + inputGenericToSpecific: { $0 }, + optionsSpecificToGeneric: { $0 }, + optionsGenericToSpecific: { $0 }, + outputSpecificToGeneric: { $0 }, + outputGenericToSpecific: { $0 } + ) + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Convert/Convert+SpeechToText+Options.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Convert/Convert+SpeechToText+Options.swift new file mode 100644 index 0000000000..b1a6f3a526 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Convert/Convert+SpeechToText+Options.swift @@ -0,0 +1,33 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension Predictions.Convert.SpeechToText { + public struct Options { + /// The default NetworkPolicy for the operation. The default value will be `auto`. + public let defaultNetworkPolicy: DefaultNetworkPolicy + + /// The language of the audio file you are transcribing + public let language: Predictions.Language? + + /// Extra plugin specific options, only used in special circumstances when the existing options do not + /// provide a way to utilize the underlying storage system's functionality. See plugin documentation for + /// expected key/values + public let pluginOptions: Any? + + public init( + defaultNetworkPolicy: DefaultNetworkPolicy = .auto, + language: Predictions.Language? = nil, + pluginOptions: Any? = nil + ) { + self.defaultNetworkPolicy = defaultNetworkPolicy + self.language = language + self.pluginOptions = pluginOptions + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Convert/Convert+SpeechToText+Request.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Convert/Convert+SpeechToText+Request.swift new file mode 100644 index 0000000000..d20625afc6 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Convert/Convert+SpeechToText+Request.swift @@ -0,0 +1,23 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension Predictions.Convert.SpeechToText { + public struct Request { + /// The text to synthesize to speech + public let speechToText: URL + + /// Options to adjust the behavior of this request, including plugin options + public let options: Options + + public init(speechToText: URL, options: Options) { + self.speechToText = speechToText + self.options = options + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Convert/Convert+SpeechToText+Result.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Convert/Convert+SpeechToText+Result.swift new file mode 100644 index 0000000000..bc597ec3ba --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Convert/Convert+SpeechToText+Result.swift @@ -0,0 +1,21 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension Predictions.Convert.SpeechToText { + /// Results are mapped to SpeechToTextResult when convert() API is + /// called to convert a text to audio + public struct Result { + /// Resulting string from speech to text conversion + public let transcription: String + + public init(transcription: String) { + self.transcription = transcription + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Convert/Convert+SpeechToText.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Convert/Convert+SpeechToText.swift new file mode 100644 index 0000000000..c60d368d9c --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Convert/Convert+SpeechToText.swift @@ -0,0 +1,22 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension Predictions.Convert { + public enum SpeechToText {} +} + +extension Predictions.Convert.Request where +Input == URL, +Options == Predictions.Convert.SpeechToText.Options, +Output == AsyncThrowingStream { + + public static func speechToText(url: URL) -> Self { + .init(input: url, kind: .speechToText(.lift)) + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Convert/Convert+TextToSpeech+Options.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Convert/Convert+TextToSpeech+Options.swift new file mode 100644 index 0000000000..59d0ea93f1 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Convert/Convert+TextToSpeech+Options.swift @@ -0,0 +1,33 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension Predictions.Convert.TextToSpeech { + public struct Options { + /// The default NetworkPolicy for the operation. The default value will be `auto`. + public let defaultNetworkPolicy: DefaultNetworkPolicy + + /// The voice type selected for synthesizing text to speech + public let voice: Predictions.Voice? + + /// Extra plugin specific options, only used in special circumstances when the existing options do not provide + /// a way to utilize the underlying storage system's functionality. See plugin documentation for expected + /// key/values + public let pluginOptions: Any? + + public init( + defaultNetworkPolicy: DefaultNetworkPolicy = .auto, + voice: Predictions.Voice? = nil, + pluginOptions: Any? = nil + ) { + self.defaultNetworkPolicy = defaultNetworkPolicy + self.pluginOptions = pluginOptions + self.voice = voice + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Convert/Convert+TextToSpeech+Request.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Convert/Convert+TextToSpeech+Request.swift new file mode 100644 index 0000000000..fa38c3fd28 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Convert/Convert+TextToSpeech+Request.swift @@ -0,0 +1,23 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension Predictions.Convert.TextToSpeech { + public struct Request { + /// The text to synthesize to speech + public let textToSpeech: String + + /// Options to adjust the behavior of this request, including plugin options + public let options: Options + + public init(textToSpeech: String, options: Options) { + self.textToSpeech = textToSpeech + self.options = options + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Convert/Convert+TextToSpeech+Result.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Convert/Convert+TextToSpeech+Result.swift new file mode 100644 index 0000000000..d1ee3019d5 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Convert/Convert+TextToSpeech+Result.swift @@ -0,0 +1,21 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension Predictions.Convert.TextToSpeech { + /// Results are mapped to TextToSpeechResult when convert() API is + /// called to convert a text to audio + public struct Result { + /// Resulting audio from text to speech conversion + public let audioData: Data + + public init(audioData: Data) { + self.audioData = audioData + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Convert/Convert+TextToSpeech.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Convert/Convert+TextToSpeech.swift new file mode 100644 index 0000000000..d15b83fa4b --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Convert/Convert+TextToSpeech.swift @@ -0,0 +1,22 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension Predictions.Convert { + public enum TextToSpeech {} +} + +extension Predictions.Convert.Request where +Input == String, +Options == Predictions.Convert.TextToSpeech.Options, +Output == Predictions.Convert.TextToSpeech.Result { + + public static func textToSpeech(_ text: String) -> Self { + .init(input: text, kind: .textToSpeech(.lift)) + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Convert/Convert+TranslateText+Options.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Convert/Convert+TranslateText+Options.swift new file mode 100644 index 0000000000..b26b75d33e --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Convert/Convert+TranslateText+Options.swift @@ -0,0 +1,28 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension Predictions.Convert.TranslateText { + public struct Options { + /// The default NetworkPolicy for the operation. The default value will be `auto`. + public let defaultNetworkPolicy: DefaultNetworkPolicy + + /// Extra plugin specific options, only used in special circumstances when the existing options do not provide + /// a way to utilize the underlying storage system's functionality. See plugin documentation for expected + /// key/values + public let pluginOptions: Any? + + public init( + defaultNetworkPolicy: DefaultNetworkPolicy = .auto, + pluginOptions: Any? = nil + ) { + self.defaultNetworkPolicy = defaultNetworkPolicy + self.pluginOptions = pluginOptions + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Convert/Convert+TranslateText+Request.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Convert/Convert+TranslateText+Request.swift new file mode 100644 index 0000000000..e8832751e5 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Convert/Convert+TranslateText+Request.swift @@ -0,0 +1,36 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension Predictions.Convert.TranslateText { + public struct Request { + /// The text to translate. + public let textToTranslate: String + + /// The language to translate + public let targetLanguage: Predictions.Language? + + /// Source language of the text given. + public let language: Predictions.Language? + + /// Options to adjust the behavior of this request, including plugin options + public let options: Options + + public init( + textToTranslate: String, + targetLanguage: Predictions.Language?, + language: Predictions.Language?, + options: Options + ) { + self.textToTranslate = textToTranslate + self.language = language + self.targetLanguage = targetLanguage + self.options = options + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Convert/Convert+TranslateText+Result.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Convert/Convert+TranslateText+Result.swift new file mode 100644 index 0000000000..920eff83ba --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Convert/Convert+TranslateText+Result.swift @@ -0,0 +1,23 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +extension Predictions.Convert.TranslateText { + /// Results are mapped to TranslateTextResult when convert() API is + /// called to translate a text into another language + public struct Result { + /// Translated text + public let text: String + + /// Language to which the text was translated. + public let targetLanguage: Predictions.Language + + public init(text: String, targetLanguage: Predictions.Language) { + self.text = text + self.targetLanguage = targetLanguage + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Convert/Convert+TranslateText.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Convert/Convert+TranslateText.swift new file mode 100644 index 0000000000..572bb3c46c --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Convert/Convert+TranslateText.swift @@ -0,0 +1,29 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension Predictions.Convert { + public enum TranslateText {} +} + +extension Predictions.Convert.Request where +Input == (String, Predictions.Language?, Predictions.Language?), +Options == Predictions.Convert.TranslateText.Options, +Output == Predictions.Convert.TranslateText.Result { + + public static func translateText( + _ text: String, + from: Predictions.Language? = nil, + to: Predictions.Language? = nil + ) -> Self { + .init( + input: (text, from, to), + kind: .textToTranslate(.lift) + ) + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Convert/Convert.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Convert/Convert.swift new file mode 100644 index 0000000000..019bf95255 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Convert/Convert.swift @@ -0,0 +1,49 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension Predictions { + public enum Convert { + public struct Request { + public let input: Input + @_spi(PredictionsConvertRequestKind) + public let kind: Kind + } + } +} + +extension Predictions.Convert.Request { + @_spi(PredictionsConvertRequestKind) + public enum Kind { + public typealias BidirectionalLift = ((T) -> U, (U) -> T) + + case textToSpeech( + Lift< + String, Input, + Predictions.Convert.TextToSpeech.Options?, Options?, + Predictions.Convert.TextToSpeech.Result, Output + > + ) + + case speechToText( + Lift< + URL, Input, + Predictions.Convert.SpeechToText.Options?, Options?, + AsyncThrowingStream, Output + > + ) + + case textToTranslate( + Lift< + (String, Predictions.Language?, Predictions.Language?), Input, + Predictions.Convert.TranslateText.Options?, Options?, + Predictions.Convert.TranslateText.Result, Output + > + ) + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Identify/Identify+Celebrities+Result.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Identify/Identify+Celebrities+Result.swift new file mode 100644 index 0000000000..8da0888ba6 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Identify/Identify+Celebrities+Result.swift @@ -0,0 +1,18 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +extension Predictions.Identify.Celebrities { + /// Results are mapped to IdentifyCelebritiesResult when .detectCelebrity in passed in the type: field + /// in identify() API + public struct Result { + public let celebrities: [Predictions.Celebrity] + + public init(celebrities: [Predictions.Celebrity]) { + self.celebrities = celebrities + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Identify/Identify+Celebrities.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Identify/Identify+Celebrities.swift new file mode 100644 index 0000000000..05cf69b606 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Identify/Identify+Celebrities.swift @@ -0,0 +1,18 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension Predictions.Identify { + public enum Celebrities {} +} + +extension Predictions.Identify.Request where Output == Predictions.Identify.Celebrities.Result { + public static let celebrities = Self( + kind: .detectCelebrities(.lift) + ) +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Identify/Identify+Document.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Identify/Identify+Document.swift new file mode 100644 index 0000000000..d8217fdc6a --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Identify/Identify+Document.swift @@ -0,0 +1,18 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension Predictions.Identify { + public enum DocumentText {} +} + +extension Predictions.Identify.Request where Output == Predictions.Identify.DocumentText.Result { + public static func textInDocument(textFormatType: Predictions.TextFormatType) -> Self { + .init(kind: .detectTextInDocument(textFormatType, .lift)) + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Identify/Identify+DocumentText+Result.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Identify/Identify+DocumentText+Result.swift new file mode 100644 index 0000000000..430667429a --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Identify/Identify+DocumentText+Result.swift @@ -0,0 +1,39 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +extension Predictions.Identify.DocumentText { + /// Results are mapped to IdentifyDocumentTextResult when .form, .table + /// or .all is passed for .detectText in the type: field + /// in identify() API + public struct Result { + public let fullText: String + public let words: [Predictions.IdentifiedWord] + public let rawLineText: [String] + public let identifiedLines: [Predictions.IdentifiedLine] + public let selections: [Predictions.Selection] + public let tables: [Predictions.Table] + public let keyValues: [Predictions.BoundedKeyValue] + + public init( + fullText: String, + words: [Predictions.IdentifiedWord], + rawLineText: [String], + identifiedLines: [Predictions.IdentifiedLine], + selections: [Predictions.Selection], + tables: [Predictions.Table], + keyValues: [Predictions.BoundedKeyValue] + ) { + self.fullText = fullText + self.words = words + self.rawLineText = rawLineText + self.identifiedLines = identifiedLines + self.selections = selections + self.tables = tables + self.keyValues = keyValues + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Identify/Identify+Entities+Result.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Identify/Identify+Entities+Result.swift new file mode 100644 index 0000000000..ba7d2041ea --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Identify/Identify+Entities+Result.swift @@ -0,0 +1,20 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +extension Predictions.Identify.Entities { + /// Results are mapped to IdentifyEntitiesResult when .detectEntities is + /// passed to type: field in identify() API and general entities like facial features, landmarks etc. + /// are needed to be detected + public struct Result { + /// List of 'Entity' as a result of Identify query + public let entities: [Predictions.Entity] + + public init(entities: [Predictions.Entity]) { + self.entities = entities + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Identify/Identify+Entities.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Identify/Identify+Entities.swift new file mode 100644 index 0000000000..ad2fe77840 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Identify/Identify+Entities.swift @@ -0,0 +1,18 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension Predictions.Identify { + public enum Entities {} +} + +extension Predictions.Identify.Request where Output == Predictions.Identify.Entities.Result { + public static let entities = Self( + kind: .detectEntities(.lift) + ) +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Identify/Identify+EntityMatches+Result.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Identify/Identify+EntityMatches+Result.swift new file mode 100644 index 0000000000..57878acea0 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Identify/Identify+EntityMatches+Result.swift @@ -0,0 +1,20 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +extension Predictions.Identify.EntityMatches { + /// Results are mapped to IdentifyEntityMatchesResult when .detectEntities is + /// passed to type: field in identify() API and matches from your Rekognition Collection + /// need to be identified + public struct Result { + /// List of matched `Entity.Match` + public let entities: [Predictions.Entity.Match] + + public init(entities: [Predictions.Entity.Match]) { + self.entities = entities + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Identify/Identify+EntityMatches.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Identify/Identify+EntityMatches.swift new file mode 100644 index 0000000000..c31b8f1ed8 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Identify/Identify+EntityMatches.swift @@ -0,0 +1,18 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension Predictions.Identify { + public enum EntityMatches {} +} + +extension Predictions.Identify.Request where Output == Predictions.Identify.EntityMatches.Result { + public static func entitiesFromCollection(withID collectionID: String) -> Self { + .init(kind: .detectEntitiesCollection(collectionID, .lift)) + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Identify/Identify+Labels+Result.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Identify/Identify+Labels+Result.swift new file mode 100644 index 0000000000..245a2f14da --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Identify/Identify+Labels+Result.swift @@ -0,0 +1,61 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import CoreGraphics + +extension Predictions.Identify.Labels { + /// Results are mapped to IdentifyLabelsResult when .labels in passed to .detectLabels + /// in the type: field in identify() API + public struct Result { + public let labels: [Predictions.Label] + public let unsafeContent: Bool? + + public init(labels: [Predictions.Label], unsafeContent: Bool? = nil) { + self.labels = labels + self.unsafeContent = unsafeContent + } + } +} + +extension Predictions { + /// Describes a real world object (e.g., chair, desk) identified in an image + public struct Label { + public let name: String + public let metadata: Metadata? + public let boundingBoxes: [CGRect]? + + public init( + name: String, + metadata: Metadata? = nil, + boundingBoxes: [CGRect]? = nil + ) { + self.name = name + self.metadata = metadata + self.boundingBoxes = boundingBoxes + } + } + + public struct Parent { + public let name: String + + public init(name: String) { + self.name = name + } + } +} + +extension Predictions.Label { + public struct Metadata { + public let confidence: Double + public let parents: [Predictions.Parent]? + + public init(confidence: Double, parents: [Predictions.Parent]? = nil) { + self.confidence = confidence + self.parents = parents + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Identify/Identify+Labels.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Identify/Identify+Labels.swift new file mode 100644 index 0000000000..51698b50b9 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Identify/Identify+Labels.swift @@ -0,0 +1,18 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension Predictions.Identify { + public enum Labels {} +} + +extension Predictions.Identify.Request where Output == Predictions.Identify.Labels.Result { + public static func labels(type: Predictions.LabelType = .labels) -> Self { + .init(kind: .detectLabels(type, .lift)) + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Identify/Identify+Lift.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Identify/Identify+Lift.swift new file mode 100644 index 0000000000..09654ba367 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Identify/Identify+Lift.swift @@ -0,0 +1,27 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension Predictions.Identify.Request.Kind { + public struct Lift< + SpecificOutput, + GenericOutput + > { + public let outputSpecificToGeneric: (SpecificOutput) -> GenericOutput + public let outputGenericToSpecific: (GenericOutput) -> SpecificOutput + } +} + +extension Predictions.Identify.Request.Kind.Lift where GenericOutput == SpecificOutput { + static var lift: Self { + .init( + outputSpecificToGeneric: { $0 }, + outputGenericToSpecific: { $0 } + ) + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Identify/Identify+Text+Result.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Identify/Identify+Text+Result.swift new file mode 100644 index 0000000000..39f66964a0 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Identify/Identify+Text+Result.swift @@ -0,0 +1,29 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +extension Predictions.Identify.Text { + /// Results are mapped to IdentifyTextResult when .plain is passed for .detectText in the type: field + /// in identify() API + public struct Result { + public let fullText: String? + public let words: [Predictions.IdentifiedWord]? + public let rawLineText: [String]? + public let identifiedLines: [Predictions.IdentifiedLine]? + + public init( + fullText: String?, + words: [Predictions.IdentifiedWord]?, + rawLineText: [String]?, + identifiedLines: [Predictions.IdentifiedLine]? + ) { + self.fullText = fullText + self.words = words + self.rawLineText = rawLineText + self.identifiedLines = identifiedLines + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Identify/Identify+Text.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Identify/Identify+Text.swift new file mode 100644 index 0000000000..4db181e3bf --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Identify/Identify+Text.swift @@ -0,0 +1,18 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension Predictions.Identify { + public enum Text {} +} + +extension Predictions.Identify.Request where Output == Predictions.Identify.Text.Result { + public static let text = Self( + kind: .detectText(.lift) + ) +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Identify/Identify.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Identify/Identify.swift new file mode 100644 index 0000000000..d033b26965 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Identify/Identify.swift @@ -0,0 +1,72 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Identification criteria provided to +/// type parameter in identify() API +extension Predictions { + public enum Identify { + public struct Request { + @_spi(PredictionsIdentifyRequestKind) + public let kind: Kind + } + + public struct Options { + /// The default NetworkPolicy for the operation. The default value will be `auto`. + public let defaultNetworkPolicy: DefaultNetworkPolicy + /// Extra plugin specific options, only used in special circumstances when the existing options do not provide + /// a way to utilize the underlying storage system's functionality. See plugin documentation for expected + /// key/values + public let pluginOptions: Any? + + public init( + defaultNetworkPolicy: DefaultNetworkPolicy = .auto, + uploadToRemote: Bool = false, + pluginOptions: Any? = nil + ) { + self.defaultNetworkPolicy = defaultNetworkPolicy + self.pluginOptions = pluginOptions + + } + } + } +} + +extension Predictions.Identify.Request { + @_spi(PredictionsIdentifyRequestKind) + public enum Kind { + public typealias Lifting = ((T) -> Output, (Output) -> T) + + case detectCelebrities( + Lift + ) + + case detectEntities( + Lift + ) + + case detectEntitiesCollection( + String, + Lift + ) + + case detectLabels( + Predictions.LabelType, + Lift + ) + + case detectTextInDocument( + Predictions.TextFormatType, + Lift + ) + + case detectText( + Lift + ) + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Interpret/Interpret+Result.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Interpret/Interpret+Result.swift new file mode 100644 index 0000000000..127cdc41a5 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Interpret/Interpret+Result.swift @@ -0,0 +1,30 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +extension Predictions.Interpret { + public struct Result { + public let keyPhrases: [Predictions.KeyPhrase]? + public let sentiment: Predictions.Sentiment? + public let entities: [Predictions.Entity.DetectionResult]? + public let language: Predictions.Language.DetectionResult? + public let syntax: [Predictions.SyntaxToken]? + + public init( + keyPhrases: [Predictions.KeyPhrase]?, + sentiment: Predictions.Sentiment?, + entities: [Predictions.Entity.DetectionResult]?, + language: Predictions.Language.DetectionResult?, + syntax: [Predictions.SyntaxToken]? + ) { + self.keyPhrases = keyPhrases + self.sentiment = sentiment + self.entities = entities + self.language = language + self.syntax = syntax + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Interpret/Interpret.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Interpret/Interpret.swift new file mode 100644 index 0000000000..fddb1095d6 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Request/Interpret/Interpret.swift @@ -0,0 +1,30 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension Predictions { + public enum Interpret {} +} + +public extension Predictions.Interpret { + struct Options { + /// The defaultNetworkPolicy for the operation. The default value will be `auto`. + public let defaultNetworkPolicy: DefaultNetworkPolicy + + /// Extra plugin specific options, only used in special circumstances when the existing options do not provide + /// a way to utilize the underlying storage system's functionality. See plugin documentation for expected + /// key/values + public let pluginOptions: Any? + + public init(defaultNetworkPolicy: DefaultNetworkPolicy = .auto, + pluginOptions: Any? = nil) { + self.defaultNetworkPolicy = defaultNetworkPolicy + self.pluginOptions = pluginOptions + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Error/StorageError.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Error/StorageError.swift new file mode 100644 index 0000000000..a34065650e --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Error/StorageError.swift @@ -0,0 +1,137 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// Errors thrown by implementations of the +/// [StorageCategoryPlugin](x-source-tag://StorageCategoryPlugin) protocol. +/// +/// - Tag: StorageError +public enum StorageError { + + /// Surfaced when a storage operation is attempted for either a file or a key to which the current user + /// does not have access. + /// + /// - Tag: StorageError.accessDenied + case accessDenied(ErrorDescription, RecoverySuggestion, Error? = nil) + + /// Surfaced when a storage operation is unable to resolve the current user. + /// + /// - Tag: StorageError.authError + case authError(ErrorDescription, RecoverySuggestion, Error? = nil) + + /// Surfaced when a storage plugin encounters an error during its configuration. + /// + /// - Tag: StorageError.configuration + case configuration(ErrorDescription, RecoverySuggestion, Error? = nil) + + /// Surfaced when a storage operation encounters an HTTP status code it considers an error. + /// + /// - Tag: StorageError.httpStatusError + case httpStatusError(Int, RecoverySuggestion, Error? = nil) + + /// Surfaced when a storage operation encounters an HTTP status code it considers an error. + /// + /// - Tag: StorageError.keyNotFound + case keyNotFound(Key, ErrorDescription, RecoverySuggestion, Error? = nil) + + /// Surfaced when a storage operation is unable to find a local file, usually when attempting to upload. + /// + /// - Tag: StorageError.localFileNotFound + case localFileNotFound(ErrorDescription, RecoverySuggestion, Error? = nil) + + /// Surfaced when a storage operation encounters an unexpected server-side error. + /// + /// - Tag: StorageError.service + case service(ErrorDescription, RecoverySuggestion, Error? = nil) + + /// Surfaced when a storage operation encounters an general unexpected error. + /// + /// - Tag: StorageError.unknown + case unknown(ErrorDescription, Error? = nil) + + /// Surfaced when a storage operation encounters invalid input. + /// + /// - Tag: StorageError.validation + case validation(Field, ErrorDescription, RecoverySuggestion, Error? = nil) +} + +extension StorageError: AmplifyError { + public var errorDescription: ErrorDescription { + switch self { + case .accessDenied(let errorDescription, _, _), + .authError(let errorDescription, _, _), + .configuration(let errorDescription, _, _), + .service(let errorDescription, _, _), + .localFileNotFound(let errorDescription, _, _): + return errorDescription + case .httpStatusError(let statusCode, _, _): + return "The HTTP response status code is [\(statusCode)]." + case .keyNotFound(let key, let errorDescription, _, _): + return "The key '\(key)' could not be found with message: \(errorDescription)." + case .unknown(let errorDescription, _): + return "Unexpected error occurred with message: \(errorDescription)" + case .validation(let field, let errorDescription, _, _): + return "There is a client side validation error for the field [\(field)] with message: \(errorDescription)" + } + } + + public var recoverySuggestion: RecoverySuggestion { + switch self { + case .accessDenied(_, let recoverySuggestion, _), + .authError(_, let recoverySuggestion, _), + .configuration(_, let recoverySuggestion, _), + .localFileNotFound(_, let recoverySuggestion, _), + .service(_, let recoverySuggestion, _), + .validation(_, _, let recoverySuggestion, _): + return recoverySuggestion + case .httpStatusError(_, let recoverySuggestion, _): + return """ + \(recoverySuggestion). + For more information on HTTP status codes, take a look at + https://en.wikipedia.org/wiki/List_of_HTTP_status_codes + """ + case .keyNotFound(_, _, let recoverySuggestion, _): + return """ + \(recoverySuggestion) + The object for key in the public access level should exist under the public folder as public/. + When looking for the key in protected or private access level, it will be under its respective folder + such as 'protected//' or 'private//'. + """ + case .unknown: + return AmplifyErrorMessages.shouldNotHappenReportBugToAWS() + } + } + + public var underlyingError: Error? { + switch self { + case .accessDenied(_, _, let underlyingError), + .authError(_, _, let underlyingError), + .configuration(_, _, let underlyingError), + .httpStatusError(_, _, let underlyingError), + .keyNotFound(_, _, _, let underlyingError), + .localFileNotFound(_, _, let underlyingError), + .service(_, _, let underlyingError), + .unknown(_, let underlyingError), + .validation(_, _, _, let underlyingError): + return underlyingError + } + } + + public init( + errorDescription: ErrorDescription = "An unknown error occurred", + recoverySuggestion: RecoverySuggestion = "(Ignored)", + error: Error + ) { + if let error = error as? Self { + self = error + } else if error.isOperationCancelledError { + self = .unknown("Operation cancelled", error) + } else { + self = .unknown(errorDescription, error) + } + } + +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Internal/StorageCategory+CategoryConfigurable.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Internal/StorageCategory+CategoryConfigurable.swift new file mode 100644 index 0000000000..caf4552793 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Internal/StorageCategory+CategoryConfigurable.swift @@ -0,0 +1,34 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +extension StorageCategory: CategoryConfigurable { + + func configure(using configuration: CategoryConfiguration?) throws { + guard !isConfigured else { + let error = ConfigurationError.amplifyAlreadyConfigured( + "\(categoryType.displayName) has already been configured.", + "Remove the duplicate call to `Amplify.configure()`" + ) + throw error + } + + try Amplify.configure(plugins: Array(plugins.values), using: configuration) + + isConfigured = true + } + + func configure(using amplifyConfiguration: AmplifyConfiguration) throws { + try configure(using: categoryConfiguration(from: amplifyConfiguration)) + } + + func configure(using amplifyOutputs: AmplifyOutputsData) throws { + for plugin in Array(plugins.values) { + try plugin.configure(using: amplifyOutputs) + } + isConfigured = true + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Internal/StorageCategory+Resettable.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Internal/StorageCategory+Resettable.swift new file mode 100644 index 0000000000..09fd10b559 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Internal/StorageCategory+Resettable.swift @@ -0,0 +1,25 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension StorageCategory: Resettable { + + public func reset() async { + await withTaskGroup(of: Void.self) { taskGroup in + for plugin in plugins.values { + taskGroup.addTask { [weak self] in + self?.log.verbose("Resetting \(String(describing: self?.categoryType)) plugin") + await plugin.reset() + self?.log.verbose("Resetting \(String(describing: self?.categoryType)) plugin: finished") + } + } + await taskGroup.waitForAll() + } + isConfigured = false + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/Request/StorageDownloadDataRequest.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/Request/StorageDownloadDataRequest.swift new file mode 100644 index 0000000000..1a8ed260b9 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/Request/StorageDownloadDataRequest.swift @@ -0,0 +1,110 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Represents a request to download an individual object as `Data`, initiated by an implementation of the +/// [StorageCategoryPlugin](x-source-tag://StorageCategoryPlugin) protocol. +/// +/// - Tag: StorageDownloadDataRequest +public struct StorageDownloadDataRequest: AmplifyOperationRequest { + + /// The path for the object in storage + /// + /// - Tag: StorageDownloadFileRequest.path + public let path: (any StoragePath)? + + /// The unique identifier for the object in storage + /// + /// - Tag: StorageDownloadDataRequest.key + @available(*, deprecated, message: "Use `path` instead of `key`") + public let key: String + + /// Options to adjust the behavior of this request, including plugin-options + /// + /// - Tag: StorageDownloadDataRequest.options + public let options: Options + + /// - Tag: StorageDownloadDataRequest.init + @available(*, deprecated, message: "Use init(path:local:options)") + public init(key: String, options: Options) { + self.key = key + self.options = options + self.path = nil + } + + /// - Tag: StorageDownloadDataRequest.init + public init(path: any StoragePath, options: Options) { + self.key = "" + self.options = options + self.path = path + } +} + +public extension StorageDownloadDataRequest { + + /// Options to adjust the behavior of this request, including plugin-options + /// + /// - Tag: StorageDownloadDataRequestOptions + struct Options { + + /// Access level of the storage system. Defaults to `public` + /// + /// - Tag: StorageDownloadDataRequestOptions.accessLevel + @available(*, deprecated, message: "Use `path` in Storage API instead of `Options`") + public let accessLevel: StorageAccessLevel + + /// Target user to apply the action on. + /// + /// - Tag: StorageDownloadDataRequestOptions.targetIdentityId + @available(*, deprecated, message: "Use `path` in Storage API instead of `Options`") + public let targetIdentityId: String? + + /// Extra plugin specific options, only used in special circumstances when the existing options do not provide + /// a way to utilize the underlying storage system's functionality. See plugin documentation for expected + /// key/values + /// + /// As an example, if using the AWSS3StoragePlugin, one may be want to add something like the + /// following (please note that `useAccelerateEndpoint` + /// [should first be setup](https://docs.amplify.aws/lib/storage/transfer-acceleration/q/platform/js/), + /// otherwise, requests will fail): + /// + /// ``` + /// let options = StorageDownloadDataRequest.Options( + /// pluginOptions: [ + /// "useAccelerateEndpoint": true + /// ] + /// ) + /// ``` + /// + /// # Reference + /// * [Storage - Use Transfer Acceleration](https://docs.amplify.aws/lib/storage/transfer-acceleration/q/platform/js/) + /// * [Transfer Acceleration](https://docs.aws.amazon.com/AmazonS3/latest/dev/transfer-acceleration.html) + /// + /// - Tag: StorageDownloadDataRequestOptions.pluginOptions + public let pluginOptions: Any? + + /// + /// - Tag: StorageDownloadDataRequestOptions.init + @available(*, deprecated, message: "Use init(pluginOptions)") + public init(accessLevel: StorageAccessLevel = .guest, + targetIdentityId: String? = nil, + pluginOptions: Any? = nil) { + self.accessLevel = accessLevel + self.targetIdentityId = targetIdentityId + self.pluginOptions = pluginOptions + } + + /// + /// - Tag: StorageDownloadDataRequestOptions.init + public init(pluginOptions: Any? = nil) { + self.accessLevel = .guest + self.targetIdentityId = nil + self.pluginOptions = pluginOptions + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/Request/StorageDownloadFileRequest.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/Request/StorageDownloadFileRequest.swift new file mode 100644 index 0000000000..7ec34c222f --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/Request/StorageDownloadFileRequest.swift @@ -0,0 +1,99 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Represents a request to download an individual object as a file, initiated by an implementation of the +/// [StorageCategoryPlugin](x-source-tag://StorageCategoryPlugin) protocol. +/// +/// - Tag: StorageDownloadFileRequest +public struct StorageDownloadFileRequest: AmplifyOperationRequest { + + /// The path for the object in storage + /// + /// - Tag: StorageDownloadFileRequest.path + public let path: (any StoragePath)? + + /// The unique identifier for the object in storage + /// + /// - Tag: StorageDownloadFileRequest.key + @available(*, deprecated, message: "Use `path` instead of `key`") + public let key: String + + /// The local file to download the object to + /// + /// - Tag: StorageDownloadFileRequest.local + public let local: URL + + /// Options to adjust the behavior of this request, including plugin options + /// + /// - Tag: StorageDownloadFileRequest.options + public let options: Options + + /// - Tag: StorageDownloadFileRequest.init + @available(*, deprecated, message: "Use init(path:local:options)") + public init(key: String, local: URL, options: Options) { + self.key = key + self.local = local + self.options = options + self.path = nil + } + + /// - Tag: StorageDownloadFileRequest.init + public init(path: any StoragePath, local: URL, options: Options) { + self.key = "" + self.local = local + self.options = options + self.path = path + } +} + +public extension StorageDownloadFileRequest { + + /// Options to adjust the behavior of this request, including plugin-options + /// + /// - Tag: StorageDownloadFileRequestOptions + struct Options { + + /// Access level of the storage system. Defaults to `public` + /// + /// - Tag: StorageDownloadFileRequestOptions.accessLevel + @available(*, deprecated, message: "Use `path` in Storage API instead of `Options`") + public let accessLevel: StorageAccessLevel + + /// Target user to apply the action on. + /// + /// - Tag: StorageDownloadFileRequestOptions.targetIdentityId + @available(*, deprecated, message: "Use `path` in Storage API instead of `Options`") + public let targetIdentityId: String? + + /// Extra plugin specific options, only used in special circumstances when the existing options do not provide + /// a way to utilize the underlying storage system's functionality. See plugin documentation for expected + /// key/values + /// + /// - Tag: StorageDownloadFileRequestOptions.pluginOptions + public let pluginOptions: Any? + + /// - Tag: StorageDownloadFileRequestOptions.init + @available(*, deprecated, message: "Use init(pluginOptions)") + public init(accessLevel: StorageAccessLevel = .guest, + targetIdentityId: String? = nil, + pluginOptions: Any? = nil) { + self.accessLevel = accessLevel + self.targetIdentityId = targetIdentityId + self.pluginOptions = pluginOptions + } + + /// - Tag: StorageDownloadFileRequestOptions.init + @available(*, deprecated, message: "Use init(pluginOptions)") + public init(pluginOptions: Any? = nil) { + self.accessLevel = .guest + self.targetIdentityId = nil + self.pluginOptions = pluginOptions + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/Request/StorageGetURLRequest.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/Request/StorageGetURLRequest.swift new file mode 100644 index 0000000000..e8ffd22c00 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/Request/StorageGetURLRequest.swift @@ -0,0 +1,106 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// Represents a request initiated by an implementation of the +/// [StorageCategoryPlugin](x-source-tag://StorageCategoryPlugin) protocol used to generate +/// a pre-signed download URL for a given object key. +/// +/// - Tag: StorageListRequest +public struct StorageGetURLRequest: AmplifyOperationRequest { + + /// The unique identifier for the object in storage + /// + /// - Tag: StorageGetURLRequest.key + @available(*, deprecated, message: "Use `path` in Storage API instead of `key`") + public let key: String + + /// The unique path for the object in storage + /// + /// - Tag: StorageGetURLRequest.path + public let path: (any StoragePath)? + + /// Options to adjust the behaviour of this request, including plugin-options + /// + /// - Tag: StorageGetURLRequest.options + public let options: Options + + /// - Tag: StorageGetURLRequest.init + @available(*, deprecated, message: "Use init(path:options)") + public init(key: String, options: Options) { + self.key = key + self.options = options + self.path = nil + } + + /// - Tag: StorageGetURLRequest.init + public init(path: any StoragePath, options: Options) { + self.key = "" + self.options = options + self.path = path + } +} + +public extension StorageGetURLRequest { + + /// Options to adjust the behavior of this request, including plugin-options + /// + /// - Tag: StorageGetURLRequest.Options + struct Options { + /// The default amount of time before the URL expires is 18000 seconds, or 5 hours. + /// + /// - Tag: StorageGetURLRequest.Options.defaultExpireInSeconds + public static let defaultExpireInSeconds = 18_000 + + /// Access level of the storage system. Defaults to `public` + /// + /// - Tag: StorageGetURLRequest.Options.accessLevel + @available(*, deprecated, message: "Use `path` in Storage API instead of `Options`") + public let accessLevel: StorageAccessLevel + + /// Target user to apply the action on. + /// + /// - Tag: StorageGetURLRequest.Options.targetIdentityId + @available(*, deprecated, message: "Use `path` in Storage API instead of `Options`") + public let targetIdentityId: String? + + /// Number of seconds before the URL expires. Defaults to + /// [defaultExpireInSeconds](x-source-tag://StorageListRequestOptions.defaultExpireInSeconds) + /// + /// - Tag: StorageGetURLRequest.Options.expires + public let expires: Int + + /// Extra plugin specific options, only used in special circumstances when the existing options do + /// not provide a way to utilize the underlying storage system's functionality. See plugin + /// documentation or + /// [AWSStorageGetURLOptions](x-source-tag://AWSStorageGetURLOptions) for + /// expected key/values. + /// + /// - Tag: StorageGetURLRequest.Options.pluginOptions + public let pluginOptions: Any? + + /// - Tag: StorageGetURLRequest.Options.init + @available(*, deprecated, message: "Use init(expires:pluginOptions)") + public init(accessLevel: StorageAccessLevel = .guest, + targetIdentityId: String? = nil, + expires: Int = Options.defaultExpireInSeconds, + pluginOptions: Any? = nil) { + self.accessLevel = accessLevel + self.targetIdentityId = targetIdentityId + self.expires = expires + self.pluginOptions = pluginOptions + } + + /// - Tag: StorageGetURLRequest.Options.init + public init(expires: Int = Options.defaultExpireInSeconds, + pluginOptions: Any? = nil) { + self.expires = expires + self.pluginOptions = pluginOptions + self.accessLevel = .guest + self.targetIdentityId = nil + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/Request/StorageListRequest.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/Request/StorageListRequest.swift new file mode 100644 index 0000000000..6cc7dbb496 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/Request/StorageListRequest.swift @@ -0,0 +1,108 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// Represents a object listing request initiated by an implementation of the +/// [StorageCategoryPlugin](x-source-tag://StorageCategoryPlugin) protocol. +/// +/// - Tag: StorageListRequest +public struct StorageListRequest: AmplifyOperationRequest { + + /// Options to adjust the behavior of this request, including plugin-options + /// - Tag: StorageListRequest + public let options: Options + + /// The unique path for the object in storage + /// + /// - Tag: StorageListRequest.path + public let path: (any StoragePath)? + + /// - Tag: StorageListRequest.init + @available(*, deprecated, message: "Use init(path:options)") + public init(options: Options) { + self.options = options + self.path = nil + } + + /// - Tag: StorageListRequest.init + public init(path: any StoragePath, options: Options) { + self.options = options + self.path = path + } +} + +public extension StorageListRequest { + + /// Options available to callers of + /// [StorageCategoryBehavior.list](x-source-tag://StorageCategoryBehavior.list). + /// + /// Tag: StorageListRequestOptions + struct Options { + + /// Access level of the storage system. Defaults to `public` + /// + /// - Tag: StorageListRequestOptions.accessLevel + @available(*, deprecated, message: "Use `path` in Storage API instead of `Options`") + public let accessLevel: StorageAccessLevel + + /// Target user to apply the action on + /// + /// - Tag: StorageListRequestOptions.targetIdentityId + @available(*, deprecated, message: "Use `path` in Storage API instead of `Options`") + public let targetIdentityId: String? + + /// Path to the keys + /// + /// - Tag: StorageListRequestOptions.path + @available(*, deprecated, message: "Use `path` in Storage API instead of `Options`") + public let path: String? + + /// Number between 1 and 1,000 that indicates the limit of how many entries to fetch when + /// retreiving file lists from the server. + /// + /// NOTE: Plugins may decide to throw or perform normalization when encoutering vaues outside + /// the specified range. + /// + /// - SeeAlso: + /// [StorageListRequestOptions.nextToken](x-source-tag://StorageListRequestOptions.nextToken) + /// [StorageListResult.nextToken](x-source-tag://StorageListResult.nextToken) + /// + /// - Tag: StorageListRequestOptions.pageSize + public let pageSize: UInt + + /// Opaque string indicating the page offset at which to resume a listing. This is usually a copy of + /// the value from [StorageListResult.nextToken](x-source-tag://StorageListResult.nextToken). + /// + /// - SeeAlso: + /// [StorageListRequestOptions.pageSize](x-source-tag://StorageListRequestOptions.pageSize) + /// [StorageListResult.nextToken](x-source-tag://StorageListResult.nextToken) + /// + /// - Tag: StorageListRequestOptions.nextToken + public let nextToken: String? + + /// Extra plugin specific options, only used in special circumstances when the existing options do not provide + /// a way to utilize the underlying storage system's functionality. See plugin documentation for expected + /// key/values + /// + /// - Tag: StorageListRequestOptions.pluginOptions + public let pluginOptions: Any? + + /// - Tag: StorageListRequestOptions.init + public init(accessLevel: StorageAccessLevel = .guest, + targetIdentityId: String? = nil, + path: String? = nil, + pageSize: UInt = 1000, + nextToken: String? = nil, + pluginOptions: Any? = nil) { + self.accessLevel = accessLevel + self.targetIdentityId = targetIdentityId + self.path = path + self.pageSize = pageSize + self.nextToken = nextToken + self.pluginOptions = pluginOptions + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/Request/StorageRemoveRequest.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/Request/StorageRemoveRequest.swift new file mode 100644 index 0000000000..31ae1de4f3 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/Request/StorageRemoveRequest.swift @@ -0,0 +1,73 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// Represents a object removal request initiated by an implementation of the +/// [StorageCategoryPlugin](x-source-tag://StorageCategoryPlugin) protocol. +/// +/// - Tag: StorageRemoveRequest +public struct StorageRemoveRequest: AmplifyOperationRequest { + + /// The unique identifier for the object in storage + /// + /// - Tag: StorageRemoveRequest.key + @available(*, deprecated, message: "Use `path` in Storage API instead of `key`") + public let key: String + + /// The unique path for the object in storage + /// + /// - Tag: StorageRemoveRequest.path + public let path: (any StoragePath)? + + /// Options to adjust the behavior of this request, including plugin-options + /// + /// - Tag: StorageRemoveRequest.options + public let options: Options + + /// - Tag: StorageRemoveRequest.init + @available(*, deprecated, message: "Use init(path:options)") + public init(key: String, options: Options) { + self.key = key + self.options = options + self.path = nil + } + + /// - Tag: StorageRemoveRequest.init + public init(path: any StoragePath, options: Options) { + self.key = "" + self.options = options + self.path = path + } +} + +public extension StorageRemoveRequest { + + /// Options to adjust the behavior of this request, including plugin-options + /// + /// - Tag: StorageRemoveRequestOptions + struct Options { + + /// Access level of the storage system. Defaults to `public` + /// + /// - Tag: StorageRemoveRequestOptions.accessLevel + @available(*, deprecated, message: "Use `path` in Storage API instead of `Options`") + public let accessLevel: StorageAccessLevel + + /// Extra plugin specific options, only used in special circumstances when the existing options do not provide + /// a way to utilize the underlying storage system's functionality. See plugin documentation for expected + /// key/values + /// + /// - Tag: StorageRemoveRequestOptions.pluginOptions + public let pluginOptions: Any? + + /// - Tag: StorageRemoveRequestOptions.init + public init(accessLevel: StorageAccessLevel = .guest, + pluginOptions: Any? = nil) { + self.accessLevel = accessLevel + self.pluginOptions = pluginOptions + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/Request/StorageUploadDataRequest.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/Request/StorageUploadDataRequest.swift new file mode 100644 index 0000000000..572b160bf8 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/Request/StorageUploadDataRequest.swift @@ -0,0 +1,117 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Represents an **data** upload request initiated by an implementation of the +/// [StorageCategoryPlugin](x-source-tag://StorageCategoryPlugin) protocol. +/// +/// - Tag: StorageUploadDataRequest +public struct StorageUploadDataRequest: AmplifyOperationRequest { + + /// The path for the object in storage + /// + /// - Tag: StorageDownloadFileRequest.path + public let path: (any StoragePath)? + + /// The unique identifier for the object in storage + /// + /// - Tag: StorageUploadDataRequest.key + @available(*, deprecated, message: "Use `path` instead of `key`") + public let key: String + + /// The data in memory to be uploaded + /// + /// - Tag: StorageUploadDataRequest.data + public let data: Data + + /// Options to adjust the behavior of this request, including plugin-options + /// + /// - Tag: StorageUploadDataRequest.options + public let options: Options + + /// - Tag: StorageUploadDataRequest.init + @available(*, deprecated, message: "Use init(path:data:options)") + public init(key: String, data: Data, options: Options) { + self.key = key + self.data = data + self.options = options + self.path = nil + } + + public init(path: any StoragePath, data: Data, options: Options) { + self.key = "" + self.data = data + self.options = options + self.path = path + } +} + +public extension StorageUploadDataRequest { + + /// Options to adjust the behavior of this request, including plugin-options + /// + /// - Tag: StorageUploadDataRequestOptions + struct Options { + + /// Access level of the storage system. Defaults to `public` + /// + /// - Tag: StorageUploadDataRequestOptions.accessLevel + @available(*, deprecated, message: "Use `path` in Storage API instead of `Options`") + public let accessLevel: StorageAccessLevel + + /// Target user to apply the action on. + /// + /// - Tag: StorageUploadDataRequestOptions.targetIdentityId + @available(*, deprecated, message: "Use `path` in Storage API instead of `Options`") + public let targetIdentityId: String? + + /// Metadata for the object to store + /// + /// - Tag: StorageUploadDataRequestOptions.metadata + public let metadata: [String: String]? + + /// The standard MIME type describing the format of the object to store + /// + /// - Tag: StorageUploadDataRequestOptions.contentType + public let contentType: String? + + /// Extra plugin specific options, only used in special circumstances when the existing options do not provide + /// a way to utilize the underlying storage system's functionality. See plugin documentation for expected + /// key/values + /// + /// - Tag: StorageUploadDataRequestOptions.pluginOptions + public let pluginOptions: Any? + + /// - Tag: StorageUploadDataRequestOptions.init + @available(*, deprecated, message: "Use init(metadata:contentType:options)") + public init(accessLevel: StorageAccessLevel = .guest, + targetIdentityId: String? = nil, + metadata: [String: String]? = nil, + contentType: String? = nil, + pluginOptions: Any? = nil + ) { + self.accessLevel = accessLevel + self.targetIdentityId = targetIdentityId + self.metadata = metadata + self.contentType = contentType + self.pluginOptions = pluginOptions + } + + /// - Tag: StorageUploadDataRequestOptions.init + public init(metadata: [String: String]? = nil, + contentType: String? = nil, + pluginOptions: Any? = nil + ) { + self.accessLevel = .guest + self.targetIdentityId = nil + self.metadata = metadata + self.contentType = contentType + self.pluginOptions = pluginOptions + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/Request/StorageUploadFileRequest.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/Request/StorageUploadFileRequest.swift new file mode 100644 index 0000000000..23c8d159f6 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/Request/StorageUploadFileRequest.swift @@ -0,0 +1,114 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Represents an **file** upload request initiated by an implementation of the +/// [StorageCategoryPlugin](x-source-tag://StorageCategoryPlugin) protocol. +/// +/// - Tag: StorageUploadFileRequest +public struct StorageUploadFileRequest: AmplifyOperationRequest { + + /// The path for the object in storage + /// + /// - Tag: StorageDownloadFileRequest.path + public let path: (any StoragePath)? + + /// The unique identifier for the object in storage + /// - Tag: StorageUploadFileRequest.key + @available(*, deprecated, message: "Use `path` instead of `key`") + public let key: String + + /// The file to be uploaded + /// - Tag: StorageUploadFileRequest.local + public let local: URL + + /// Options to adjust the behavior of this request, including plugin-options + /// - Tag: StorageUploadFileRequest.options + public let options: Options + + /// - Tag: StorageUploadFileRequest.init + @available(*, deprecated, message: "Use init(path:local:options)") + public init(key: String, local: URL, options: Options) { + self.key = key + self.local = local + self.options = options + self.path = nil + } + + public init(path: any StoragePath, local: URL, options: Options) { + self.key = "" + self.local = local + self.options = options + self.path = path + } +} + +public extension StorageUploadFileRequest { + + /// Options to adjust the behavior of this request, including plugin-options + /// + /// - Tag: StorageUploadFileRequestOptions + struct Options { + + /// Access level of the storage system. Defaults to `public` + /// + /// - Tag: StorageUploadFileRequestOptions.accessLevel + @available(*, deprecated, message: "Use `path` in Storage API instead of `Options`") + public let accessLevel: StorageAccessLevel + + /// Target user to apply the action on. + /// + /// - Tag: StorageUploadFileRequestOptions.targetIdentityId + @available(*, deprecated, message: "Use `path` in Storage API instead of `Options`") + public let targetIdentityId: String? + + /// Metadata for the object to store + /// + /// - Tag: StorageUploadFileRequestOptions.metadata + public let metadata: [String: String]? + + /// The standard MIME type describing the format of the object to store + /// + /// - Tag: StorageUploadFileRequestOptions.contentType + public let contentType: String? + + /// Extra plugin specific options, only used in special circumstances when the existing options do not provide + /// a way to utilize the underlying storage system's functionality. See plugin documentation for expected + /// key/values + /// + /// - Tag: StorageUploadFileRequestOptions.pluginOptions + public let pluginOptions: Any? + + /// - Tag: StorageUploadFileRequestOptions.init + @available(*, deprecated, message: "Use init(metadata:contentType:pluginOptions)") + public init(accessLevel: StorageAccessLevel = .guest, + targetIdentityId: String? = nil, + metadata: [String: String]? = nil, + contentType: String? = nil, + pluginOptions: Any? = nil + ) { + self.accessLevel = accessLevel + self.targetIdentityId = targetIdentityId + self.metadata = metadata + self.contentType = contentType + self.pluginOptions = pluginOptions + } + + /// - Tag: StorageUploadFileRequestOptions.init + public init(metadata: [String: String]? = nil, + contentType: String? = nil, + pluginOptions: Any? = nil + ) { + self.accessLevel = .guest + self.targetIdentityId = nil + self.metadata = metadata + self.contentType = contentType + self.pluginOptions = pluginOptions + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/StorageDownloadDataOperation.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/StorageDownloadDataOperation.swift new file mode 100644 index 0000000000..ed608da6de --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/StorageDownloadDataOperation.swift @@ -0,0 +1,27 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// - Tag: StorageDownloadDataOperation +public protocol StorageDownloadDataOperation: AmplifyInProcessReportingOperation< + StorageDownloadDataRequest, + Progress, + Data, + StorageError +> {} + +public extension HubPayload.EventName.Storage { + /// eventName for HubPayloads emitted by this operation + static let downloadData = "Storage.downloadData" +} + +/// - Tag: StorageDownloadDataTask +public typealias StorageDownloadDataTask = AmplifyInProcessReportingOperationTaskAdapter diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/StorageDownloadFileOperation.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/StorageDownloadFileOperation.swift new file mode 100644 index 0000000000..7d896219dc --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/StorageDownloadFileOperation.swift @@ -0,0 +1,37 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// This operation encapsulates work to download an object from cloud storage to local storage. +/// +/// ## Event descriptions +/// - InProcess: `Progress` - representing the progress of the download. This event will be emitted as controlled by the +/// underlying NSURLSession behavior, but could be multiple times per second. Apps should not rely on Progress to be +/// 1.0 to determine a completed operation +/// - Completed: `Void` - Receipt of a `.completed` event indicates the download is complete and the file has been +/// successfully stored to the local URL supplied in the original `StorageDownloadFileRequest` +/// - Error: `StorageError` - Emitted if the download encounters an error. +/// +/// - Tag: StorageDownloadFileOperation +public protocol StorageDownloadFileOperation: AmplifyInProcessReportingOperation< + StorageDownloadFileRequest, + Progress, + Void, + StorageError +> { } + +public extension HubPayload.EventName.Storage { + /// eventName for HubPayloads emitted by this operation + static let downloadFile = "Storage.downloadFile" +} + +/// - Tag: StorageDownloadFileTask +public typealias StorageDownloadFileTask = AmplifyInProcessReportingOperationTaskAdapter diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/StorageGetURLOperation.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/StorageGetURLOperation.swift new file mode 100644 index 0000000000..42f0e73746 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/StorageGetURLOperation.swift @@ -0,0 +1,16 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// - Tag: StorageGetURLOperation +public protocol StorageGetURLOperation: AmplifyOperation {} + +public extension HubPayload.EventName.Storage { + /// eventName for HubPayloads emitted by this operation + static let getURL = "Storage.getURL" +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/StorageListOperation.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/StorageListOperation.swift new file mode 100644 index 0000000000..2d3654250a --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/StorageListOperation.swift @@ -0,0 +1,14 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// - Tag: StorageListOperation +public protocol StorageListOperation: AmplifyOperation {} + +public extension HubPayload.EventName.Storage { + /// eventName for HubPayloads emitted by this operation + static let list = "Storage.list" +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/StorageRemoveOperation.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/StorageRemoveOperation.swift new file mode 100644 index 0000000000..22a8ba299a --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/StorageRemoveOperation.swift @@ -0,0 +1,14 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// - Tag: StorageRemoveOperation +public protocol StorageRemoveOperation: AmplifyOperation {} + +public extension HubPayload.EventName.Storage { + /// eventName for HubPayloads emitted by this operation + static let remove = "Storage.remove" +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/StorageUploadDataOperation.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/StorageUploadDataOperation.swift new file mode 100644 index 0000000000..5f9fed049f --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/StorageUploadDataOperation.swift @@ -0,0 +1,27 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// - Tag: StorageUploadDataOperation +public protocol StorageUploadDataOperation: AmplifyInProcessReportingOperation< + StorageUploadDataRequest, + Progress, + String, + StorageError +> {} + +public extension HubPayload.EventName.Storage { + /// eventName for HubPayloads emitted by this operation + static let uploadData = "Storage.uploadData" +} + +/// - Tag: StorageUploadDataTask +public typealias StorageUploadDataTask = AmplifyInProcessReportingOperationTaskAdapter diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/StorageUploadFileOperation.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/StorageUploadFileOperation.swift new file mode 100644 index 0000000000..35d62df864 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/StorageUploadFileOperation.swift @@ -0,0 +1,27 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// - Tag: StorageUploadFileOperation +public protocol StorageUploadFileOperation: AmplifyInProcessReportingOperation< + StorageUploadFileRequest, + Progress, + String, + StorageError +> {} + +public extension HubPayload.EventName.Storage { + /// eventName for HubPayloads emitted by this operation + static let uploadFile = "Storage.uploadFile" +} + +/// - Tag: StorageUploadFileTask +public typealias StorageUploadFileTask = AmplifyInProcessReportingOperationTaskAdapter diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Result/ProgressListener.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Result/ProgressListener.swift new file mode 100644 index 0000000000..a00c0f224f --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Result/ProgressListener.swift @@ -0,0 +1,13 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Convenience typealias for a callback invoked with an asynchronous operation's `Progress` +/// +/// - Tag: ProgressListener +public typealias ProgressListener = (Progress) -> Void diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Result/StorageListResult.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Result/StorageListResult.swift new file mode 100644 index 0000000000..057b9e177a --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Result/StorageListResult.swift @@ -0,0 +1,112 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Represents the output of a call to +/// [StorageCategoryBehavior.list](x-source-tag://StorageCategoryBehavior.list) +/// +/// - Tag: StorageListResult +public struct StorageListResult { + + /// This is meant to be called by plugins implementing + /// [StorageCategoryBehavior.list](x-source-tag://StorageCategoryBehavior.list). + /// + /// - Tag: StorageListResult.init + public init(items: [Item], nextToken: String? = nil) { + self.items = items + self.nextToken = nextToken + } + + /// Array of Items in the Result + /// + /// - Tag: StorageListResult.items + public var items: [Item] + + /// Opaque string indicating the page offset at which to resume a listing. This value is usually copied to + /// [StorageListRequestOptions.nextToken](x-source-tag://StorageListRequestOptions.nextToken). + /// + /// - SeeAlso: + /// [StorageListRequestOptions.nextToken](x-source-tag://StorageListRequestOptions.nextToken) + /// + /// - Tag: StorageListResult.nextToken + public let nextToken: String? +} + +extension StorageListResult { + + /// - Tag: StorageListResultItem + public struct Item { + + /// The path of the object in storage. + /// + /// - Tag: StorageListResultItem.path + public let path: String + + /// The unique identifier of the object in storage. + /// + /// - Tag: StorageListResultItem.key + @available(*, deprecated, message: "Use `path` instead.") + public let key: String + + /// Size in bytes of the object + /// + /// - Tag: StorageListResultItem.size + public let size: Int? + + /// The date the Object was Last Modified + /// + /// - Tag: StorageListResultItem.lastModified + public let lastModified: Date? + + /// The entity tag is an MD5 hash of the object. + /// ETag reflects only changes to the contents of an object, not its metadata. + /// + /// - Tag: StorageListResultItem.eTag + public let eTag: String? + + /// Additional results specific to the plugin. + /// + /// - Tag: StorageListResultItem.pluginResults + public let pluginResults: Any? + + /// This is meant to be called by plugins implementing + /// [StorageCategoryBehavior.list](x-source-tag://StorageCategoryBehavior.list). + /// + /// - Tag: StorageListResultItem.init + @available(*, deprecated, message: "Use init(path:size:lastModifiedDate:eTag:pluginResults)") + public init( + key: String, + size: Int? = nil, + eTag: String? = nil, + lastModified: Date? = nil, + pluginResults: Any? = nil + ) { + self.key = key + self.size = size + self.eTag = eTag + self.lastModified = lastModified + self.pluginResults = pluginResults + self.path = "" + } + + public init( + path: String, + size: Int? = nil, + eTag: String? = nil, + lastModified: Date? = nil, + pluginResults: Any? = nil + ) { + self.path = path + self.key = path + self.size = size + self.eTag = eTag + self.lastModified = lastModified + self.pluginResults = pluginResults + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/StorageAccessLevel.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/StorageAccessLevel.swift new file mode 100644 index 0000000000..726effc9be --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/StorageAccessLevel.swift @@ -0,0 +1,31 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// The access level for objects in Storage operations. +/// See https://aws-amplify.github.io/docs/ios/storage#storage-access +/// +/// - Tag: StorageAccessLevel +@available(*, deprecated, message: "Use `path` in Storage API instead of `Options`") +public enum StorageAccessLevel: String { + + /// Objects can be read or written by any user without authentication + /// + /// - Tag: StorageAccessLevel.guest + case guest + + /// Objects can be viewed by any user without authentication, but only written by the owner + /// + /// - Tag: StorageAccessLevel.protected + case protected + + /// Objects can only be read and written by the owner + /// + /// - Tag: StorageAccessLevel.private + case `private` +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/StorageCategory+ClientBehavior.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/StorageCategory+ClientBehavior.swift new file mode 100644 index 0000000000..55b69bbe43 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/StorageCategory+ClientBehavior.swift @@ -0,0 +1,133 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension StorageCategory: StorageCategoryBehavior { + + @discardableResult + public func getURL( + key: String, + options: StorageGetURLOperation.Request.Options? = nil + ) async throws -> URL { + try await plugin.getURL(key: key, options: options) + } + + @discardableResult + public func getURL( + path: any StoragePath, + options: StorageGetURLOperation.Request.Options? = nil + ) async throws -> URL { + try await plugin.getURL(path: path, options: options) + } + + @discardableResult + public func downloadData( + key: String, + options: StorageDownloadDataOperation.Request.Options? = nil + ) -> StorageDownloadDataTask { + plugin.downloadData(key: key, options: options) + } + + @discardableResult + public func downloadData( + path: any StoragePath, + options: StorageDownloadDataOperation.Request.Options? = nil + ) -> StorageDownloadDataTask { + plugin.downloadData(path: path, options: options) + } + + @discardableResult + public func downloadFile( + key: String, + local: URL, + options: StorageDownloadFileOperation.Request.Options? = nil + ) -> StorageDownloadFileTask { + plugin.downloadFile(key: key, local: local, options: options) + } + + @discardableResult + public func downloadFile( + path: any StoragePath, + local: URL, + options: StorageDownloadFileOperation.Request.Options? = nil + ) -> StorageDownloadFileTask { + plugin.downloadFile(path: path, local: local, options: options) + } + + @discardableResult + public func uploadData( + key: String, + data: Data, + options: StorageUploadDataOperation.Request.Options? = nil + ) -> StorageUploadDataTask { + plugin.uploadData(key: key, data: data, options: options) + } + + @discardableResult + public func uploadData( + path: any StoragePath, + data: Data, + options: StorageUploadDataOperation.Request.Options? = nil + ) -> StorageUploadDataTask { + plugin.uploadData(path: path, data: data, options: options) + } + + @discardableResult + public func uploadFile( + key: String, + local: URL, + options: StorageUploadFileOperation.Request.Options? = nil + ) -> StorageUploadFileTask { + plugin.uploadFile(key: key, local: local, options: options) + } + + @discardableResult + public func uploadFile( + path: any StoragePath, + local: URL, + options: StorageUploadFileOperation.Request.Options? = nil + ) -> StorageUploadFileTask { + plugin.uploadFile(path: path, local: local, options: options) + } + + @discardableResult + public func remove( + key: String, + options: StorageRemoveRequest.Options? = nil + ) async throws -> String { + try await plugin.remove(key: key, options: options) + } + + @discardableResult + public func remove( + path: any StoragePath, + options: StorageRemoveRequest.Options? = nil + ) async throws -> String { + try await plugin.remove(path: path, options: options) + } + + @discardableResult + public func list( + options: StorageListOperation.Request.Options? = nil + ) async throws -> StorageListResult { + try await plugin.list(options: options) + } + + @discardableResult + public func list( + path: any StoragePath, + options: StorageListOperation.Request.Options? = nil + ) async throws -> StorageListResult { + try await plugin.list(path: path, options: options) + } + + public func handleBackgroundEvents(identifier: String) async -> Bool { + await plugin.handleBackgroundEvents(identifier: identifier) + } + +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/StorageCategory+HubPayloadEventName.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/StorageCategory+HubPayloadEventName.swift new file mode 100644 index 0000000000..25d964ef6a --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/StorageCategory+HubPayloadEventName.swift @@ -0,0 +1,10 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +public extension HubPayload.EventName { + struct Storage { } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/StorageCategory.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/StorageCategory.swift new file mode 100644 index 0000000000..919b127a0c --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/StorageCategory.swift @@ -0,0 +1,105 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// AWS Amplify Storage module provides a simple mechanism for managing user content for your app in public, protected +/// or private storage buckets. +/// +/// - Tag: StorageCategory +final public class StorageCategory: Category { + public let categoryType = CategoryType.storage + + var plugins = [PluginKey: StorageCategoryPlugin]() + + /// Returns the plugin added to the category, if only one plugin is added. Accessing this property if no plugins + /// are added, or if more than one plugin is added, will cause a preconditionFailure. + var plugin: StorageCategoryPlugin { + guard isConfigured else { + return Fatal.preconditionFailure( + """ + \(categoryType.displayName) category is not configured. Call Amplify.configure() before using \ + any methods on the category. + """ + ) + } + + guard !plugins.isEmpty else { + return Fatal.preconditionFailure("No plugins added to \(categoryType.displayName) category.") + } + + guard plugins.count == 1 else { + return Fatal.preconditionFailure( + """ + More than 1 plugin added to \(categoryType.displayName) category. \ + You must invoke operations on this category by getting the plugin you want, as in: + #"Amplify.\(categoryType.displayName).getPlugin(for: "ThePluginKey").foo() + """ + ) + } + + return plugins.first!.value + } + + var isConfigured = false + + // MARK: - Plugin handling + + /// Adds `plugin` to the list of Plugins that implement functionality for this category. + /// + /// - Parameter plugin: The Plugin to add + public func add(plugin: StorageCategoryPlugin) throws { + let key = plugin.key + guard !key.isEmpty else { + let pluginDescription = String(describing: plugin) + let error = StorageError.configuration("Plugin \(pluginDescription) has an empty `key`.", + "Set the `key` property for \(String(describing: plugin))") + throw error + } + + guard !isConfigured else { + let pluginDescription = String(describing: plugin) + let error = ConfigurationError.amplifyAlreadyConfigured( + "\(pluginDescription) cannot be added after `Amplify.configure()`.", + "Do not add plugins after calling `Amplify.configure()`." + ) + throw error + } + + plugins[plugin.key] = plugin + } + + /// Returns the added plugin with the specified `key` property. + /// + /// - Parameter key: The PluginKey (String) of the plugin to retrieve + /// - Returns: The wrapped plugin + public func getPlugin(for key: PluginKey) throws -> StorageCategoryPlugin { + guard let plugin = plugins[key] else { + let keys = plugins.keys.joined(separator: ", ") + let error = StorageError.configuration("No plugin has been added for '\(key)'.", + "Either add a plugin for '\(key)', or use one of the known keys: \(keys)") + throw error + } + return plugin + } + + /// Removes the plugin registered for `key` from the list of Plugins that implement functionality for this category. + /// If no plugin has been added for `key`, no action is taken, making this method safe to call multiple times. + /// + /// - Parameter key: The key used to `add` the plugin + public func removePlugin(for key: PluginKey) { + plugins.removeValue(forKey: key) + } + +} + +extension StorageCategory: DefaultLogger { + public static var log: Logger { + Amplify.Logging.logger(forCategory: CategoryType.storage.displayName, forNamespace: String(describing: self)) + } + public var log: Logger { + Self.log + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/StorageCategoryBehavior.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/StorageCategoryBehavior.swift new file mode 100644 index 0000000000..0b933d4dfc --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/StorageCategoryBehavior.swift @@ -0,0 +1,232 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Behavior of the Storage category used though `Amplify.Storage.*`. Plugin implementations +/// conform to this protocol indirectly though the +/// [StorageCategoryPlugin](x-source-tag://StorageCategoryPlugin) protocol. +/// +/// - Tag: StorageCategoryBehavior +public protocol StorageCategoryBehavior { + + /// Retrieve the remote URL for the object from storage. + /// + /// - Parameters: + /// - key: The unique identifier for the object in storage. + /// - options: Parameters to specific plugin behavior + /// - Returns: requested Get URL + /// + /// - Tag: StorageCategoryBehavior.getURL + @available(*, deprecated, message: "Use getURL(path:options:)") + @discardableResult + func getURL( + key: String, + options: StorageGetURLOperation.Request.Options? + ) async throws -> URL + + /// Retrieve the remote URL for the object from storage. + /// + /// - Parameters: + /// - path: the path to the object in storage. + /// - options: Parameters to specific plugin behavior + /// - Returns: requested Get URL + /// + /// - Tag: StorageCategoryBehavior.getURL + @discardableResult + func getURL( + path: any StoragePath, + options: StorageGetURLOperation.Request.Options? + ) async throws -> URL + + /// Retrieve the object from storage into memory. + /// + /// - Parameters: + /// - key: The unique identifier for the object in storage + /// - options: Options to adjust the behavior of this request, including plugin-options + /// - Returns: A task that provides progress updates and the key which was used to download + /// + /// - Tag: StorageCategoryBehavior.downloadData + @available(*, deprecated, message: "Use downloadData(path:options:)") + @discardableResult + func downloadData(key: String, + options: StorageDownloadDataOperation.Request.Options?) -> StorageDownloadDataTask + + /// Retrieve the object from storage into memory. + /// + /// - Parameters: + /// - path: The path for the object in storage + /// - options: Options to adjust the behavior of this request, including plugin-options + /// - Returns: A task that provides progress updates and the key which was used to download + /// + /// - Tag: StorageCategoryBehavior.downloadData + func downloadData( + path: any StoragePath, + options: StorageDownloadDataOperation.Request.Options? + ) -> StorageDownloadDataTask + + /// Download to file the object from storage. + /// + /// - Parameters: + /// - key: The unique identifier for the object in storage. + /// - local: The local file to download destination + /// - options: Parameters to specific plugin behavior + /// - Returns: A task that provides progress updates and the key which was used to download + /// + /// - Tag: StorageCategoryBehavior.downloadFile + @available(*, deprecated, message: "Use downloadFile(path:options:)") + @discardableResult + func downloadFile( + key: String, + local: URL, + options: StorageDownloadFileOperation.Request.Options? + ) -> StorageDownloadFileTask + + /// Download to file the object from storage. + /// + /// - Parameters: + /// - path: The path for the object in storage. + /// - local: The local file to download destination + /// - options: Parameters to specific plugin behavior + /// - Returns: A task that provides progress updates and the key which was used to download + /// + /// - Tag: StorageCategoryBehavior.downloadFile + @discardableResult + func downloadFile( + path: any StoragePath, + local: URL, + options: StorageDownloadFileOperation.Request.Options? + ) -> StorageDownloadFileTask + + /// Upload data to storage + /// + /// - Parameters: + /// - key: The unique identifier of the object in storage. + /// - data: The data in memory to be uploaded + /// - options: Parameters to specific plugin behavior + /// - Returns: A task that provides progress updates and the key which was used to upload + /// + /// - Tag: StorageCategoryBehavior.uploadData + @available(*, deprecated, message: "Use uploadData(path:options:)") + @discardableResult + func uploadData( + key: String, + data: Data, + options: StorageUploadDataOperation.Request.Options? + ) -> StorageUploadDataTask + + /// Upload data to storage + /// + /// - Parameters: + /// - path: The path of the object in storage. + /// - data: The data in memory to be uploaded + /// - options: Parameters to specific plugin behavior + /// - Returns: A task that provides progress updates and the key which was used to upload + /// + /// - Tag: StorageCategoryBehavior.uploadData + @discardableResult + func uploadData( + path: any StoragePath, + data: Data, + options: StorageUploadDataOperation.Request.Options? + ) -> StorageUploadDataTask + + /// Upload local file to storage + /// + /// - Parameters: + /// - key: The unique identifier of the object in storage. + /// - local: The path to a local file. + /// - options: Parameters to specific plugin behavior + /// - Returns: A task that provides progress updates and the key which was used to upload + /// + /// - Tag: StorageCategoryBehavior.uploadFile + @available(*, deprecated, message: "Use uploadFile(path:options:)") + @discardableResult + func uploadFile( + key: String, + local: URL, + options: StorageUploadFileOperation.Request.Options? + ) -> StorageUploadFileTask + + /// Upload local file to storage + /// + /// - Parameters: + /// - path: The path of the object in storage. + /// - local: The path to a local file. + /// - options: Parameters to specific plugin behavior + /// - Returns: A task that provides progress updates and the key which was used to upload + /// + /// - Tag: StorageCategoryBehavior.uploadFile + @discardableResult + func uploadFile( + path: any StoragePath, + local: URL, + options: StorageUploadFileOperation.Request.Options? + ) -> StorageUploadFileTask + + /// Delete object from storage + /// + /// - Parameters: + /// - key: The unique identifier of the object in storage. + /// - options: Parameters to specific plugin behavior + /// - Returns: An operation object that provides notifications and actions related to the execution of the work + /// + /// - Tag: StorageCategoryBehavior.remove + @available(*, deprecated, message: "Use remove(path:options:)") + @discardableResult + func remove( + key: String, + options: StorageRemoveOperation.Request.Options? + ) async throws -> String + + /// Delete object from storage + /// + /// - Parameters: + /// - path: The path of the object in storage. + /// - options: Parameters to specific plugin behavior + /// - Returns: An operation object that provides notifications and actions related to the execution of the work + /// + /// - Tag: StorageCategoryBehavior.remove + @discardableResult + func remove( + path: any StoragePath, + options: StorageRemoveOperation.Request.Options? + ) async throws -> String + + /// List the object identifiers under the hierarchy specified by the path, relative to access level, from storage + /// + /// - Parameters: + /// - options: Parameters to specific plugin behavior + /// - Returns: An operation object that provides notifications and actions related to the execution of the work + /// + /// - Tag: StorageCategoryBehavior.list + @available(*, deprecated, message: "Use list(path:options:)") + @discardableResult + func list(options: StorageListOperation.Request.Options?) async throws -> StorageListResult + + /// List the object identifiers under the hierarchy specified by the path, relative to access level, from storage + /// + /// - Parameters: + /// - path: The path of the object in storage. + /// - options: Parameters to specific plugin behavior + /// - Returns: An operation object that provides notifications and actions related to the execution of the work + /// + /// - Tag: StorageCategoryBehavior.list + @discardableResult + func list( + path: any StoragePath, + options: StorageListOperation.Request.Options? + ) async throws -> StorageListResult + + /// Handles background events which are related to URLSession + /// - Parameter identifier: identifier + /// - Returns: returns true if the identifier is handled by Amplify + /// + /// - Tag: StorageCategoryBehavior.handleBackgroundEvents + func handleBackgroundEvents(identifier: String) async -> Bool + +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/StorageCategoryConfiguration.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/StorageCategoryConfiguration.swift new file mode 100644 index 0000000000..5a790d7e7b --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/StorageCategoryConfiguration.swift @@ -0,0 +1,17 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// - Tag: StorageCategoryConfiguration +public struct StorageCategoryConfiguration: CategoryConfiguration { + public let plugins: [String: JSONValue] + + public init(plugins: [String: JSONValue] = [:]) { + self.plugins = plugins + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/StorageCategoryPlugin.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/StorageCategoryPlugin.swift new file mode 100644 index 0000000000..990d801637 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/StorageCategoryPlugin.swift @@ -0,0 +1,15 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// - Tag: StorageCategoryPlugin +public protocol StorageCategoryPlugin: Plugin, StorageCategoryBehavior { } + +public extension StorageCategoryPlugin { + var categoryType: CategoryType { + return .storage + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/StoragePath.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/StoragePath.swift new file mode 100644 index 0000000000..b3cf55867a --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/StoragePath.swift @@ -0,0 +1,45 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public typealias IdentityIDPathResolver = (String) -> String + +/// Protocol that provides a closure to resolve the storage path. +/// +/// - Tag: StoragePath +public protocol StoragePath { + associatedtype Input + var resolve: (Input) -> String { get } +} + +public extension StoragePath where Self == StringStoragePath { + static func fromString(_ path: String) -> Self { + return StringStoragePath(resolve: { _ in return path }) + } +} + +public extension StoragePath where Self == IdentityIDStoragePath { + static func fromIdentityID(_ identityIdPathResolver: @escaping IdentityIDPathResolver) -> Self { + return IdentityIDStoragePath(resolve: identityIdPathResolver) + } +} + +/// Conforms to StoragePath protocol. Provides a storage path based on a string storage path. +/// +/// - Tag: StringStoragePath +public struct StringStoragePath: StoragePath { + public let resolve: (String) -> String +} + +/// Conforms to StoragePath protocol. +/// Provides a storage path constructed from an unique identity identifer. +/// +/// - Tag: IdentityStoragePath +public struct IdentityIDStoragePath: StoragePath { + public let resolve: IdentityIDPathResolver +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Category/Category+Logging.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Category/Category+Logging.swift new file mode 100644 index 0000000000..ae043895e9 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Category/Category+Logging.swift @@ -0,0 +1,15 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +public extension Category { + /// A default logger for the category + /// + /// - Tag: Category.log + var log: Logger { + Amplify.Logging.logger(forCategory: categoryType.displayName) + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Category/Category.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Category/Category.swift new file mode 100644 index 0000000000..61270c3aa7 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Category/Category.swift @@ -0,0 +1,26 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// An Amplify Category stores certain global states, holds references to plugins for the category, and routes method +/// requests to those plugins appropriately. +/// +/// - Tag: Category +public protocol Category: AnyObject, CategoryTypeable, DefaultLogger { + + // NOTE: `add(plugin:)` and `getPlugin(for key:)` must be implemented in the actual category classes, since they + // operate on specific plugin types + + /// Removes the plugin registered for `key` from the list of Plugins that implement functionality for this category. + /// If no plugin has been added for `key`, no action is taken, making this method safe to call multiple times. + /// + /// See: [Amplify.add(plugin:)](x-source-tag://Amplify.add_plugin) + /// + /// - Parameter key: The key used to `add` the plugin + /// - Tag: Category.removePlugin + func removePlugin(for key: PluginKey) + +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Category/CategoryType.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Category/CategoryType.swift new file mode 100644 index 0000000000..9ff9c5b216 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Category/CategoryType.swift @@ -0,0 +1,126 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// The Amplify category with which the conforming type is associated. Categories, Plugins, ClientBehaviors, etc must +/// all share the same CategoryType +/// +/// - Tag: CategoryTypeable +public protocol CategoryTypeable { + /// - Tag: CategoryTypeable.categoryType + var categoryType: CategoryType { get } +} + +/// Amplify supports these Category types +/// +/// - Tag: CategoryType +public enum CategoryType: String { + /// Record app metrics and analytics data + /// + /// - Tag: CategoryType.analytics + case analytics + + /// Retrieve data from a remote service + /// + /// - Tag: CategoryType.api + case api + + /// Authentication + /// + /// - Tag: CategoryType.auth + case auth + + /// Persist data + /// + /// - Tag: CategoryType.dataStore + case dataStore + + /// Interact with geospatial services + /// + /// - Tag: CategoryType.geo + case geo + + /// Listen for or dispatch Amplify events + /// + /// - Tag: CategoryType.hub + case hub + + /// Log Amplify and app messages + /// + /// - Tag: CategoryType.logging + case logging + + /// Prediction + /// + /// - Tag: CategoryType.predictions + case predictions + + /// Push Notifications + /// + /// - Tag: CategoryType.pushNotifications + case pushNotifications + + /// Upload and download files from the cloud + /// + /// - Tag: CategoryType.storage + case storage +} + +extension CategoryType: CaseIterable {} + +public extension CategoryType { + /// - Tag: CategoryType.displayName + var displayName: String { + switch self { + case .analytics: + return "Analytics" + case .api: + return "API" + case .auth: + return "Authentication" + case .dataStore: + return "DataStore" + case .geo: + return "Geo" + case .hub: + return "Hub" + case .logging: + return "Logging" + case .predictions: + return "Predictions" + case .pushNotifications: + return "PushNotifications" + case .storage: + return "Storage" + } + } + + /// - Tag: CategoryType.category + var category: Category { + switch self { + case .analytics: + return Amplify.Analytics + case .api: + return Amplify.API + case .auth: + return Amplify.Auth + case .dataStore: + return Amplify.DataStore + case .geo: + return Amplify.Geo + case .hub: + return Amplify.Hub + case .logging: + return Amplify.Logging + case .predictions: + return Amplify.Predictions + case .pushNotifications: + return Amplify.Notifications.Push + case .storage: + return Amplify.Storage + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Configuration/AmplifyConfiguration.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Configuration/AmplifyConfiguration.swift new file mode 100644 index 0000000000..2cb769f981 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Configuration/AmplifyConfiguration.swift @@ -0,0 +1,217 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Represents Amplify's configuration for all categories intended to be used in an application. +/// +/// See: [Amplify.configure](x-source-tag://Amplify.configure) +/// +/// - Tag: AmplifyConfiguration +public struct AmplifyConfiguration: Codable { + enum CodingKeys: String, CodingKey { + case analytics + case api + case auth + case dataStore + case geo + case hub + case logging + case notifications + case predictions + case storage + } + + /// Configurations for the Amplify Analytics category + let analytics: AnalyticsCategoryConfiguration? + + /// Configurations for the Amplify API category + let api: APICategoryConfiguration? + + /// Configurations for the Amplify Auth category + let auth: AuthCategoryConfiguration? + + /// Configurations for the Amplify DataStore category + let dataStore: DataStoreCategoryConfiguration? + + /// Configurations for the Amplify Geo category + let geo: GeoCategoryConfiguration? + + /// Configurations for the Amplify Hub category + let hub: HubCategoryConfiguration? + + /// Configurations for the Amplify Logging category + let logging: LoggingCategoryConfiguration? + + /// Configurations for the Amplify Notifications category + let notifications: NotificationsCategoryConfiguration? + + /// Configurations for the Amplify Predictions category + let predictions: PredictionsCategoryConfiguration? + + /// Configurations for the Amplify Storage category + let storage: StorageCategoryConfiguration? + + /// - Tag: Amplify.init + public init(analytics: AnalyticsCategoryConfiguration? = nil, + api: APICategoryConfiguration? = nil, + auth: AuthCategoryConfiguration? = nil, + dataStore: DataStoreCategoryConfiguration? = nil, + geo: GeoCategoryConfiguration? = nil, + hub: HubCategoryConfiguration? = nil, + logging: LoggingCategoryConfiguration? = nil, + notifications: NotificationsCategoryConfiguration? = nil, + predictions: PredictionsCategoryConfiguration? = nil, + storage: StorageCategoryConfiguration? = nil) { + self.analytics = analytics + self.api = api + self.auth = auth + self.dataStore = dataStore + self.geo = geo + self.hub = hub + self.logging = logging + self.notifications = notifications + self.predictions = predictions + self.storage = storage + } + + /// Initialize `AmplifyConfiguration` by loading it from a URL representing the configuration file. + /// + /// - Tag: Amplify.configureWithConfigurationFile + public init(configurationFile url: URL) throws { + self = try AmplifyConfiguration.loadAmplifyConfiguration(from: url) + } + +} + +// MARK: - Configure + +extension Amplify { + + /// Configures Amplify with the specified configuration. + /// + /// This method must be invoked after registering plugins, and before using any Amplify category. It must not be + /// invoked more than once. + /// + /// **Lifecycle** + /// + /// Internally, Amplify configures the Hub and Logging categories first, so they are available to plugins in the + /// remaining categories during the configuration phase. Plugins for the Hub and Logging categories must not + /// assume that any other categories are available. + /// + /// After Amplify has configured all of its categories, it will dispatch a `HubPayload.EventName.Amplify.configured` + /// event to each Amplify Hub channel. After this point, plugins may invoke calls on other Amplify categories. + /// + /// - Parameter configuration: The AmplifyConfiguration for specified Categories + /// + /// - Tag: Amplify.configure + public static func configure(_ configuration: AmplifyConfiguration? = nil) throws { + log.info("Configuring") + log.debug("Configuration: \(String(describing: configuration))") + guard !isConfigured else { + let error = ConfigurationError.amplifyAlreadyConfigured( + "Amplify has already been configured.", + """ + Remove the duplicate call to `Amplify.configure()` + """ + ) + throw error + } + + let resolvedConfiguration: AmplifyConfiguration + do { + resolvedConfiguration = try Amplify.resolve(configuration: configuration) + } catch { + log.info("Failed to find Amplify configuration.") + if isRunningForSwiftUIPreviews { + log.info("Running for SwiftUI previews with no configuration file present, skipping configuration.") + return + } else { + throw error + } + } + + // Always configure logging first since Auth dependings on logging + try configure(CategoryType.logging.category, using: resolvedConfiguration) + + // Always configure Hub and Auth next, so they are available to other categories. + // Auth is a special case for other plugins which depend on using Auth when being configured themselves. + let manuallyConfiguredCategories = [CategoryType.hub, .auth] + for categoryType in manuallyConfiguredCategories { + try configure(categoryType.category, using: resolvedConfiguration) + } + + // Looping through all categories to ensure we don't accidentally forget a category at some point in the future + let remainingCategories = CategoryType.allCases.filter { !manuallyConfiguredCategories.contains($0) } + for categoryType in remainingCategories { + switch categoryType { + case .analytics: + try configure(Analytics, using: resolvedConfiguration) + case .api: + try configure(API, using: resolvedConfiguration) + case .dataStore: + try configure(DataStore, using: resolvedConfiguration) + case .geo: + try configure(Geo, using: resolvedConfiguration) + case .predictions: + try configure(Predictions, using: resolvedConfiguration) + case .pushNotifications: + try configure(Notifications.Push, using: resolvedConfiguration) + case .storage: + try configure(Storage, using: resolvedConfiguration) + case .hub, .logging, .auth: + // Already configured + break + } + } + isConfigured = true + + notifyAllHubChannels() + } + + /// Notifies all hub channels that Amplify is configured, in case any plugins need to be notified of the end of the + /// configuration phase (e.g., to set up cross-channel dependencies) + static func notifyAllHubChannels() { + let payload = HubPayload(eventName: HubPayload.EventName.Amplify.configured) + for channel in HubChannel.amplifyChannels { + Hub.plugins.values.forEach { $0.dispatch(to: channel, payload: payload) } + } + } + + /// If `candidate` is `CategoryConfigurable`, then invokes `candidate.configure(using: configuration)`. + private static func configure(_ candidate: Category, using configuration: AmplifyConfiguration) throws { + guard let configurable = candidate as? CategoryConfigurable else { + return + } + + try configurable.configure(using: configuration) + } + + /// Configures a list of plugins with the specified CategoryConfiguration. If any configurations do not match the + /// specified plugins, emits a log warning. + static func configure(plugins: [Plugin], using configuration: CategoryConfiguration?) throws { + var pluginConfigurations = configuration?.plugins + + for plugin in plugins { + let pluginConfiguration = pluginConfigurations?[plugin.key] + try plugin.configure(using: pluginConfiguration) + pluginConfigurations?.removeValue(forKey: plugin.key) + } + + if let pluginKeys = pluginConfigurations?.keys { + for unusedPluginKey in pluginKeys { + log.warn("No plugin found for configuration key `\(unusedPluginKey)`. Add a plugin for that key.") + } + } + } + + //// Indicates is the runtime is for SwiftUI Previews + static var isRunningForSwiftUIPreviews: Bool { + ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] != nil + } + +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Configuration/AmplifyOutputsData.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Configuration/AmplifyOutputsData.swift new file mode 100644 index 0000000000..93a2f60e6a --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Configuration/AmplifyOutputsData.swift @@ -0,0 +1,363 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +// swiftlint:disable nesting +// `nesting` is disabled to best represent `AmplifyOutputsData` as close as possible +// to the JSON schema which is derived from. The JSON schema is hosted at +// https://github.com/aws-amplify/amplify-backend/blob/main/packages/client-config/src/client-config-schema/schema_v1.json + +/// Represents Amplify's Gen2 configuration for all categories intended to be used in an application. +/// +/// See: [Amplify.configure](x-source-tag://Amplify.configure) +/// +/// - Tag: AmplifyOutputs +/// +@_spi(InternalAmplifyConfiguration) +public struct AmplifyOutputsData: Codable { + public let version: String + public let analytics: Analytics? + public let auth: Auth? + public let data: DataCategory? + public let geo: Geo? + public let notifications: Notifications? + public let storage: Storage? + public let custom: CustomOutput? + + @_spi(InternalAmplifyConfiguration) + public struct Analytics: Codable { + public let amazonPinpoint: AmazonPinpoint? + + public struct AmazonPinpoint: Codable { + public let awsRegion: AWSRegion + public let appId: String + } + } + + @_spi(InternalAmplifyConfiguration) + public struct Auth: Codable { + public let awsRegion: AWSRegion + public let userPoolId: String + public let userPoolClientId: String + public let identityPoolId: String? + public let passwordPolicy: PasswordPolicy? + public let oauth: OAuth? + public let standardRequiredAttributes: [AmazonCognitoStandardAttributes]? + public let usernameAttributes: [UsernameAttributes]? + public let userVerificationTypes: [UserVerificationType]? + public let unauthenticatedIdentitiesEnabled: Bool? + public let mfaConfiguration: String? + public let mfaMethods: [String]? + + @_spi(InternalAmplifyConfiguration) + public struct PasswordPolicy: Codable { + public let minLength: UInt + public let requireNumbers: Bool + public let requireLowercase: Bool + public let requireUppercase: Bool + public let requireSymbols: Bool + } + + @_spi(InternalAmplifyConfiguration) + public struct OAuth: Codable { + public let identityProviders: [String] + public let domain: String + public let scopes: [String] + public let redirectSignInUri: [String] + public let redirectSignOutUri: [String] + public let responseType: String + } + + @_spi(InternalAmplifyConfiguration) + public enum UsernameAttributes: String, Codable { + case email = "email" + case phoneNumber = "phone_number" + } + + @_spi(InternalAmplifyConfiguration) + public enum UserVerificationType: String, Codable { + case email = "email" + case phoneNumber = "phone_number" + } + + init(awsRegion: AWSRegion, + userPoolId: String, + userPoolClientId: String, + identityPoolId: String? = nil, + passwordPolicy: PasswordPolicy? = nil, + oauth: OAuth? = nil, + standardRequiredAttributes: [AmazonCognitoStandardAttributes]? = nil, + usernameAttributes: [UsernameAttributes]? = nil, + userVerificationTypes: [UserVerificationType]? = nil, + unauthenticatedIdentitiesEnabled: Bool? = nil, + mfaConfiguration: String? = nil, + mfaMethods: [String]? = nil) { + self.awsRegion = awsRegion + self.userPoolId = userPoolId + self.userPoolClientId = userPoolClientId + self.identityPoolId = identityPoolId + self.passwordPolicy = passwordPolicy + self.oauth = oauth + self.standardRequiredAttributes = standardRequiredAttributes + self.usernameAttributes = usernameAttributes + self.userVerificationTypes = userVerificationTypes + self.unauthenticatedIdentitiesEnabled = unauthenticatedIdentitiesEnabled + self.mfaConfiguration = mfaConfiguration + self.mfaMethods = mfaMethods + } + + } + + @_spi(InternalAmplifyConfiguration) + public struct DataCategory: Codable { + public let awsRegion: AWSRegion + public let url: String + public let modelIntrospection: JSONValue? + public let apiKey: String? + public let defaultAuthorizationType: AWSAppSyncAuthorizationType + public let authorizationTypes: [AWSAppSyncAuthorizationType] + } + + @_spi(InternalAmplifyConfiguration) + public struct Geo: Codable { + public let awsRegion: AWSRegion + public let maps: Maps? + public let searchIndices: SearchIndices? + public let geofenceCollections: GeofenceCollections? + + @_spi(InternalAmplifyConfiguration) + public struct Maps: Codable { + public let items: [String: AmazonLocationServiceConfig] + public let `default`: String + + @_spi(InternalAmplifyConfiguration) + public struct AmazonLocationServiceConfig: Codable { + public let style: String + } + } + + @_spi(InternalAmplifyConfiguration) + public struct SearchIndices: Codable { + public let items: [String] + public let `default`: String + } + + @_spi(InternalAmplifyConfiguration) + public struct GeofenceCollections: Codable { + public let items: [String] + public let `default`: String + } + + // Internal init used for testing + init(awsRegion: AWSRegion, + maps: Maps? = nil, + searchIndices: SearchIndices? = nil, + geofenceCollections: GeofenceCollections? = nil) { + self.awsRegion = awsRegion + self.maps = maps + self.searchIndices = searchIndices + self.geofenceCollections = geofenceCollections + } + } + + @_spi(InternalAmplifyConfiguration) + public struct Notifications: Codable { + public let awsRegion: String + public let amazonPinpointAppId: String + public let channels: [AmazonPinpointChannelType] + } + + @_spi(InternalAmplifyConfiguration) + public struct Storage: Codable { + public let awsRegion: AWSRegion + public let bucketName: String + } + + @_spi(InternalAmplifyConfiguration) + public struct CustomOutput: Codable {} + + @_spi(InternalAmplifyConfiguration) + public typealias AWSRegion = String + + @_spi(InternalAmplifyConfiguration) + public enum AmazonCognitoStandardAttributes: String, Codable, CodingKeyRepresentable { + case address + case birthdate + case email + case familyName = "family_name" + case gender + case givenName = "given_name" + case locale + case middleName = "middle_name" + case name + case nickname + case phoneNumber = "phone_number" + case picture + case preferredUsername = "preferred_username" + case profile + case sub + case updatedAt = "updated_at" + case website + case zoneinfo + } + + @_spi(InternalAmplifyConfiguration) + public enum AWSAppSyncAuthorizationType: String, Codable { + case amazonCognitoUserPools = "AMAZON_COGNITO_USER_POOLS" + case apiKey = "API_KEY" + case awsIAM = "AWS_IAM" + case awsLambda = "AWS_LAMBDA" + case openIDConnect = "OPENID_CONNECT" + } + + @_spi(InternalAmplifyConfiguration) + public enum AmazonPinpointChannelType: String, Codable { + case inAppMessaging = "IN_APP_MESSAGING" + case fcm = "FCM" + case apns = "APNS" + case email = "EMAIL" + case sms = "SMS" + } + + // Internal init used for testing + init(version: String = "", + analytics: Analytics? = nil, + auth: Auth? = nil, + data: DataCategory? = nil, + geo: Geo? = nil, + notifications: Notifications? = nil, + storage: Storage? = nil, + custom: CustomOutput? = nil) { + self.version = version + self.analytics = analytics + self.auth = auth + self.data = data + self.geo = geo + self.notifications = notifications + self.storage = storage + self.custom = custom + } +} +// swiftlint:enable nesting + +// MARK: - Configure + +/// Represents helper methods to configure with Amplify CLI Gen2 configuration. +public struct AmplifyOutputs { + + /// A closure that resolves the `AmplifyOutputsData` configuration + let resolveConfiguration: () throws -> AmplifyOutputsData + + /// Resolves configuration with `amplify_outputs.json` in the main bundle. + public static let amplifyOutputs: AmplifyOutputs = { + .init { + try AmplifyOutputsData(bundle: Bundle.main, resource: "amplify_outputs") + } + }() + + /// Resolves configuration with a data object, from the contents of an `amplify_outputs.json` file. + public static func data(_ data: Data) -> AmplifyOutputs { + .init { + try AmplifyOutputsData.decodeAmplifyOutputsData(from: data) + } + } + + /// Resolves configuration with the resource in the main bundle. + public static func resource(named resource: String) -> AmplifyOutputs { + .init { + try AmplifyOutputsData(bundle: Bundle.main, resource: resource) + } + } +} + +extension Amplify { + + /// API to configure with Amplify CLI Gen2's configuration. + /// + /// - Parameter with: `AmplifyOutputs` configuration resolver + public static func configure(with amplifyOutputs: AmplifyOutputs) throws { + do { + let resolvedConfiguration = try amplifyOutputs.resolveConfiguration() + try configure(resolvedConfiguration) + } catch { + log.info("Failed to find configuration.") + if isRunningForSwiftUIPreviews { + log.info("Running for SwiftUI previews with no configuration file present, skipping configuration.") + return + } else { + throw error + } + } + } + + /// Configures Amplify with the specified configuration. + /// + /// This method must be invoked after registering plugins, and before using any Amplify category. It must not be + /// invoked more than once. + /// + /// **Lifecycle** + /// + /// Internally, Amplify configures the Hub and Logging categories first, so they are available to plugins in the + /// remaining categories during the configuration phase. Plugins for the Hub and Logging categories must not + /// assume that any other categories are available. + /// + /// After Amplify has configured all of its categories, it will dispatch a `HubPayload.EventName.Amplify.configured` + /// event to each Amplify Hub channel. After this point, plugins may invoke calls on other Amplify categories. + /// + /// - Parameter configuration: The AmplifyOutputsData object + /// + /// - Tag: Amplify.configure + @_spi(InternalAmplifyConfiguration) + public static func configure(_ configuration: AmplifyOutputsData) throws { + // Always configure logging first since Auth dependings on logging + try configure(CategoryType.logging.category, using: configuration) + + // Always configure Hub and Auth next, so they are available to other categories. + // Auth is a special case for other plugins which depend on using Auth when being configured themselves. + let manuallyConfiguredCategories = [CategoryType.hub, .auth] + for categoryType in manuallyConfiguredCategories { + try configure(categoryType.category, using: configuration) + } + + // Looping through all categories to ensure we don't accidentally forget a category at some point in the future + let remainingCategories = CategoryType.allCases.filter { !manuallyConfiguredCategories.contains($0) } + for categoryType in remainingCategories { + switch categoryType { + case .analytics: + try configure(Analytics, using: configuration) + case .api: + try configure(API, using: configuration) + case .dataStore: + try configure(DataStore, using: configuration) + case .geo: + try configure(Geo, using: configuration) + case .predictions: + try configure(Predictions, using: configuration) + case .pushNotifications: + try configure(Notifications.Push, using: configuration) + case .storage: + try configure(Storage, using: configuration) + case .hub, .logging, .auth: + // Already configured + break + } + } + isConfigured = true + + notifyAllHubChannels() + } + + /// If `candidate` is `CategoryConfigurable`, then invokes `candidate.configure(using: configuration)`. + private static func configure(_ candidate: Category, using configuration: AmplifyOutputsData) throws { + guard let configurable = candidate as? CategoryConfigurable else { + return + } + + try configurable.configure(using: configuration) + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Configuration/CategoryConfiguration.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Configuration/CategoryConfiguration.swift new file mode 100644 index 0000000000..fc64dc1f87 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Configuration/CategoryConfiguration.swift @@ -0,0 +1,17 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// A CategoryConfiguration must contain a plugins field used to configure plugins for that category +/// +/// - Tag: CategoryConfiguration +public protocol CategoryConfiguration: Codable { + /// A map of category plugin configurations by PluginKey. Such configurations are defined by the plugins + /// themselves, and may be of any type. + /// + /// - Tag: CategoryConfiguration.plugins + var plugins: [String: JSONValue] { get } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Configuration/ConfigurationError.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Configuration/ConfigurationError.swift new file mode 100644 index 0000000000..36d7d7abab --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Configuration/ConfigurationError.swift @@ -0,0 +1,90 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// Errors associated with configuring and inspecting Amplify Categories +/// +/// See: [Amplify.configure](x-source-tag://Amplify.configure) +/// +/// - Tag: ConfigurationError +public enum ConfigurationError { + /// The client issued a subsequent call to `Amplify.configure` after the first had already succeeded + /// + /// - Tag: ConfigurationError.amplifyAlreadyConfigured + case amplifyAlreadyConfigured(ErrorDescription, RecoverySuggestion, Error? = nil) + + /// The specified `amplifyconfiguration.json` file was not present or unreadable + /// + /// - Tag: ConfigurationError.invalidAmplifyConfigurationFile + case invalidAmplifyConfigurationFile(ErrorDescription, RecoverySuggestion, Error? = nil) + + /// The specified `amplify_outputs.json` file was not present or unreadable + /// + /// - Tag: ConfigurationError.invalidAmplifyOutputsFile + case invalidAmplifyOutputsFile(ErrorDescription, RecoverySuggestion, Error? = nil) + + /// Unable to decode `amplifyconfiguration.json` into a valid AmplifyConfiguration object + /// + /// - Tag: ConfigurationError.unableToDecode + case unableToDecode(ErrorDescription, RecoverySuggestion, Error? = nil) + + /// An unknown error occurred + /// + /// - Tag: ConfigurationError.unknown + case unknown(ErrorDescription, RecoverySuggestion, Error?) +} + +extension ConfigurationError: AmplifyError { + /// - Tag: ConfigurationError.errorDescription + public var errorDescription: ErrorDescription { + switch self { + case .amplifyAlreadyConfigured(let description, _, _), + .invalidAmplifyConfigurationFile(let description, _, _), + .invalidAmplifyOutputsFile(let description, _, _), + .unableToDecode(let description, _, _), + .unknown(let description, _, _): + return description + } + } + + /// - Tag: ConfigurationError.recoverySuggestion + public var recoverySuggestion: RecoverySuggestion { + switch self { + case .amplifyAlreadyConfigured(_, let recoverySuggestion, _), + .invalidAmplifyConfigurationFile(_, let recoverySuggestion, _), + .invalidAmplifyOutputsFile(_, let recoverySuggestion, _), + .unableToDecode(_, let recoverySuggestion, _), + .unknown(_, let recoverySuggestion, _): + return recoverySuggestion + } + } + + /// - Tag: ConfigurationError.underlyingError + public var underlyingError: Error? { + switch self { + case .amplifyAlreadyConfigured(_, _, let underlyingError), + .invalidAmplifyConfigurationFile(_, _, let underlyingError), + .invalidAmplifyOutputsFile(_, _, let underlyingError), + .unableToDecode(_, _, let underlyingError), + .unknown(_, _, let underlyingError): + return underlyingError + } + } + + /// - Tag: ConfigurationError.init + public init( + errorDescription: ErrorDescription = "An unknown error occurred", + recoverySuggestion: RecoverySuggestion = "See `underlyingError` for more details", + error: Error + ) { + if let error = error as? Self { + self = error + } else { + self = .unknown(errorDescription, recoverySuggestion, error) + } + } + +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Configuration/Internal/Amplify+Reset.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Configuration/Internal/Amplify+Reset.swift new file mode 100644 index 0000000000..9a2239fe4b --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Configuration/Internal/Amplify+Reset.swift @@ -0,0 +1,99 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +// swiftlint:disable cyclomatic_complexity +extension Amplify { + + /// Resets the state of the Amplify framework. + /// + /// Internally, this method: + /// - Invokes `reset` on each configured category, which clears that categories registered plugins. + /// - Releases each configured category, and replaces the instances referred to by the static accessor properties + /// (e.g., `Amplify.Hub`) with new instances. These instances must subsequently have providers added, and be + /// configured prior to use. + static func reset() async { + // Looping through all categories to ensure we don't accidentally forget a category at some point in the future + for categoryType in CategoryType.allCases { + switch categoryType { + case .analytics: + await reset(Analytics) + case .api: + await reset(API) + case .auth: + await reset(Auth) + case .dataStore: + await reset(DataStore) + case .geo: + await reset(Geo) + case .storage: + await reset(Storage) + case .predictions: + await reset(Predictions) + case .pushNotifications: + await reset(Notifications.Push) + case .hub, .logging: + // Hub and Logging should be reset after all other categories + break + } + } + + await reset(Hub) + await reset(Logging) + + log.verbose("Resetting ModelRegistry, ModelListDecoderRegistry, ModelProviderRegistry") + ModelRegistry.reset() + ModelListDecoderRegistry.reset() + ModelProviderRegistry.reset() + log.verbose("Resetting ModelRegistry, ModelListDecoderRegistry, ModelProviderRegistry finished") + +#if os(iOS) + await MainActor.run { + devMenu = nil + } +#endif + + // Initialize Logging and Hub first, to ensure their default plugins are registered and available to other + // categories during their initialization and configuration phases. + Logging = LoggingCategory() + Hub = HubCategory() + + // Switch over all category types to ensure we don't forget any + for categoryType in CategoryType.allCases.filter({ $0 != .logging && $0 != .hub }) { + switch categoryType { + case .logging, .hub: + // Initialized above + break + case .analytics: + Analytics = AnalyticsCategory() + case .api: + API = APICategory() + case .auth: + Auth = AuthCategory() + case .dataStore: + DataStore = DataStoreCategory() + case .geo: + Geo = GeoCategory() + case .predictions: + Predictions = PredictionsCategory() + case .pushNotifications: + Notifications.Push = PushNotificationsCategory() + case .storage: + Storage = StorageCategory() + } + } + + isConfigured = false + } + + private static func reset(_ candidate: Any) async { + guard let resettable = candidate as? Resettable else { return } + + await resettable.reset() + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Configuration/Internal/Amplify+Resolve.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Configuration/Internal/Amplify+Resolve.swift new file mode 100644 index 0000000000..43e2f7cc4b --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Configuration/Internal/Amplify+Resolve.swift @@ -0,0 +1,19 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension Amplify { + + static func resolve(configuration: AmplifyConfiguration? = nil) throws -> AmplifyConfiguration { + if let configuration = configuration { + return configuration + } + + return try AmplifyConfiguration(bundle: Bundle.main) + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Configuration/Internal/AmplifyConfigurationInitialization.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Configuration/Internal/AmplifyConfigurationInitialization.swift new file mode 100644 index 0000000000..4c2b0ebcda --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Configuration/Internal/AmplifyConfigurationInitialization.swift @@ -0,0 +1,145 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension AmplifyConfiguration { + init(bundle: Bundle) throws { + guard let path = bundle.path(forResource: "amplifyconfiguration", ofType: "json") else { + throw ConfigurationError.invalidAmplifyConfigurationFile( + """ + Could not load default `amplifyconfiguration.json` file + """, + + """ + Expected to find the file, `amplifyconfiguration.json` in the app bundle at `\(bundle.bundlePath)`, but + it was not present. Either add amplifyconfiguration.json to your app's "Copy Bundle Resources" build + phase, or invoke `Amplify.configure()` with a configuration object that you load from a custom path. + """ + ) + } + + let url = URL(fileURLWithPath: path) + + self = try AmplifyConfiguration.loadAmplifyConfiguration(from: url) + } + + static func loadAmplifyConfiguration(from url: URL) throws -> AmplifyConfiguration { + let fileData: Data + do { + fileData = try Data(contentsOf: url) + } catch { + throw ConfigurationError.invalidAmplifyConfigurationFile( + """ + Could not extract UTF-8 data from `\(url.path)` + """, + + """ + Could not load data from the file at `\(url.path)`. Inspect the file to ensure it is present. + The system reported the following error: + \(error.localizedDescription) + """, + error + ) + } + + return try decodeAmplifyConfiguration(from: fileData) + } + + static func decodeAmplifyConfiguration(from data: Data) throws -> AmplifyConfiguration { + let jsonDecoder = JSONDecoder() + + do { + let configuration = try jsonDecoder.decode(AmplifyConfiguration.self, from: data) + return configuration + } catch { + throw ConfigurationError.unableToDecode( + """ + Could not decode `amplifyconfiguration.json` into a valid AmplifyConfiguration object + """, + + """ + `amplifyconfiguration.json` was found, but could not be converted to an AmplifyConfiguration object + using the default JSONDecoder. The system reported the following error: + \(error.localizedDescription) + """, + error + ) + } + } + +} + +extension AmplifyOutputsData { + init(bundle: Bundle, resource: String) throws { + guard let path = bundle.path(forResource: resource, ofType: "json") else { + throw ConfigurationError.invalidAmplifyOutputsFile( + """ + Could not load default `\(resource).json` file + """, + + """ + Expected to find the file, `\(resource).json` in the app bundle at `\(bundle.bundlePath)`, but + it was not present. Add `\(resource).json` to your app's "Copy Bundle Resources" build + phase and invoke `Amplify.configure(with: resource(named: "\(resource)")` with a configuration + object that you load. If your resource file is the default `amplify_outputs.json`, you can + invoke `Amplify.configure(with: .amplifyOutputs)` instead. + """ + ) + } + + let url = URL(fileURLWithPath: path) + + self = try AmplifyOutputsData.loadAmplifyOutputsData(from: url) + } + + static func loadAmplifyOutputsData(from url: URL) throws -> AmplifyOutputsData { + let fileData: Data + do { + fileData = try Data(contentsOf: url) + } catch { + throw ConfigurationError.invalidAmplifyOutputsFile( + """ + Could not extract UTF-8 data from `\(url.path)` + """, + + """ + Could not load data from the file at `\(url.path)`. Inspect the file to ensure it is present. + The system reported the following error: + \(error.localizedDescription) + """, + error + ) + } + + return try decodeAmplifyOutputsData(from: fileData) + } + + static func decodeAmplifyOutputsData(from data: Data) throws -> AmplifyOutputsData { + let jsonDecoder = JSONDecoder() + + do { + jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase + let configuration = try jsonDecoder.decode(AmplifyOutputsData.self, from: data) + return configuration + } catch { + throw ConfigurationError.unableToDecode( + """ + Could not decode `amplify_outputs.json`. + """, + + """ + `amplify_outputs.json` was found, but could not be converted to an object + using JSONDecoder. The system reported the following error: + \(error.localizedDescription) + """, + error + ) + } + } + +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Configuration/Internal/Category+Configuration.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Configuration/Internal/Category+Configuration.swift new file mode 100644 index 0000000000..ea8e81af0a --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Configuration/Internal/Category+Configuration.swift @@ -0,0 +1,40 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +// Internal utility extensions +extension CategoryTypeable { + + /// Returns the appropriate category-specific configuration section from an AmplifyConfiguration + /// + /// - Parameter amplifyConfiguration: The AmplifyConfiguration from which to return the category specific + /// configuration section + /// - Returns: The category-specific configuration section, or nil if the configuration has no value for the section + func categoryConfiguration(from amplifyConfiguration: AmplifyConfiguration) -> CategoryConfiguration? { + switch categoryType { + case .analytics: + return amplifyConfiguration.analytics + case .api: + return amplifyConfiguration.api + case .dataStore: + return amplifyConfiguration.dataStore + case .geo: + return amplifyConfiguration.geo + case .hub: + return amplifyConfiguration.hub + case .logging: + return amplifyConfiguration.logging + case .predictions: + return amplifyConfiguration.predictions + case .pushNotifications: + return amplifyConfiguration.notifications + case .storage: + return amplifyConfiguration.storage + case .auth: + return amplifyConfiguration.auth + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Configuration/Internal/CategoryConfigurable.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Configuration/Internal/CategoryConfigurable.swift new file mode 100644 index 0000000000..d92c18d93d --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Configuration/Internal/CategoryConfigurable.swift @@ -0,0 +1,30 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +protocol CategoryConfigurable: AnyObject, CategoryTypeable { + + /// true if the category has already been configured + var isConfigured: Bool { get } + + /// Configures the category and added plugins using `configuration` + /// + /// - Parameter configuration: The CategoryConfiguration + func configure(using configuration: CategoryConfiguration?) throws + + /// Convenience method for configuring the category using the top-level AmplifyConfiguration + /// + /// - Parameter amplifyConfiguration: The AmplifyConfiguration + func configure(using amplifyConfiguration: AmplifyConfiguration) throws + + /// Convenience method for configuring the category using the top-level AmplifyOutputsData + /// + /// - Parameter amplifyOutputs: The AmplifyOutputsData configuration + func configure(using amplifyOutputs: AmplifyOutputsData) throws + + /// Clears the category configurations, and invokes `reset` on each added plugin + func reset() async +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Error/CoreError.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Error/CoreError.swift new file mode 100644 index 0000000000..f6c628c764 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Error/CoreError.swift @@ -0,0 +1,65 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// Errors associated with operations provided by Amplify +/// +/// - Tag: CoreError +public enum CoreError { + + /// A related operation performed on `List` resulted in an error. + /// + /// - Tag: CoreError.listOperation + case listOperation(ErrorDescription, RecoverySuggestion, Error? = nil) + + /// A client side validation error occured. + /// + /// - Tag: CoreError.clientValidation + case clientValidation(ErrorDescription, RecoverySuggestion, Error? = nil) +} + +extension CoreError: AmplifyError { + /// - Tag: CoreError.errorDescription + public var errorDescription: ErrorDescription { + switch self { + case .listOperation(let errorDescription, _, _), + .clientValidation(let errorDescription, _, _): + return errorDescription + } + } + + /// - Tag: CoreError.recoverySuggestion + public var recoverySuggestion: RecoverySuggestion { + switch self { + case .listOperation(_, let recoverySuggestion, _), + .clientValidation(_, let recoverySuggestion, _): + return recoverySuggestion + } + } + + /// - Tag: CoreError.underlyingError + public var underlyingError: Error? { + switch self { + case .listOperation(_, _, let underlyingError), + .clientValidation(_, _, let underlyingError): + return underlyingError + } + } + + /// - Tag: CoreError.init + public init( + errorDescription: ErrorDescription = "An unknown error occurred", + recoverySuggestion: RecoverySuggestion = "(Ignored)", + error: Error + ) { + if let error = error as? Self { + self = error + } else { + self = .clientValidation(errorDescription, recoverySuggestion, error) + } + } + +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Internal/Foundation+Utils.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Internal/Foundation+Utils.swift new file mode 100644 index 0000000000..b419b2dfb0 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Internal/Foundation+Utils.swift @@ -0,0 +1,42 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Allows use of `isEmpty` on optional `Collection`s: +/// let optionalString: String? = getSomeOptionalString() +/// guard optionalString.isEmpty else { return } +/// +/// `Collection` provides the `isEmpty` property to declare whether an instance has any members. But it’s also pretty +/// common to expand the definition of “empty” to include nil. Unfortunately, the standard library doesn't include an +/// extension mapping the Collection.isEmpty property, so testing Optional collections means you have to unwrap: +/// +/// var optionalString: String? +/// // Do some work +/// if let s = optionalString where s != "" { +/// // s is not empty or nil +/// } +/// +/// Or slightly more succinctly, use the nil coalescing operator “??”: +/// +/// if !(optionalString ?? "").isEmpty { +/// // optionalString is not empty or nil +/// } +/// +/// This extension simply unwraps the `Optional` and returns the value of `isEmpty` for non-nil collections, and returns +/// `true` if the collection is nil. +extension Optional where Wrapped: Collection { + /// Returns `true` for nil values, or `value.isEmpty` for non-nil values. + var isEmpty: Bool { + switch self { + case .some(let val): + return val.isEmpty + case .none: + return true + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Model/BasicUserProfile.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Model/BasicUserProfile.swift new file mode 100644 index 0000000000..5d6227c122 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Model/BasicUserProfile.swift @@ -0,0 +1,40 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// A basic implementation of `UserProfile` +public struct BasicUserProfile: UserProfile { + public var name: String? + + public var email: String? + + public var plan: String? + + public var location: UserProfileLocation? + + public var customProperties: [String: UserProfilePropertyValue]? + + /// Initializer + /// - Parameters: + /// - name: Name of user + /// - email: The user's e-mail + /// - plan: The plan for the user + /// - location: Location data about the user + /// - customProperties: Properties of the user profile + public init(name: String? = nil, + email: String? = nil, + plan: String? = nil, + location: UserProfileLocation? = nil, + customProperties: [String: UserProfilePropertyValue]? = nil) { + self.name = name + self.email = email + self.plan = plan + self.location = location + self.customProperties = customProperties + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Model/UserProfile.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Model/UserProfile.swift new file mode 100644 index 0000000000..307f045d82 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Model/UserProfile.swift @@ -0,0 +1,70 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Contains specific data for a User +public protocol UserProfile { + /// Name of the user + var name: String? { get } + + /// The user's email + var email: String? { get } + + /// The plan for the user + var plan: String? { get } + + /// Location data about the user + var location: UserProfileLocation? { get } + + /// Additional properties of the user profile + var customProperties: [String: UserProfilePropertyValue]? { get } +} + +/// Contains specific data for a Location +public struct UserProfileLocation { + + /// The user's latitude + public var latitude: Double? + + /// The user's longitude + public var longitude: Double? + + /// The user's postal code + public var postalCode: String? + + /// The user's city + public var city: String? + + /// The user's region + public var region: String? + + /// The user's country + public var country: String? + + /// Initializer + /// - Parameters: + /// - latitude: The user's latitude + /// - longitude: The user's longitude + /// - postalCode: The user's postal code + /// - city: The user's city + /// - region: The user's region + /// - country: The user's country + public init(latitude: Double? = nil, + longitude: Double? = nil, + postalCode: String? = nil, + city: String? = nil, + region: String? = nil, + country: String? = nil) { + self.latitude = latitude + self.longitude = longitude + self.postalCode = postalCode + self.city = city + self.region = region + self.country = country + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Model/UserProfilePropertyValue.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Model/UserProfilePropertyValue.swift new file mode 100644 index 0000000000..0a992a12eb --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Model/UserProfilePropertyValue.swift @@ -0,0 +1,23 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Properties of a `UserProfile` can store values of different common types. +/// This protocol is an encapsulation of said types. +/// +/// Currently supported types are `String`, `Int`, `Double`, `Bool` and `Array`. +/// +/// If the underlying service does not support one of these, it is expected that the plugin will +/// cast it to another supported type. For example, casting a `Bool` to a `String`. +public protocol UserProfilePropertyValue {} + +extension String: UserProfilePropertyValue {} +extension Int: UserProfilePropertyValue {} +extension Double: UserProfilePropertyValue {} +extension Bool: UserProfilePropertyValue {} +extension Array: UserProfilePropertyValue where Element == String {} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Plugin/Internal/Plugin+Resettable.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Plugin/Internal/Plugin+Resettable.swift new file mode 100644 index 0000000000..51ddbcbf11 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Plugin/Internal/Plugin+Resettable.swift @@ -0,0 +1,42 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +public extension Resettable where Self: Plugin { + /// A default conformance if the plugin has no reset logic + /// + /// **Warning** + /// + /// This conformance will take precedence over a non-async `reset` method in an async context. Thus, given a plugin like: + /// ```swift + /// class MyPlugin: Plugin { + /// // Not invoked during `await Amplify.reset()` + /// func reset() { ... } + /// } + /// ``` + /// + /// The `MyPlugin.reset()` method will never be called during an invocation of `await Amplify.reset()`. Ensure + /// plugin `reset()` methods are always declared `async`: + /// ```swift + /// class MyPlugin: Plugin { + /// // Invoked during `await Amplify.reset()` + /// func reset() async { ... } + /// } + /// ``` + /// + /// As a best practice, always invoke `reset` through the Resettable protocol existential, rather than the concrete conforming + /// type, especially in tests: + /// ```swift + /// func testReset() async { + /// let resettable = plugin as Resettable + /// await resettable.reset() + /// // ... assert that the plugin state has been cleared + /// } + /// ``` + func reset() async { + // Do nothing + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Plugin/Internal/Resettable.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Plugin/Internal/Resettable.swift new file mode 100644 index 0000000000..0a1a3c67ad --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Plugin/Internal/Resettable.swift @@ -0,0 +1,24 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Note that although this protocol is public, the `reset` method is intended only +/// for internal use, and should not be invoked by host applications. +public protocol Resettable { + + /// Called when the client calls `await Amplify.reset()` during testing. When invoked, + /// the plugin must release resources and reset shared state. Shortly after calling + /// `reset()` on the plugin, the category and its associated plugins will be + /// released. Immediately after returning, the plugin's underlying system + /// must be ready to instantiate a new plugin and configure it. + /// + /// This method is intended only for use by the Amplify system, and should not be + /// invoked by host applications. + func reset() async + +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Plugin/Plugin.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Plugin/Plugin.swift new file mode 100644 index 0000000000..6e60fedf61 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Plugin/Plugin.swift @@ -0,0 +1,34 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// CategoryPlugins implement the behavior defined by the category. The `Plugin` protocol defines behavior common to +/// all plugins, but each category will also define client API behavior and optionally, plugin API behavior to describe +/// the contract to which the plugin must conform. +public protocol Plugin: CategoryTypeable, Resettable { + /// The key under which the plugin is registered in the Amplify configuration. Keys must be unique within the + /// category configuration section. + var key: PluginKey { get } + + /// Configures the category plugin using `configuration` + /// + /// - Parameter configuration: The category plugin configuration for configuring the plugin. The plugin + /// implementation is responsible for validating the incoming object, including required configurations, and + /// handling potential conflicts with globally-specified options. + /// - Throws: + /// - PluginError.pluginConfigurationError: If the plugin encounters an error during configuration + func configure(using configuration: Any?) throws +} + +/// Convenience typealias to clarify when Strings are being used as plugin keys +public typealias PluginKey = String + +/// The conforming type can be initialized with a `Plugin`. Allows for construction of concrete, type-erasing wrappers +/// to store heterogenous collections of Plugins in a category. +/// - Throws: PluginError.mismatchedPlugin if the instance is associated with the wrong category +public protocol PluginInitializable { + init(instance: P) +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Plugin/PluginError.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Plugin/PluginError.swift new file mode 100644 index 0000000000..2e544f3c7a --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Plugin/PluginError.swift @@ -0,0 +1,67 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// Errors associated with configuring and inspecting Amplify Plugins +public enum PluginError { + + /// A plugin is being added to the wrong category + case mismatchedPlugin(ErrorDescription, RecoverySuggestion, Error? = nil) + + /// The plugin specified by `getPlugin(key)` does not exist + case noSuchPlugin(ErrorDescription, RecoverySuggestion, Error? = nil) + + /// The plugin encountered an error during configuration + case pluginConfigurationError(ErrorDescription, RecoverySuggestion, Error? = nil) + + /// An unknown error occurred + case unknown(ErrorDescription, RecoverySuggestion, Error?) +} + +extension PluginError: AmplifyError { + public var errorDescription: ErrorDescription { + switch self { + case .mismatchedPlugin(let description, _, _), + .noSuchPlugin(let description, _, _), + .pluginConfigurationError(let description, _, _), + .unknown(let description, _, _): + return description + } + } + + public var recoverySuggestion: RecoverySuggestion { + switch self { + case .mismatchedPlugin(_, let recoverySuggestion, _), + .noSuchPlugin(_, let recoverySuggestion, _), + .pluginConfigurationError(_, let recoverySuggestion, _), + .unknown(_, let recoverySuggestion, _): + return recoverySuggestion + } + } + + public var underlyingError: Error? { + switch self { + case .mismatchedPlugin(_, _, let underlyingError), + .noSuchPlugin(_, _, let underlyingError), + .pluginConfigurationError(_, _, let underlyingError), + .unknown(_, _, let underlyingError): + return underlyingError + } + } + + public init( + errorDescription: ErrorDescription = "An unknown error occurred", + recoverySuggestion: RecoverySuggestion = "See `underlyingError` for more details", + error: Error + ) { + if let error = error as? Self { + self = error + } else { + self = .unknown(errorDescription, recoverySuggestion, error) + } + } + +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AccessLevel.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AccessLevel.swift new file mode 100644 index 0000000000..8e2123e350 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AccessLevel.swift @@ -0,0 +1,13 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +public enum AccessLevel: String { + case `public` + case protected + case `private` +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/Amplify+HubPayloadEventName.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/Amplify+HubPayloadEventName.swift new file mode 100644 index 0000000000..7af23ef675 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/Amplify+HubPayloadEventName.swift @@ -0,0 +1,14 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +public extension HubPayload.EventName { + struct Amplify { } +} + +public extension HubPayload.EventName.Amplify { + static let configured = "Amplify.configured" +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/Amplify+Publisher.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/Amplify+Publisher.swift new file mode 100644 index 0000000000..98501d9cee --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/Amplify+Publisher.swift @@ -0,0 +1,136 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#if canImport(Combine) +import Combine + +public extension Amplify { + + /// Get Combine Publishers for Amplify APIs. + /// + /// Provides static methods to create Combine Publishers from Tasks and + /// AsyncSequences. + /// + /// These can be used to get Combine Publishers for any Amplify API. + enum Publisher { + /// Create a Combine Publisher for a given Task. + /// + /// Example Usage + /// ``` + /// let sink = Amplify.Publisher.create { + /// try await Amplify.Geo.search(for "coffee") + /// } + /// .sink { completion in + /// // handle completion + /// } receiveValue: { value in + /// // handle value + /// } + /// ``` + /// + /// - Parameter operation: The Task for which to create the Publisher. + /// - Returns: The Publisher for the given Task. + public static func create( + _ operation: @escaping @Sendable () async throws -> Success + ) -> AnyPublisher { + let task = Task(operation: operation) + return Future { promise in + Task { + do { + let value = try await task.value + promise(.success(value)) + } catch { + promise(.failure(error)) + } + } + } + .handleEvents(receiveCancel: { task.cancel() }) + .eraseToAnyPublisher() + } + + /// Create a Combine Publisher for a given non-throwing Task. + /// + /// Example Usage + /// ``` + /// let sink = Amplify.Publisher.create { + /// try await Amplify.Auth.signOut() + /// } + /// .sink(receiveValue: { value in + /// // handle value + /// }) + /// ``` + /// + /// - Parameter operation: The Task for which to create the Publisher. + /// - Returns: The Publisher for the given Task. + public static func create( + _ operation: @escaping @Sendable () async -> Success + ) -> AnyPublisher { + let task = Task(operation: operation) + return Future { promise in + Task { + let value = await task.value + promise(.success(value)) + } + } + .handleEvents(receiveCancel: { task.cancel() }) + .eraseToAnyPublisher() + } + + /// Create a Combine Publisher for a given AsyncSequence. + /// + /// Example Usage + /// ``` + /// let subscription = Amplify.API.subscribe( + /// request: .subscription(of: Todo.self, type: .onCreate) + /// ) + /// + /// let sink = Amplify.Publisher.create(subscription) + /// .sink { completion in + /// // handle completion + /// } receiveValue: { value in + /// // handle value + /// } + /// ``` + /// + /// - Parameter sequence: The AsyncSequence for which to create the Publisher. + /// - Returns: The Publisher for the given AsyncSequence. + public static func create( + _ sequence: Sequence + ) -> AnyPublisher { + let subject = PassthroughSubject() + let task = Task { + do { + // If the Task is cancelled, this will allow the onCancel closure to be called immediately. + // This is necessary to prevent continuing to wait until another value is received from + // the sequence before cancelling in the case of a slow Iterator. + try await withTaskCancellationHandler { + for try await value in sequence { + // If the Task is cancelled, this will end the loop and send a CancellationError + // via the publisher. + // This is necessary to prevent the sequence from continuing to send values for a time + // after cancellation in the case of a fast Iterator. + try Task.checkCancellation() + subject.send(value) + } + subject.send(completion: .finished) + } onCancel: { + // If the Task is cancelled and the AsyncSequence is Cancellable, as + // is the case with AmplifyAsyncSequence, cancel the AsyncSequence. + if let cancellable = sequence as? Cancellable { + cancellable.cancel() + } + } + } catch { + subject.send(completion: .failure(error)) + } + } + return subject + .handleEvents(receiveCancel: { task.cancel() }) + .eraseToAnyPublisher() + } + } +} +#endif diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AmplifyAsyncSequence.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AmplifyAsyncSequence.swift new file mode 100644 index 0000000000..0320b6c9a3 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AmplifyAsyncSequence.swift @@ -0,0 +1,45 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public typealias WeakAmplifyAsyncSequenceRef = WeakRef> + +public class AmplifyAsyncSequence: AsyncSequence, Cancellable { + public typealias Iterator = AsyncStream.Iterator + private let asyncStream: AsyncStream + private let continuation: AsyncStream.Continuation + private var parent: Cancellable? + + public private(set) var isCancelled: Bool = false + + public init(parent: Cancellable? = nil, + bufferingPolicy: AsyncStream.Continuation.BufferingPolicy = .unbounded) { + self.parent = parent + (asyncStream, continuation) = AsyncStream.makeStream(of: Element.self, bufferingPolicy: bufferingPolicy) + } + + public func makeAsyncIterator() -> Iterator { + asyncStream.makeAsyncIterator() + } + + public func send(_ element: Element) { + continuation.yield(element) + } + + public func finish() { + continuation.finish() + parent = nil + } + + public func cancel() { + guard !isCancelled else { return } + isCancelled = true + parent?.cancel() + finish() + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AmplifyAsyncThrowingSequence.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AmplifyAsyncThrowingSequence.swift new file mode 100644 index 0000000000..5ff7d388eb --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AmplifyAsyncThrowingSequence.swift @@ -0,0 +1,51 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public typealias WeakAmplifyAsyncThrowingSequenceRef = WeakRef> + +public class AmplifyAsyncThrowingSequence: AsyncSequence, Cancellable { + public typealias Iterator = AsyncThrowingStream.Iterator + private let asyncStream: AsyncThrowingStream + private let continuation: AsyncThrowingStream.Continuation + private var parent: Cancellable? + + public private(set) var isCancelled: Bool = false + + public init(parent: Cancellable? = nil, + bufferingPolicy: AsyncThrowingStream.Continuation.BufferingPolicy = .unbounded) { + self.parent = parent + (asyncStream, continuation) = AsyncThrowingStream.makeStream(of: Element.self, bufferingPolicy: bufferingPolicy) + } + + public func makeAsyncIterator() -> Iterator { + asyncStream.makeAsyncIterator() + } + + public func send(_ element: Element) { + continuation.yield(element) + } + + public func fail(_ error: Error) { + continuation.yield(with: .failure(error)) + continuation.finish() + } + + public func finish() { + continuation.finish() + parent = nil + } + + public func cancel() { + guard !isCancelled else { return } + isCancelled = true + parent?.cancel() + finish() + } + +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AmplifyError.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AmplifyError.swift new file mode 100644 index 0000000000..207dbb1835 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AmplifyError.swift @@ -0,0 +1,85 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// See: [AmplifyError.errorDescription](x-source-tag://AmplifyError.errorDescription) +/// +/// - Tag: ErrorDescription +public typealias ErrorDescription = String + +/// See: [AmplifyError.recoverySuggestion](x-source-tag://AmplifyError.recoverySuggestion) +/// +/// - Tag: RecoverySuggestion +public typealias RecoverySuggestion = String + +/// - Tag: ErrorName +public typealias ErrorName = String + +/// - Tag: Field +public typealias Field = String + +/// - Tag: Key +public typealias Key = String + +/// - Tag: TargetIdentityId +public typealias TargetIdentityId = String + +/// Amplify's philosophy is to expose friendly error messages to the customer that assist with debugging. +/// Therefore, failable APIs are declared to return error results with Amplify errors, which require +/// recovery suggestions and error messages. +/// +/// - Tag: AmplifyError +public protocol AmplifyError: Error, CustomDebugStringConvertible { + + /// A localized message describing what error occurred. + /// + /// - Tag: AmplifyError.errorDescription + var errorDescription: ErrorDescription { get } + + /// A localized message describing how one might recover from the failure. + /// + /// - Tag: AmplifyError.recoverySuggestion + var recoverySuggestion: RecoverySuggestion { get } + + /// The underlying error that caused the error condition + /// + /// - Tag: AmplifyError.underlyingError + var underlyingError: Error? { get } + + /// AmplifyErrors must be able to be initialized from an underlying error. If an AmplifyError is created + /// with this initializer, it must store the underlying error in the `underlyingError` property so it can be + /// inspected later. + /// + /// Implementations of this method should handle the case where `error` is already an instance of `Self`, and simply + /// return `self` as the incoming `error`. + /// + /// - Tag: AmplifyError.initWithErrorDescription_recoverySuggestion_error + init(errorDescription: ErrorDescription, recoverySuggestion: RecoverySuggestion, error: Error) +} + +public extension AmplifyError { + var debugDescription: String { + let errorType = type(of: self) + + var components = ["\(errorType): \(errorDescription)"] + + if !recoverySuggestion.isEmpty { + components.append("Recovery suggestion: \(recoverySuggestion)") + } + + if let underlyingError = underlyingError { + if let underlyingAmplifyError = underlyingError as? AmplifyError { + components.append("Caused by:\n\(underlyingAmplifyError.debugDescription)") + } else { + components.append("Caused by:\n\(underlyingError)") + } + } + + return components.joined(separator: "\n") + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AmplifyErrorMessages.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AmplifyErrorMessages.swift new file mode 100644 index 0000000000..f6632450b8 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AmplifyErrorMessages.swift @@ -0,0 +1,44 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// Commonly used cross-category error messages. +/// +/// - Tag: AmplifyErrorMessages +public struct AmplifyErrorMessages { + /// - Tag: AmplifyErrorMessages.reportBugToAWS + public static func reportBugToAWS(file: StaticString = #file, + function: StaticString = #function, + line: UInt = #line) -> String { + """ + There is a possibility that there is a bug if this error persists. Please take a look at \ + https://github.com/aws-amplify/amplify-ios/issues to see if there are any existing issues that \ + match your scenario, and file an issue with the details of the bug if there isn't. Issue encountered \ + at: + file: \(file) + function: \(function) + line: \(line) + """ + } + + /// - Tag: AmplifyErrorMessages.shouldNotHappenReportBugToAWS + public static func shouldNotHappenReportBugToAWS(file: StaticString = #file, + function: StaticString = #function, + line: UInt = #line) -> String { + "This should not happen. \(reportBugToAWS(file: file, function: function, line: line))" + } + + /// - Tag: AmplifyErrorMessages.shouldNotHappenReportBugToAWSWithoutLineInfo + public static func shouldNotHappenReportBugToAWSWithoutLineInfo() -> String { + """ + This should not happen. There is a possibility that there is a bug if this error persists. \ + Please take a look at https://github.com/aws-amplify/amplify-swift/issues to see if there \ + are any existing issues that match your scenario, and file an issue with the details of \ + the bug if there isn't. + """ + } + +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AmplifyInProcessReportingOperation+Combine.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AmplifyInProcessReportingOperation+Combine.swift new file mode 100644 index 0000000000..d2e0b44160 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AmplifyInProcessReportingOperation+Combine.swift @@ -0,0 +1,42 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#if canImport(Combine) +import Foundation +import Combine + +extension AmplifyInProcessReportingOperation { + /// A Publisher that emits in-process values for an operation, or the associated + /// failure. Cancelled operations will emit a completion without a value as long as + /// the cancellation was received before the operation was resolved. + /// + /// Note that the `inProcessPublisher`'s `Failure` type is `Never`. An + /// AmplifyOperation reports its overall success or failure on the + /// `resultPublisher`. The `inProcessPublisher` reports a stream of values + /// associated with the ongoing work of the AmplifyOperation. For example, a + /// `StorageUploadFileOperation` uses the `inProcessOperation` to report the + /// `Progress` of the file's upload. + var internalInProcessPublisher: AnyPublisher { + inProcessSubject.eraseToAnyPublisher() + } + + /// Publish an in-process value for the operation + /// + /// - Parameter result: the result of the operation + func publish(inProcessValue: InProcess) { + inProcessSubject.send(inProcessValue) + } + + /// Publish a completion to the in-process publisher + /// + /// - Parameter result: the result of the operation + func publish(completion: Subscribers.Completion) { + inProcessSubject.send(completion: completion) + } + +} +#endif diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AmplifyInProcessReportingOperation.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AmplifyInProcessReportingOperation.swift new file mode 100644 index 0000000000..f77e99d932 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AmplifyInProcessReportingOperation.swift @@ -0,0 +1,131 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +#if canImport(Combine) +import Combine +#endif + +/// An AmplifyOperation that emits InProcess values intermittently during the operation. +/// +/// Unlike a regular `AmplifyOperation`, which emits a single Result at the completion of the operation's work, an +/// `AmplifyInProcessReportingOperation` may emit intermediate values while its work is ongoing. These values could be +/// incidental to the operation (such as a `Storage.downloadFile` operation reporting Progress values periodically as +/// the download proceeds), or they could be the primary delivery mechanism for an operation (such as a +/// `GraphQLSubscriptionOperation`'s emitting new subscription values). +open class AmplifyInProcessReportingOperation< + Request: AmplifyOperationRequest, + InProcess, + Success, + Failure: AmplifyError +>: AmplifyOperation { + public typealias InProcess = InProcess + + var inProcessListenerUnsubscribeToken: UnsubscribeToken? + + /// Local storage for the result publisher associated with this operation. + /// We derive the `inProcessPublisher` computed property from this value. + /// Amplify V2 can expect Combine to be available. +#if canImport(Combine) + var inProcessSubject: PassthroughSubject! +#endif + + public init(categoryType: CategoryType, + eventName: HubPayloadEventName, + request: Request, + inProcessListener: InProcessListener? = nil, + resultListener: ResultListener? = nil) { + + super.init(categoryType: categoryType, eventName: eventName, request: request, resultListener: resultListener) + +#if canImport(Combine) + inProcessSubject = PassthroughSubject() +#endif + + // If the inProcessListener is present, we need to register a hub event listener for it, and ensure we + // automatically unsubscribe when we receive a completion event for the operation + if let inProcessListener = inProcessListener { + self.inProcessListenerUnsubscribeToken = subscribe(inProcessListener: inProcessListener) + } + } + + /// Registers an in-process listener for this operation. If the operation + /// completes, this listener will automatically be removed. + /// + /// - Parameter inProcessListener: The listener for in-process events + /// - Returns: an UnsubscribeToken that can be used to remove the listener from Hub + func subscribe(inProcessListener: @escaping InProcessListener) -> UnsubscribeToken { + let channel = HubChannel(from: categoryType) + let filterById = HubFilters.forOperation(self) + + var inProcessListenerToken: UnsubscribeToken! + let inProcessHubListener: HubListener = { payload in + if let inProcessData = payload.data as? InProcess { + inProcessListener(inProcessData) + return + } + // Remove listener if we see a result come through + if payload.data is OperationResult { + Amplify.Hub.removeListener(inProcessListenerToken) + } + } + + inProcessListenerToken = Amplify.Hub.listen(to: channel, + isIncluded: filterById, + listener: inProcessHubListener) + + return inProcessListenerToken + } + + /// Classes that override this method must emit a completion to the `inProcessPublisher` upon cancellation + open override func cancel() { + super.cancel() +#if canImport(Combine) + publish(completion: .finished) +#endif + } + + /// Invokes `super.dispatch()`. On iOS 13+, this method first publishes a + /// `.finished` completion on the in-process publisher. + /// + /// - Parameter result: The OperationResult to dispatch to the hub as part of the + /// HubPayload + public override func dispatch(result: OperationResult) { +#if canImport(Combine) + publish(completion: .finished) +#endif + super.dispatch(result: result) + } + +} + +public extension AmplifyInProcessReportingOperation { + /// Convenience typealias for the `inProcessListener` callback submitted during Operation creation + typealias InProcessListener = (InProcess) -> Void + + /// Dispatches an event to the hub. Internally, creates an + /// `AmplifyOperationContext` object from the operation's `id`, and `request` + /// - Parameter result: The OperationResult to dispatch to the hub as part of the HubPayload + func dispatchInProcess(data: InProcess) { +#if canImport(Combine) + publish(inProcessValue: data) +#endif + + let channel = HubChannel(from: categoryType) + let context = AmplifyOperationContext(operationId: id, request: request) + let payload = HubPayload(eventName: eventName, context: context, data: data) + Amplify.Hub.dispatch(to: channel, payload: payload) + } + + /// Removes the listener that was registered during operation instantiation + func removeInProcessResultListener() { + if let inProcessListenerUnsubscribeToken = inProcessListenerUnsubscribeToken { + Amplify.Hub.removeListener(inProcessListenerUnsubscribeToken) + } + } + +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AmplifyOperation+Combine.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AmplifyOperation+Combine.swift new file mode 100644 index 0000000000..5643a32ecd --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AmplifyOperation+Combine.swift @@ -0,0 +1,62 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#if canImport(Combine) +import Foundation +import Combine + +// Most APIs will return an operation that exposes a `resultPublisher`. The +// Storage and API category methods that expose both a result and an in-process +// publisher will use use-case specific names for their publishers. The call at +// use site would be: +// ```swift +// let publisher = Amplify.Category.apiCall().resultPublisher() +// ``` +// +// These extension methods and properties provide internal support for a generic +// `result` publisher. These are exposed on specific operations by constrained +// protocol extensions, to allow for use-case specific names and overrides where +// necessary. The implementation would undoubtedly be simpler if we simply exposed +// a generic `resultPublisher` on all AmplifyOperations, but that causes confusion +// in the GraphQL Subscription case, where we want to combine values from `result` +// and `inProcess` publishers into a `connectionStatePublisher` and a +// `subscriptionDataPublisher`, neither of which map exactly to `result` or +// `inProcess` cases. Similarly, having a generically named `inProcessPublisher` +// isn't as meaningful at the call site of a Storage file operation as a +// `progressPublisher`. + +extension AmplifyOperation { + /// A Publisher that emits the result of the operation, or the associated failure. + /// Cancelled operations will emit a completion without a value as long as the + /// cancellation was processed before the operation was resolved. + var internalResultPublisher: AnyPublisher { + resultFuture + .catch(interceptCancellation) + .eraseToAnyPublisher() + } + + /// Publish the result of the operation + /// + /// - Parameter result: the result of the operation + func publish(result: OperationResult) { + resultPromise(result) + } + + /// Utility method to help Swift type-cast the handling logic for cancellation + /// errors vs. re-thrown errors + /// + /// - Parameter error: The error being intercepted + /// - Returns: A publisher that either completes successfully (if the underlying + /// error of `error` is a cancellation) or re-emits the existing error + private func interceptCancellation(error: Failure) -> AnyPublisher { + error.isOperationCancelledError ? + Empty(completeImmediately: true).eraseToAnyPublisher() : + Fail(error: error).eraseToAnyPublisher() + } + +} +#endif diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AmplifyOperation+Hub.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AmplifyOperation+Hub.swift new file mode 100644 index 0000000000..97636a786e --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AmplifyOperation+Hub.swift @@ -0,0 +1,40 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public extension HubCategory { + + /// Convenience method to allow callers to listen to Hub events for a particular operation. Internally, the listener + /// transforms the HubPayload into the Operation's expected OperationResult type, so callers may re-use their + /// `listener`s + /// + /// - Parameter operation: The operation to monitor for results + /// - Parameter resultListener: The Operation-specific listener callback to be invoked when an OperationResult for + /// that operation is received. + func listenForResult( + to operation: AmplifyOperation, + resultListener: @escaping AmplifyOperation.ResultListener + ) -> UnsubscribeToken { + return operation.subscribe(resultListener: resultListener) + } + + /// Convenience method to allow callers to listen to Hub in-process events for a particular operation. Internally, + /// the listener transforms the HubPayload into the Operation's expected InProcess type, so callers may re-use + /// their `listener`s. + /// + /// - Parameter operation: The progress reporting operation monitor for progress and results + /// - Parameter inProcessListener: The ProgressListener callback to be invoked when the operation emits an + /// in-process value + func listenForInProcess( + to operation: AmplifyInProcessReportingOperation, + inProcessListener: @escaping AmplifyInProcessReportingOperation< + Request, InProcess, Success, Failure>.InProcessListener + ) -> UnsubscribeToken { + return operation.subscribe(inProcessListener: inProcessListener) + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AmplifyOperation.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AmplifyOperation.swift new file mode 100644 index 0000000000..07d122d68c --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AmplifyOperation.swift @@ -0,0 +1,211 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Combine +import Foundation + +/// An abstract representation of an Amplify unit of work. Subclasses may aggregate multiple work items +/// to fulfull a single "AmplifyOperation", such as an "extract text operation" which might include +/// uploading an image to cloud storage, processing it via a Predictions engine, and translating the results. +/// +/// AmplifyOperations are used by plugin developers to perform tasks on behalf of the calling app. They have a default +/// implementation of a `dispatch` method that sends a contextualized payload to the Hub. +/// +/// Pausable/resumable tasks that do not require Hub dispatching should use AsynchronousOperation instead. +open class AmplifyOperation: AsynchronousOperation { + + /// The concrete Request associated with this operation + public typealias Request = Request + + /// The concrete Success type associated with this operation + public typealias Success = Success + + /// The concrete Failure type associated with this operation + public typealias Failure = Failure + + /// Convenience typealias defining the `Result`s dispatched by this operation + public typealias OperationResult = Result + + /// Convenience typealias for the `listener` callback submitted during Operation creation + public typealias ResultListener = (OperationResult) -> Void + + /// The unique ID of the operation. In categories where operations are persisted for future processing, this id can + /// be used to identify previously-scheduled work for progress tracking or other functions. + public let id: UUID + + /// Incoming parameters of the original request + public let request: Request + + /// All AmplifyOperations must be associated with an Amplify Category + public let categoryType: CategoryType + + /// All AmplifyOperations must declare a HubPayloadEventName + public let eventName: HubPayloadEventName + + private var resultListenerUnsubscribeToken: UnsubscribeToken? + + /// Local storage for the result publisher associated with this operation. We use a + /// Future here to ensure that a subscriber will always receive a value, even if + /// the operation has already completed execution by the time the subscriber is + /// attached. We derive the `resultPublisher` computed property from this value. + /// Amplify V2 can expect Combine to be available. +#if canImport(Combine) + var resultFuture: Future! + + /// Local storage for the result promise associated with this operation. We use + /// this promise handle to resolve the operation in the `dispatch` method + var resultPromise: Future.Promise! +#endif + + /// Creates an AmplifyOperation for the specified reequest. + /// + /// ## Events + /// An AmplifyOperation will dispatch messages to the Hub as it completes its work. The HubPayload for these + /// messages will have the following structure: + /// - **`eventName`**: The event name defined by the operation , such as "Storage.getURL" or "Storage.downloadFile". + /// See `HubPayload.EventName` for a list of pre-defined event names. + /// - **`context`**: An `AmplifyOperationContext` whose `operationId` will be the ID of this operation, and whose + /// `request` will be the Request used to create the operation. + /// - **`data`**: The `OperationResult` that will be dispatched to an event listener. Event types for the listener + /// are derived from the request. + /// + /// A caller may specify a listener during a call to an + /// Amplify category API: + /// ```swift + /// Amplify.Storage.list { event in print(event) } + /// ``` + /// + /// Or after the fact, by passing the operation to the Hub: + /// ```swift + /// Amplify.Hub.listen(to: operation) { event in print(event) } + /// ``` + /// + /// In either of these cases, Amplify creates a HubListener for the operation by: + /// 1. Filtering messages by the operation's ID + /// 1. Extracting the HubPayload's `data` element and casts it to the expected `OperationResult` type for the + /// listener + /// 1. Automatically unsubscribing the listener (by calling `Amplify.Hub.removeListener`) when the listener receives + /// a result + /// + /// Callers can remove the listener at any time by calling `operation.removeResultListener()`. + /// + /// - Parameter categoryType: The categoryType of this operation + /// - Parameter eventName: The event name of this operation, used in HubPayload messages dispatched by the operation + /// - Parameter request: The request used to generate this operation + /// - Parameter resultListener: The optional listener for the OperationResults associated with the operation + public init(categoryType: CategoryType, + eventName: HubPayloadEventName, + request: Request, + resultListener: ResultListener? = nil) { + self.categoryType = categoryType + self.eventName = eventName + self.request = request + self.id = UUID() + + super.init() + +#if canImport(Combine) + resultFuture = Future { self.resultPromise = $0 } +#endif + + if let resultListener = resultListener { + self.resultListenerUnsubscribeToken = subscribe(resultListener: resultListener) + } + } + + func subscribe(resultListener: @escaping ResultListener) -> UnsubscribeToken { + let channel = HubChannel(from: categoryType) + let filterById = HubFilters.forOperation(self) + + var token: UnsubscribeToken? + let resultHubListener: HubListener = { payload in + guard let result = payload.data as? OperationResult else { + return + } + + resultListener(result) + + // Automatically unsubscribe when event is received + guard let token = token else { + return + } + Amplify.Hub.removeListener(token) + } + + token = Amplify.Hub.listen(to: channel, isIncluded: filterById, listener: resultHubListener) + + // We know that `token` is assigned by `Amplify.Hub.listen` so it's safe to force-unwrap + return token! + } + + /// Classes that override this method must emit a completion to the `resultPublisher` upon cancellation + open override func cancel() { + super.cancel() +#if canImport(Combine) + let cancellationError = Failure( + errorDescription: "Operation cancelled", + recoverySuggestion: "The operation was cancelled before it completed", + error: OperationCancelledError() + ) + publish(result: .failure(cancellationError)) +#endif + removeResultListener() + } + + /// Dispatches an event to the hub. Internally, creates an + /// `AmplifyOperationContext` object from the operation's `id`, and `request`. On + /// iOS 13+, this method also publishes the result on the `resultPublisher`. + /// + /// - Parameter result: The OperationResult to dispatch to the hub as part of the + /// HubPayload + public func dispatch(result: OperationResult) { +#if canImport(Combine) + publish(result: result) +#endif + + let channel = HubChannel(from: categoryType) + let context = AmplifyOperationContext(operationId: id, request: request) + let payload = HubPayload(eventName: eventName, context: context, data: result) + Amplify.Hub.dispatch(to: channel, payload: payload) + } + + /// Removes the listener that was registered during operation instantiation + public func removeResultListener() { + guard let unsubscribeToken = resultListenerUnsubscribeToken else { + return + } + + Amplify.Hub.removeListener(unsubscribeToken) + resultListenerUnsubscribeToken = nil + } + +} + +/// All AmplifyOperations must be associated with an Amplify Category +extension AmplifyOperation: CategoryTypeable { } + +/// All AmplifyOperations must declare a HubPayloadEventName. Subclasses should provide names by extending +/// `HubPayload.EventName`, e.g.: +/// +/// ``` +/// public extension HubPayload.EventName.Storage { +/// static let put = "Storage.put" +/// } +/// ``` +extension AmplifyOperation: HubPayloadEventNameable { } + +/// Conformance to Cancellable we gain for free by subclassing AsynchronousOperation +extension AmplifyOperation: Cancellable { } + +/// Describes the parameters that are passed during the creation of an AmplifyOperation +public protocol AmplifyOperationRequest { + /// The concrete Options type that adjusts the behavior of the request type + associatedtype Options + + /// Options to adjust the behavior of this request, including plugin options + var options: Options { get } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AmplifyOperationContext.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AmplifyOperationContext.swift new file mode 100644 index 0000000000..c7b49cc278 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AmplifyOperationContext.swift @@ -0,0 +1,18 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// A context object passed in the HubPayload of all events dispatched to the Hub by an AmplifyOperation. This object +/// can be used to filter on a particular operation. +public struct AmplifyOperationContext { + /// The id of the operation + public let operationId: UUID + + /// The Request used to instantiate the operation + public let request: Request +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AmplifyTask+OperationTaskAdapters.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AmplifyTask+OperationTaskAdapters.swift new file mode 100644 index 0000000000..50e505bce9 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AmplifyTask+OperationTaskAdapters.swift @@ -0,0 +1,157 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +#if canImport(Combine) +import Combine +#endif + +public class AmplifyOperationTaskAdapter: AmplifyTask { + let operation: AmplifyOperation + let childTask: ChildTask + var resultToken: UnsubscribeToken? + + public init(operation: AmplifyOperation) { + self.operation = operation + self.childTask = ChildTask(parent: operation) + resultToken = operation.subscribe { [weak self] in + self?.resultListener($0) + } + } + + deinit { + if let resultToken = resultToken { + Amplify.Hub.removeListener(resultToken) + } + } + + public var value: Success { + get async throws { + try await childTask.value + } + } + + public func pause() { + operation.pause() + } + + public func resume() { + operation.resume() + } + + public func cancel() { + Task { + await childTask.cancel() + } + } + +#if canImport(Combine) + public var resultPublisher: AnyPublisher { + operation.resultPublisher + } +#endif + + private func resultListener(_ result: Result) { + Task { + await childTask.finish(result) + } + } +} + +public class AmplifyInProcessReportingOperationTaskAdapter: AmplifyTask, AmplifyInProcessReportingTask { + let operation: AmplifyInProcessReportingOperation + let childTask: ChildTask + var resultToken: UnsubscribeToken? + var inProcessToken: UnsubscribeToken? + + public init(operation: AmplifyInProcessReportingOperation) { + self.operation = operation + self.childTask = ChildTask(parent: operation) + resultToken = operation.subscribe(resultListener: { [weak self] result in + guard let self = self else { return } + self.resultListener(result) + }) + inProcessToken = operation.subscribe(inProcessListener: { [weak self] inProcess in + guard let self = self else { return } + self.inProcessListener(inProcess) + }) + } + + deinit { + if let resultToken = resultToken { + Amplify.Hub.removeListener(resultToken) + } + if let inProcessToken = inProcessToken { + Amplify.Hub.removeListener(inProcessToken) + } + } + + public var value: Success { + get async throws { + try await childTask.value + } + } + + public var inProcess: AmplifyAsyncSequence { + get async { + await childTask.inProcess + } + } + + public func pause() { + operation.pause() + } + + public func resume() { + operation.resume() + } + + public func cancel() { + Task { + await childTask.cancel() + } + } + +#if canImport(Combine) + public var resultPublisher: AnyPublisher { + operation.resultPublisher + } + + public var inProcessPublisher: AnyPublisher { + operation.inProcessPublisher + } +#endif + + private func resultListener(_ result: Result) { + Task { + await childTask.finish(result) + } + } + + private func inProcessListener(_ inProcess: InProcess) { + Task { + try await childTask.report(inProcess) + } + } +} + +public extension AmplifyOperationTaskAdapter where Request: RequestIdentifier { + var requestID: String { + operation.request.requestID + } +} + +public extension AmplifyInProcessReportingOperationTaskAdapter where Request: RequestIdentifier { + var requestID: String { + operation.request.requestID + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AmplifyTask.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AmplifyTask.swift new file mode 100644 index 0000000000..915f0f06b6 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AmplifyTask.swift @@ -0,0 +1,77 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +#if canImport(Combine) +import Combine +#endif + +/// Simple task that represents a unit of work and its result. +/// +/// See Also: [AmplifyInProcessReportingTask](x-source-tag://AmplifyInProcessReportingTask) +/// +/// - Tag: AmplifyTask +public protocol AmplifyTask { + associatedtype Request + associatedtype Success + associatedtype Failure: AmplifyError + + /// Blocks until the receiver has successfully collected a result or throws if an error was encountered. + /// + /// - Tag: AmplifyTask.value + var value: Success { get async throws } + + /// Pauses the work represented by the receiver. + /// + /// - Tag: AmplifyTask.pause + func pause() + + /// Resumes any paused work represented by the receiver. + /// + /// - Tag: AmplifyTask.resume + func resume() + + /// Cancels any work represented by the receiver. + /// + /// - Tag: AmplifyTask.resume + func cancel() + +#if canImport(Combine) + var resultPublisher: AnyPublisher { get } +#endif +} + +/// Simple task that represents a unit of work and its result and is able to report its progress. +/// +/// See Also: [AmplifyTask](x-source-tag://AmplifyTask) +/// +/// - Tag: AmplifyInProcessReportingTask +public protocol AmplifyInProcessReportingTask { + associatedtype InProcess + + /// An async sequence that is able to report on the progress of the work represented by the receiver. + /// + /// - Tag: AmplifyInProcessReportingTask.inProcess + var inProcess: AmplifyAsyncSequence { get async } + +#if canImport(Combine) + var inProcessPublisher: AnyPublisher { get } +#endif +} + +public extension AmplifyInProcessReportingTask where InProcess == Progress { + + /// An async sequence that is able to report on the progress of the work represented by the receiver + /// using [Progress](x-source-tag://Progress). + /// + /// - Tag: AmplifyInProcessReportingTask.progress + var progress: AmplifyAsyncSequence { + get async { + await inProcess + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AmplifyTaskExecution.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AmplifyTaskExecution.swift new file mode 100644 index 0000000000..ff73c60f26 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AmplifyTaskExecution.swift @@ -0,0 +1,71 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +import Foundation + +/// Task that supports hub with execution of a single unit of work. . +/// +/// See Also: [AmplifyTask](x-source-tag://AmplifyTask) +/// +/// - Tag: AmplifyTaskExecution +public protocol AmplifyTaskExecution { + + associatedtype Success + associatedtype Request + associatedtype Failure: AmplifyError + + typealias AmplifyTaskExecutionResult = Result + + /// Blocks until the receiver has successfully collected a result or throws if an error was encountered. + /// + /// - Tag: AmplifyTaskExecution.value + var value: Success { get async throws } + + /// Hub event name for the task + /// + /// - Tag: AmplifyTaskExecution.eventName + var eventName: HubPayloadEventName { get } + + /// Category for which the Hub event would be dispatched for. + /// + /// - Tag: AmplifyTaskExecution.eventNameCategoryType + var eventNameCategoryType: CategoryType { get } + + /// Executes work represented by the receiver. + /// + /// - Tag: AmplifyTaskExecution.execute + func execute() async throws -> Success + + /// Dispatches a hub event. + /// + /// - Tag: AmplifyTaskExecution.dispatch + func dispatch(result: AmplifyTaskExecutionResult) + +} + +public extension AmplifyTaskExecution where Self: DefaultLogger { + var value: Success { + get async throws { + do { + log.info("Starting execution for \(eventName)") + let valueReturned = try await execute() + log.info("Successfully completed execution for \(eventName) with result:\n\(valueReturned)") + dispatch(result: .success(valueReturned)) + return valueReturned + } catch let error as Failure { + log.error("Failed execution for \(eventName) with error:\n\(error)") + dispatch(result: .failure(error)) + throw error + } + } + } + + func dispatch(result: AmplifyTaskExecutionResult) { + let channel = HubChannel(from: eventNameCategoryType) + let payload = HubPayload(eventName: eventName, context: nil, data: result) + Amplify.Hub.dispatch(to: channel, payload: payload) + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AmplifyTaskGateway.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AmplifyTaskGateway.swift new file mode 100644 index 0000000000..cca0a94fbc --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AmplifyTaskGateway.swift @@ -0,0 +1,129 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +// Filtering was being handled by an Operation ID set up when subscribing. All elements dispatched with the +// operation would be filtered for the listener. AmplifyOperationContext is sent which passes the ID and +// the Request. HubPayload is made up of the eventName, context and the element which is being sent. + +import Foundation + +// Supports Hub and Async API +protocol AmplifyTaskGateway { + associatedtype Request: AmplifyOperationRequest + associatedtype InProcess: Sendable + associatedtype Success + associatedtype Failure: AmplifyError + + typealias TaskResult = Result + typealias ResultListener = (TaskResult) -> Void + typealias InProcessListener = (InProcess) -> Void + + var id: UUID { get } + var request: Request { get } + var categoryType: CategoryType { get } + var eventName: HubPayloadEventName { get } + + var value: Success { get async throws } + var result: TaskResult { get async } + var inProcess: AmplifyAsyncSequence { get async } + + func pause() + func resume() + func cancel() + + func subscribe(resultListener: @escaping ResultListener) -> UnsubscribeToken + func subscribe(inProcessListener: @escaping InProcessListener) -> UnsubscribeToken + func unsubscribe(_ token: UnsubscribeToken) + func dispatch(result: TaskResult) + func dispatch(inProcess: InProcess) +} + +extension AmplifyTaskGateway { + var value: Success { + get async throws { + try await result.get() + } + } + + var idFilter: HubFilter { + let filter: HubFilter = { payload in + guard let context = payload.context as? AmplifyOperationContext else { + return false + } + + return context.operationId == id + } + + return filter + } + + func subscribe(resultListener: @escaping ResultListener) -> UnsubscribeToken { + let channel = HubChannel(from: categoryType) + let filterById = idFilter + + var unsubscribe: (() -> Void)? + let resultHubListener: HubListener = { payload in + guard let result = payload.data as? TaskResult else { + return + } + resultListener(result) + // Automatically unsubscribe when event is received + unsubscribe?() + } + let token = Amplify.Hub.listen(to: channel, + isIncluded: filterById, + listener: resultHubListener) + unsubscribe = { + Amplify.Hub.removeListener(token) + } + return token + } + + func subscribe(inProcessListener: @escaping InProcessListener) -> UnsubscribeToken { + let channel = HubChannel(from: categoryType) + let filterById = idFilter + + var unsubscribe: (() -> Void)? + let inProcessHubListener: HubListener = { payload in + if let inProcessData = payload.data as? InProcess { + inProcessListener(inProcessData) + return + } + + // Remove listener if we see a result come through + if payload.data is TaskResult { + unsubscribe?() + } + } + let token = Amplify.Hub.listen(to: channel, + isIncluded: filterById, + listener: inProcessHubListener) + unsubscribe = { + Amplify.Hub.removeListener(token) + } + return token + } + + func unsubscribe(_ token: UnsubscribeToken) { + Amplify.Hub.removeListener(token) + } + + func dispatch(result: TaskResult) { + let channel = HubChannel(from: categoryType) + let context = AmplifyOperationContext(operationId: id, request: request) + let payload = HubPayload(eventName: eventName, context: context, data: result) + Amplify.Hub.dispatch(to: channel, payload: payload) + } + + func dispatch(inProcess: InProcess) { + let channel = HubChannel(from: categoryType) + let context = AmplifyOperationContext(operationId: id, request: request) + let payload = HubPayload(eventName: eventName, context: context, data: inProcess) + Amplify.Hub.dispatch(to: channel, payload: payload) + } + +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AmplifyTesting.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AmplifyTesting.swift new file mode 100644 index 0000000000..9bd2e82d7c --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AmplifyTesting.swift @@ -0,0 +1,30 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Support for testing in Amplify +enum AmplifyTesting { + + /// Instance factory to use during testing. + private static var instanceFactory: InstanceFactory? + + /// Indicates if XCTest is running. + private static var isTesting: Bool { + ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil + } + + static func assign(instanceFactory: InstanceFactory?) { + Self.instanceFactory = instanceFactory + } + + /// Returns an instance factory only while testing + static func getInstanceFactory() -> InstanceFactory? { + isTesting ? instanceFactory : nil + } + +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/Array+Extensions.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/Array+Extensions.swift new file mode 100644 index 0000000000..fbf50b0dbd --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/Array+Extensions.swift @@ -0,0 +1,17 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +// Inspired from: https://www.hackingwithswift.com/example-code/language/how-to-split-an-array-into-chunks +extension Array { + public func chunked(into size: Int) -> [[Element]] { + return stride(from: 0, to: count, by: size).map { + Array(self[$0 ..< Swift.min($0 + size, count)]) + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AsychronousOperation.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AsychronousOperation.swift new file mode 100644 index 0000000000..762b336e39 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AsychronousOperation.swift @@ -0,0 +1,100 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// This class is to facilitate executing asychronous requests. The caller can transition the operation to its finished +/// state by calling finish() in the callback of an asychronous request to ensure that the operation is only removed +/// from the OperationQueue after it has completed all its work. This class is not inherently thread safe. Although it +/// is a subclass of Foundation's Operation, it contains private state to support pausing, resuming, and finishing, that +/// must be managed by callers. +open class AsynchronousOperation: Operation { + + /// State for this operation. + @objc private enum OperationState: Int { + case notExecuting + case executing + case finished + } + + /// Synchronizes access to `state`. + private let stateQueue = DispatchQueue(label: "com.amazonaws.amplify.AsynchronousOperation.state", + target: DispatchQueue.global()) + + /// Private backing stored property for `state`. + private var _state: OperationState = .notExecuting + + /// The state of the operation + @objc private dynamic var state: OperationState { + get { return stateQueue.sync { _state } } + set { stateQueue.sync { self._state = newValue } } + } + + // MARK: - Various `Operation` properties + + /// `true` if the operation is ready to be executed + open override var isReady: Bool { + return state == .notExecuting && super.isReady + } + + /// `true` if the operation is currently executing + public final override var isExecuting: Bool { + return state == .executing + } + + /// `true` if the operation has completed executing, either successfully or with an error + public final override var isFinished: Bool { + return state == .finished + } + + /// KVN for dependent properties + open override class func keyPathsForValuesAffectingValue(forKey key: String) -> Set { + if ["isReady", "isFinished", "isExecuting"].contains(key) { + return [#keyPath(state)] + } + + return super.keyPathsForValuesAffectingValue(forKey: key) + } + + /// Starts the operation + public final override func start() { + if isCancelled { + state = .finished + return + } + + state = .executing + main() + } + + /// Subclasses must implement this to perform their work and they must not call `super`. + /// The default implementation of this function throws an exception. + open override func main() { + fatalError("Subclasses must implement `main`.") + } + + /// Call this function to pause an operation that is currently executing + open func pause() { + if isExecuting { + state = .notExecuting + } + } + + /// Call this function to resume an operation that is currently ready + open func resume() { + if isReady { + state = .executing + } + } + + /// Call this function to finish an operation that is currently executing + public final func finish() { + if !isFinished { + state = .finished + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AsyncSequence+forEach.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AsyncSequence+forEach.swift new file mode 100644 index 0000000000..6a579bad27 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AsyncSequence+forEach.swift @@ -0,0 +1,20 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public extension AsyncSequence { + + /// Iterates over each element of an AsyncSequence + /// - Parameter block: block to run with element + func forEach(_ block: (Element) async throws -> Void) async rethrows { + for try await element in self { + try await block(element) + } + } + +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AtomicDictionary.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AtomicDictionary.swift new file mode 100644 index 0000000000..fee9411e87 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AtomicDictionary.swift @@ -0,0 +1,79 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public final class AtomicDictionary { + private let lock: NSLocking + private var value: [Key: Value] + + public init(initialValue: [Key: Value] = [Key: Value]()) { + self.lock = NSLock() + self.value = initialValue + } + + public var count: Int { + lock.execute { value.count } + } + + public var keys: [Key] { + lock.execute { Array(value.keys) } + } + + public var values: [Value] { + lock.execute { Array(value.values) } + } + + // MARK: - Functions + + public func getValue(forKey key: Key) -> Value? { + lock.execute { value[key] } + } + + public func removeAll() { + lock.execute { value = [:] } + } + + @discardableResult + func removeValue(forKey key: Key) -> Value? { + return lock.execute { value.removeValue(forKey: key) } + } + + public func set(value: Value, forKey key: Key) { + lock.execute { self.value[key] = value } + } + + public subscript(key: Key) -> Value? { + get { + getValue(forKey: key) + } + set { + if let newValue = newValue { + set(value: newValue, forKey: key) + } else { + removeValue(forKey: key) + } + } + } +} + +extension AtomicDictionary: ExpressibleByDictionaryLiteral { + public convenience init(dictionaryLiteral elements: (Key, Value)...) { + let dictionary: [Key: Value] = .init(uniqueKeysWithValues: elements) + self.init(initialValue: dictionary) + } +} + +extension AtomicDictionary: Sequence { + typealias Iterator = DictionaryIterator + + public func makeIterator() -> DictionaryIterator { + lock.execute { + value.makeIterator() + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AtomicValue+Bool.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AtomicValue+Bool.swift new file mode 100644 index 0000000000..c8a60002c4 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AtomicValue+Bool.swift @@ -0,0 +1,25 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +extension AtomicValue where Value == Bool { + + /// Toggles the boolean's value, and returns the **old** value. + /// + /// Example: + /// ```swift + /// let atomicBool = AtomicValue(initialValue: true) + /// print(atomicBool.getAndToggle()) // prints "true" + /// print(atomicBool.get()) // prints "false" + /// ``` + public func getAndToggle() -> Value { + lock.execute { + let oldValue = value + value.toggle() + return oldValue + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AtomicValue+Numeric.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AtomicValue+Numeric.swift new file mode 100644 index 0000000000..a84dfc64a2 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AtomicValue+Numeric.swift @@ -0,0 +1,24 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +extension AtomicValue where Value: Numeric { + /// Increments the current value by `amount` and returns the incremented value + public func increment(by amount: Value = 1) -> Value { + lock.execute { + value += amount + return value + } + } + + /// Decrements the current value by `amount` and returns the decremented value + public func decrement(by amount: Value = 1) -> Value { + lock.execute { + value -= amount + return value + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AtomicValue+RangeReplaceableCollection.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AtomicValue+RangeReplaceableCollection.swift new file mode 100644 index 0000000000..84396d9c96 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AtomicValue+RangeReplaceableCollection.swift @@ -0,0 +1,32 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +extension AtomicValue where Value: RangeReplaceableCollection { + public func append(_ newElement: Value.Element) { + lock.execute { + value.append(newElement) + } + } + + public func append(contentsOf sequence: S) where S: Sequence, S.Element == Value.Element { + lock.execute { + value.append(contentsOf: sequence) + } + } + + public func removeFirst() -> Value.Element { + lock.execute { + value.removeFirst() + } + } + + public subscript(_ key: Value.Index) -> Value.Element { + lock.execute { + value[key] + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AtomicValue.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AtomicValue.swift new file mode 100644 index 0000000000..3664b17377 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AtomicValue.swift @@ -0,0 +1,61 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// A class that wraps access to its underlying value with an NSLocking instance. +public final class AtomicValue { + let lock: NSLocking + var value: Value + + public init(initialValue: Value) { + self.lock = NSLock() + self.value = initialValue + } + + public func get() -> Value { + lock.execute { + value + } + } + + public func set(_ newValue: Value) { + lock.execute { + value = newValue + } + } + + /// Sets AtomicValue to `newValue` and returns the old value + public func getAndSet(_ newValue: Value) -> Value { + lock.execute { + let oldValue = value + value = newValue + return oldValue + } + } + + /// Performs `block` with the current value, preventing other access until the block exits. + public func atomicallyPerform(block: (Value) -> Void) { + lock.execute { + block(value) + } + } + + /// Performs `block` with an `inout` value, preventing other access until the block exits, + /// and enabling the block to mutate the value + /// + /// - Warning: The AtomicValue lock is not reentrant. Specifically, it is not + /// possible to call outside the block to `get` an AtomicValue (e.g., via a + /// convenience property) while inside the `with` block. Attempting to do so will + /// cause a deadlock. + public func with(block: (inout Value) -> Void) { + lock.execute { + block(&value) + } + } + +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/BasicClosure.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/BasicClosure.swift new file mode 100644 index 0000000000..ba8936d302 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/BasicClosure.swift @@ -0,0 +1,11 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// Convenience typealias +public typealias BasicClosure = () -> Void + +public typealias BasicThrowableClosure = () throws -> Void diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/BufferingSequence.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/BufferingSequence.swift new file mode 100644 index 0000000000..539e7e873d --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/BufferingSequence.swift @@ -0,0 +1,25 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public protocol BufferingSequence { + associatedtype Element + var bufferingPolicy: AsyncStream.Continuation.BufferingPolicy { get } +} + +public extension BufferingSequence { + var bufferingPolicy: AsyncStream.Continuation.BufferingPolicy { + .unbounded + } +} + +public extension BufferingSequence where Element == Progress { + var bufferingPolicy: AsyncStream.Continuation.BufferingPolicy { + .bufferingNewest(5) + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/Cancellable.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/Cancellable.swift new file mode 100644 index 0000000000..15f5db5b6a --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/Cancellable.swift @@ -0,0 +1,20 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import _Concurrency + +/// The conforming type supports cancelling an in-process operation. The exact semantics of "canceling" are not defined +/// in the protocol. Specifically, there is no guarantee that a `cancel` results in immediate cessation of activity. +public protocol Cancellable { + func cancel() +} + +/// Unique name for Cancellable which handles a name conflict with the Combine framework. +public typealias AmplifyCancellable = Cancellable + +extension _Concurrency.Task: AmplifyCancellable {} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/ChildTask.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/ChildTask.swift new file mode 100644 index 0000000000..b9fa73adad --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/ChildTask.swift @@ -0,0 +1,105 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Takes a Parent Operation which conforms to Cancellable so that if the +/// Child Task is cancelled it will also cancel the parent. +actor ChildTask: BufferingSequence { + typealias Element = InProcess + private let parent: Cancellable + private var inProcessChannel: AmplifyAsyncSequence? + private var valueContinuations: [CheckedContinuation] = [] + private var storedResult: Result? + private var isCancelled = false + + var inProcess: AmplifyAsyncSequence { + let channel: AmplifyAsyncSequence + if let inProcessChannel = inProcessChannel { + channel = inProcessChannel + } else { + channel = AmplifyAsyncSequence(bufferingPolicy: bufferingPolicy) + inProcessChannel = channel + } + + // finish channel if there is already a result + if storedResult != nil || isCancelled { + channel.finish() + } + return channel + } + + var value: Success { + get async throws { + try await withTaskCancellationHandler(handler: { + Task { + await cancel() + } + }, operation: { + try await withCheckedThrowingContinuation { continuation in + if isCancelled { + // immediately cancel is already cancelled + continuation.resume(throwing: CancellationError()) + } else if let result = storedResult { + // immediately send result if it is available + valueContinuations.append(continuation) + send(result) + } else { + // capture contination to use later + valueContinuations.append(continuation) + } + } + }) + } + } + + init(parent: Cancellable) { + self.parent = parent + } + + func report(_ inProcess: InProcess?) async throws { + if let channel = inProcessChannel { + if let inProcess = inProcess { + channel.send(inProcess) + } else { + // nil indicates the sequence is done + channel.finish() + } + } + } + + func finish(_ result: Result) { + if !valueContinuations.isEmpty { + send(result) + } + // store result for when the value property is used + self.storedResult = result + if let channel = inProcessChannel { + channel.finish() + } + } + + func cancel() async { + isCancelled = true + if let channel = inProcessChannel { + channel.finish() + } + while !valueContinuations.isEmpty { + let continuation = valueContinuations.removeFirst() + continuation.resume(throwing: CancellationError()) + } + parent.cancel() + } + + private func send(_ result: Result) { + while !valueContinuations.isEmpty { + let continuation = valueContinuations.removeFirst() + continuation.resume(with: result) + } + } + +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/DeviceInfo.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/DeviceInfo.swift new file mode 100644 index 0000000000..c55e0d9baf --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/DeviceInfo.swift @@ -0,0 +1,142 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +// Note: It's important to check for WatchKit first because a stripped-down version of UIKit is also +// available on watchOS +#if canImport(WatchKit) +import WatchKit +#elseif canImport(UIKit) +import UIKit +#elseif canImport(IOKit) +import IOKit +#endif +#if canImport(AppKit) +import AppKit +#endif + +/// Convenience type that may be used to access device info across different platforms. +/// +/// Usage Example: +/// +/// ``` +/// print(DeviceInfo.current.architecture) /* prints "x86_64", "arm64", or "unknown" */ +/// ``` +/// +/// - Tag: DeviceInfo +public struct DeviceInfo { + + private init() {} + + /// - Tag: DeviceInfo.current + public static var current: DeviceInfo = DeviceInfo() + + /// Returns the name of the host or device + /// + /// - Tag: DeviceInfo.name + public var name: String { + #if canImport(WatchKit) + WKInterfaceDevice.current().name + #elseif canImport(UIKit) + UIDevice.current.name + #else + ProcessInfo.processInfo.hostName + #endif + } + + /// Returns the name of the host + /// + /// - Tag: DeviceInfo.hostName + public var hostName: String { + ProcessInfo.processInfo.hostName + } + + /// Returns "x86_64", "arm64", or "unknown" depending on the architecture of the device. + /// + /// - Tag: DeviceInfo.architecture + public var architecture: String { + #if arch(x86_64) + "x86_64" + #elseif arch(arm64) + "arm64" + #else + "unknown" + #endif + } + + /// Returns the name of the model of the device + /// + /// - Tag: DeviceInfo.model + public var model: String { + #if canImport(WatchKit) + WKInterfaceDevice.current().model + #elseif canImport(UIKit) + UIDevice.current.model + #elseif canImport(IOKit) + value(forKey: "model") ?? "Mac" + #else + "Mac" + #endif + } + + /// Returns a tuple with the name of the operating system, e.g. "watchOS", "iOS", or "macOS" and its + /// semantic version number + /// + /// - Tag: DeviceInfo.operatingSystem + public var operatingSystem: (name: String, version: String) { + #if canImport(WatchKit) + let device = WKInterfaceDevice.current() + return (name: device.systemName, version: device.systemVersion) + #elseif canImport(UIKit) + let device = UIDevice.current + return (name: device.systemName, version: device.systemVersion) + #else + return (name: "macOS", + version: ProcessInfo.processInfo.operatingSystemVersionString) + #endif + } + + /// If available, returns the unique identifier for the device + /// + /// - Tag: DeviceInfo.identifierForVendor + public var identifierForVendor: UUID? { + #if canImport(WatchKit) + WKInterfaceDevice.current().identifierForVendor + #elseif canImport(UIKit) + UIDevice.current.identifierForVendor + #else + nil + #endif + } + + /// Returns the bounding rect of the main screen of the device + /// + /// - Tag: DeviceInfo.screenBounds + public var screenBounds: CGRect { + #if canImport(WatchKit) + .zero + #elseif canImport(UIKit) + UIScreen.main.nativeBounds + #elseif canImport(AppKit) + NSScreen.main?.visibleFrame ?? .zero + #endif + } + +#if canImport(IOKit) + private func value(forKey key: String) -> String? { + let service = IOServiceGetMatchingService(kIOMasterPortDefault, + IOServiceMatching("IOPlatformExpertDevice")) + var modelIdentifier: String? + if let modelData = IORegistryEntryCreateCFProperty(service, key as CFString, kCFAllocatorDefault, 0).takeRetainedValue() as? Data { + modelIdentifier = String(data: modelData, encoding: .utf8)?.trimmingCharacters(in: .controlCharacters) + } + + IOObjectRelease(service) + return modelIdentifier + } +#endif +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/DispatchSource+MakeOneOff.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/DispatchSource+MakeOneOff.swift new file mode 100644 index 0000000000..0bf038b33b --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/DispatchSource+MakeOneOff.swift @@ -0,0 +1,42 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension DispatchSource { + /// Convenience function to encapsulate creation of a one-off DispatchSourceTimer for different versions of Swift + /// + /// - Parameters: + /// - interval: The future DispatchInterval at which to fire the timer + /// - queue: The queue on which the timer should perform its block + /// - block: The block to invoke when the timer is fired + /// - Returns: The unstarted timer + public static func makeOneOffDispatchSourceTimer(interval: DispatchTimeInterval, + queue: DispatchQueue, + block: @escaping () -> Void ) -> DispatchSourceTimer { + let deadline = DispatchTime.now() + interval + return makeOneOffDispatchSourceTimer(deadline: deadline, queue: queue, block: block) + } + + /// Convenience function to encapsulate creation of a one-off DispatchSourceTimer for different versions of Swift + /// - Parameters: + /// - deadline: The time to fire the timer + /// - queue: The queue on which the timer should perform its block + /// - block: The block to invoke when the timer is fired + public static func makeOneOffDispatchSourceTimer(deadline: DispatchTime, + queue: DispatchQueue, + block: @escaping () -> Void ) -> DispatchSourceTimer { + let timer = DispatchSource.makeTimerSource(flags: DispatchSource.TimerFlags(rawValue: 0), queue: queue) + #if swift(>=4) + timer.schedule(deadline: deadline) + #else + timer.scheduleOneshot(deadline: deadline) + #endif + timer.setEventHandler(handler: block) + return timer + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/Encodable+AnyEncodable.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/Encodable+AnyEncodable.swift new file mode 100644 index 0000000000..17baadd403 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/Encodable+AnyEncodable.swift @@ -0,0 +1,29 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public struct AnyEncodable: Encodable { + + let encodable: Encodable + + init(_ encodable: Encodable) { + self.encodable = encodable + } + + public func encode(to encoder: Encoder) throws { + try encodable.encode(to: encoder) + } +} + +extension Encodable { + + public func eraseToAnyEncodable() -> AnyEncodable { + return AnyEncodable(self) + } + +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/Fatal.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/Fatal.swift new file mode 100644 index 0000000000..fa1600351d --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/Fatal.swift @@ -0,0 +1,80 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +// Credit: Dave DeLong +// https://forums.swift.org/t/introducing-namespacing-for-common-swift-error-scenarios/10773 + +/// An umbrella type supplying static members to handle common +/// and conventional exit scenarios. +public enum Fatal { + + @discardableResult + public static func preconditionFailure(_ message: @autoclosure () -> String = String(), + file: StaticString = #file, + line: UInt = #line) -> T { + guard let instanceFactory = AmplifyTesting.getInstanceFactory() else { + Swift.preconditionFailure(message(), file: file, line: line) + } + do { + return try instanceFactory.get(type: T.self, message: message()) + } catch { + die(reason: "Error: \(error)", file: file, line: line) + } + } + + /// Die because a default method must be overriden by a + /// subtype or extension. + public static func mustOverride(function: StaticString = #function, + file: StaticString = #file, + line: UInt = #line) -> Never { + die(reason: "Must be overridden", extra: String(describing: function), file: file, line: line) + } + + /// Die because this code branch should be unreachable + public static func unreachable(_ why: String, file: StaticString = #file, line: UInt = #line) -> Never { + die(reason: "Unreachable", extra: why, file: file, line: line) + } + + /// Die because this method or function has not yet been implemented. + /// + /// - Note: This name takes precedence over `unimplemented` as it + /// is clearer and more Swifty + public static func notImplemented(_ why: String? = nil, file: StaticString = #file, line: UInt = #line) -> Never { + die(reason: "Not Implemented", extra: why, file: file, line: line) + } + + /// Die because of a failed assertion. This does not distinguish + /// between logic conditions (as in `assert`) and user calling + /// requirements (as in `precondition`) + public static func require(_ why: String? = nil, file: StaticString = #file, line: UInt = #line) -> Never { + die(reason: "Assertion failed", extra: why, file: file, line: line) + } + + /// Die because this TO DO item has not yet been implemented. + public static func TODO(_ reason: String? = nil, file: StaticString = #file, line: UInt = #line) -> Never { + die(reason: "Not yet implemented", extra: reason, file: file, line: line) + } + + /// Provide a `Fatal.error` equivalent to fatalError() to move + /// Swift standard style away from using `fatalError` directly + public static func error(_ reason: String? = nil, file: StaticString = #file, line: UInt = #line) -> Never { + die(reason: "", extra: reason, file: file, line: line) + } + + /// Performs a diagnostic fatal error with reason and + /// context information. + private static func die(reason: String, extra: String? = nil, file: StaticString, line: UInt) -> Never { + var message = reason + if let extra = extra { + message += ": \(extra)" + } + fatalError(message, file: file, line: line) + } + +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/InstanceFactory.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/InstanceFactory.swift new file mode 100644 index 0000000000..b9c1b8123f --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/InstanceFactory.swift @@ -0,0 +1,12 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +protocol InstanceFactory { + func get(type: T.Type, message: @autoclosure () -> String) throws -> T +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/Internal/InternalTask+AsyncSequence.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/Internal/InternalTask+AsyncSequence.swift new file mode 100644 index 0000000000..ad66b30b94 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/Internal/InternalTask+AsyncSequence.swift @@ -0,0 +1,99 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public extension InternalTaskAsyncSequence where Self: InternalTaskRunner { + + var sequence: AmplifyAsyncSequence { + guard let sequence = context.sequence else { + let task = Task { [weak self] in + guard let self = self else { return } + try await self.run() + } + + let sequence = AmplifyAsyncSequence(parent: self, bufferingPolicy: context.bufferingPolicy) + self.context.task = task + context.sequence = sequence + return sequence + } + + return sequence + } + + func cancel() { + sequence.cancel() + context.task?.cancel() + context.task = nil + context.sequence = nil + } + +} + +public extension InternalTaskAsyncThrowingSequence where Self: InternalTaskRunner { + + var sequence: AmplifyAsyncThrowingSequence { + guard let sequence = context.sequence else { + let sequence = AmplifyAsyncThrowingSequence(parent: self, bufferingPolicy: context.bufferingPolicy) + context.sequence = sequence + + let task = Task { [weak self] in + guard let self = self else { return } + try await self.run() + } + self.context.task = task + + return sequence + } + + return sequence + } + + func cancel() { + sequence.cancel() + context.task?.cancel() + context.task = nil + context.sequence = nil + } + +} + +public extension InternalTaskChannel where Self: InternalTaskRunner & InternalTaskAsyncSequence { + + /// Sends element to sequence + /// - Parameter element: element + func send(_ element: InProcess) { + context.sequence?.send(element) + } + + /// Terminates sequence + func finish() { + context.sequence?.finish() + } + +} + +public extension InternalTaskThrowingChannel where Self: InternalTaskRunner & InternalTaskAsyncThrowingSequence { + + /// Sends element to sequence + /// - Parameter element: element + func send(_ element: InProcess) { + context.sequence?.send(element) + } + + /// Terminates sequence + func finish() { + context.sequence?.finish() + } + + /// Fails sequence + /// - Parameter error: error + func fail(_ error: Error) { + context.sequence?.fail(error) + } + +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/Internal/InternalTask+Hub.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/Internal/InternalTask+Hub.swift new file mode 100644 index 0000000000..71bdb20ecb --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/Internal/InternalTask+Hub.swift @@ -0,0 +1,206 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public extension InternalTaskIdentifiable { + + var idFilter: HubFilter { + let filter: HubFilter = { payload in + guard let context = payload.context as? AmplifyOperationContext else { + return false + } + + return context.operationId == id + } + + return filter + } + +} + +public extension InternalTaskHubResult { + + /// Unsubscribe from Hub channel + /// - Parameter token: unsubscribe token + func unsubscribe(_ token: UnsubscribeToken) { + Amplify.Hub.removeListener(token) + } + +} + +public extension InternalTaskHubInProcess { + + /// Unsubscribe from Hub channel + /// - Parameter token: unsubscribe token + func unsubscribe(_ token: UnsubscribeToken) { + Amplify.Hub.removeListener(token) + } + +} + +public extension InternalTaskHubResult where Self: InternalTaskIdentifiable & InternalTaskResult { + + /// Subscribe to channel on Hub for result + /// - Parameter resultListener: result listener + /// - Returns: unsubscribe token + func subscribe(resultListener: @escaping ResultListener) -> UnsubscribeToken { + let channel = HubChannel(from: categoryType) + + var unsubscribe: (() -> Void)? + let resultHubListener: HubListener = { payload in + guard let result = payload.data as? TaskResult else { + return + } + resultListener(result) + // Automatically unsubscribe when event is received + unsubscribe?() + } + let token = Amplify.Hub.listen(to: channel, + isIncluded: idFilter, + listener: resultHubListener) + unsubscribe = { + Amplify.Hub.removeListener(token) + } + return token + } + + /// Dispatch result to Hub channel + /// - Parameter result: result + func dispatch(result: TaskResult) { + let channel = HubChannel(from: categoryType) + let context = AmplifyOperationContext(operationId: id, request: request) + let payload = HubPayload(eventName: eventName, context: context, data: result) + Amplify.Hub.dispatch(to: channel, payload: payload) + } + +} + +public extension InternalTaskHubInProcess where Self: InternalTaskIdentifiable & InternalTaskInProcess { + + /// Subscribe to channel on Hub for InProcess value + /// - Parameter resultListener: InProcess listener + /// - Returns: unsubscribe token + func subscribe(inProcessListener: @escaping InProcessListener) -> UnsubscribeToken { + let channel = HubChannel(from: categoryType) + + let inProcessHubListener: HubListener = { payload in + if let inProcessData = payload.data as? InProcess { + inProcessListener(inProcessData) + return + } + } + let token = Amplify.Hub.listen(to: channel, + isIncluded: idFilter, + listener: inProcessHubListener) + return token + } + + /// Dispatch value to sequence + /// - Parameter inProcess: InProcess value + func dispatch(inProcess: InProcess) { + let channel = HubChannel(from: categoryType) + let context = AmplifyOperationContext(operationId: id, request: request) + let payload = HubPayload(eventName: eventName, context: context, data: inProcess) + Amplify.Hub.dispatch(to: channel, payload: payload) + } + +} + +public extension InternalTaskHubInProcess where Self: InternalTaskIdentifiable & InternalTaskResult & InternalTaskInProcess { + + /// Subscribe to channel on Hub for InProcess value + /// - Parameter resultListener: InProcess listener + /// - Returns: unsubscribe token + func subscribe(inProcessListener: @escaping InProcessListener) -> UnsubscribeToken { + let channel = HubChannel(from: categoryType) + + var unsubscribe: (() -> Void)? + let inProcessHubListener: HubListener = { payload in + if let inProcessData = payload.data as? InProcess { + inProcessListener(inProcessData) + return + } + + // Remove listener if we see a result come through + if payload.data is TaskResult { + unsubscribe?() + } + } + let token = Amplify.Hub.listen(to: channel, + isIncluded: idFilter, + listener: inProcessHubListener) + unsubscribe = { + Amplify.Hub.removeListener(token) + } + return token + } + +} + +public extension InternalTaskHubInProcess where Self: InternalTaskIdentifiable { + + /// Subscribe to channel on Hub for InProcess value + /// - Parameter resultListener: InProcess listener + /// - Returns: unsubscribe token + func subscribe(inProcessListener: @escaping InProcessListener) -> UnsubscribeToken { + let channel = HubChannel(from: categoryType) + let filterById = idFilter + + let inProcessHubListener: HubListener = { payload in + if let inProcessData = payload.data as? InProcess { + inProcessListener(inProcessData) + return + } + } + let token = Amplify.Hub.listen(to: channel, + isIncluded: filterById, + listener: inProcessHubListener) + return token + } + + /// Dispatch value to sequence + /// - Parameter inProcess: InProcess value + func dispatch(inProcess: InProcess) { + let channel = HubChannel(from: categoryType) + let context = AmplifyOperationContext(operationId: id, request: request) + let payload = HubPayload(eventName: eventName, context: context, data: inProcess) + Amplify.Hub.dispatch(to: channel, payload: payload) + } +} + +public extension InternalTaskHubInProcess where Self: InternalTaskIdentifiable & InternalTaskResult { + + /// Subscribe to channel on Hub for InProcess value + /// - Parameter resultListener: InProcess listener + /// - Returns: unsubscribe token + func subscribe(inProcessListener: @escaping InProcessListener) -> UnsubscribeToken { + let channel = HubChannel(from: categoryType) + let filterById = idFilter + + var unsubscribe: (() -> Void)? + let inProcessHubListener: HubListener = { payload in + if let inProcessData = payload.data as? InProcess { + inProcessListener(inProcessData) + return + } + + // Remove listener if we see a result come through + if payload.data is TaskResult { + unsubscribe?() + } + } + let token = Amplify.Hub.listen(to: channel, + isIncluded: filterById, + listener: inProcessHubListener) + unsubscribe = { + Amplify.Hub.removeListener(token) + } + return token + } + +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/Internal/InternalTask+Result.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/Internal/InternalTask+Result.swift new file mode 100644 index 0000000000..d0d05a1179 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/Internal/InternalTask+Result.swift @@ -0,0 +1,19 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public extension InternalTaskValue where Self: InternalTaskResult { + + /// Value from result + var value: Success { + get async throws { + try await result.get() + } + } + +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/Internal/InternalTask.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/Internal/InternalTask.swift new file mode 100644 index 0000000000..10277e4298 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/Internal/InternalTask.swift @@ -0,0 +1,174 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +// MARK: - Result/Value - + +public protocol InternalTaskResult { + associatedtype Success + associatedtype Failure: AmplifyError + typealias TaskResult = Result + var result: TaskResult { get async } +} + +public protocol InternalTaskValue { + associatedtype Success + associatedtype Failure: AmplifyError + var value: Success { get async throws } +} + +// MARK: - Sequences - + +public protocol InternalTaskInProcess { + associatedtype InProcess: Sendable + var inProcess: AmplifyAsyncSequence { get async } +} + +public struct InternalTaskAsyncSequenceContext { + public let bufferingPolicy: AsyncStream.Continuation.BufferingPolicy + public var weakSequenceRef: WeakAmplifyAsyncSequenceRef + public var task: Task? + public var sequence: AmplifyAsyncSequence? { + get { + weakSequenceRef.value + } + set { + weakSequenceRef.value = newValue + } + } + + public init(bufferingPolicy: AsyncStream.Continuation.BufferingPolicy = .unbounded) { + self.bufferingPolicy = bufferingPolicy + self.weakSequenceRef = WeakAmplifyAsyncSequenceRef(nil) + } +} + +public protocol InternalTaskAsyncSequence: AnyObject { + associatedtype InProcess: Sendable + var context: InternalTaskAsyncSequenceContext { get set } + var sequence: AmplifyAsyncSequence { get } +} + +public struct InternalTaskAsyncThrowingSequenceContext { + public let bufferingPolicy: AsyncThrowingStream.Continuation.BufferingPolicy + public var weakSequenceRef: WeakAmplifyAsyncThrowingSequenceRef + public var task: Task? + public var sequence: AmplifyAsyncThrowingSequence? { + get { + weakSequenceRef.value + } + set { + weakSequenceRef.value = newValue + } + } + + public init(bufferingPolicy: AsyncThrowingStream.Continuation.BufferingPolicy = .unbounded) { + self.bufferingPolicy = bufferingPolicy + self.weakSequenceRef = WeakAmplifyAsyncThrowingSequenceRef(nil) + } +} + +public protocol InternalTaskAsyncThrowingSequence: AnyObject { + associatedtype InProcess: Sendable + var context: InternalTaskAsyncThrowingSequenceContext { get set } + var sequence: AmplifyAsyncThrowingSequence { get } +} + +public protocol InternalTaskChannel { + associatedtype InProcess: Sendable + + /// Sends element to sequence + /// - Parameter element: element + func send(_ element: InProcess) + + /// Terminates sequence + func finish() +} + +public protocol InternalTaskThrowingChannel { + associatedtype InProcess: Sendable + + /// Sends element to sequence + /// - Parameter element: element + func send(_ element: InProcess) + + /// Fails sequence + /// - Parameter error: error + func fail(_ error: Error) + + /// Terminates sequence + func finish() +} + +// MARK: - Control - + +public protocol InternalTaskRunner: AnyObject, AmplifyCancellable { + associatedtype Request: AmplifyOperationRequest + var request: Request { get } + + /// Run task + func run() async throws +} + +public protocol InternalTaskController { + func pause() + func resume() + func cancel() +} + +// MARK: - Hub Support - + +public protocol InternalTaskIdentifiable { + associatedtype Request: AmplifyOperationRequest + var id: UUID { get } + var request: Request { get } + var categoryType: CategoryType { get } + var eventName: HubPayloadEventName { get } +} + +public protocol InternalTaskHubResult { + associatedtype Request: AmplifyOperationRequest + associatedtype Success + associatedtype Failure: AmplifyError + + typealias OperationResult = Result + typealias ResultListener = (OperationResult) -> Void + + /// Subscribe for result + /// - Parameter resultListener: result listener + /// - Returns: unsubscribe token + func subscribe(resultListener: @escaping ResultListener) -> UnsubscribeToken + + /// Unsubscribe from Hub channel + /// - Parameter token: unsubscribe token + func unsubscribe(_ token: UnsubscribeToken) + + /// Dispatch result to Hub channel + /// - Parameter result: result + func dispatch(result: OperationResult) +} + +public protocol InternalTaskHubInProcess { + associatedtype Request: AmplifyOperationRequest + associatedtype InProcess: Sendable + + typealias InProcessListener = (InProcess) -> Void + + /// Subscribe for result + /// - Parameter resultListener: result listener + /// - Returns: unsubscribe token + func subscribe(inProcessListener: @escaping InProcessListener) -> UnsubscribeToken + + /// Unsubscribe from Hub channel + /// - Parameter token: unsubscribe token + func unsubscribe(_ token: UnsubscribeToken) + + /// Dispatch value to sequence + /// - Parameter inProcess: InProcess value + func dispatch(inProcess: InProcess) +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/Internal/NSLocking+Execute.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/Internal/NSLocking+Execute.swift new file mode 100644 index 0000000000..87de3b3aba --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/Internal/NSLocking+Execute.swift @@ -0,0 +1,50 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// - Warning: Although this has `public` access, it is intended for internal use +/// and should not be used directly by host applications. The behaviors and names of +/// this type may change without warning. +extension NSLocking { + /// Execute `block` after obtaining a lock on `lock`. + /// + /// - Warning: Although this has `public` access, it is intended for internal use + /// and should not be used directly by host applications. The behaviors and names of + /// this type may change without warning. + /// - Parameters: + /// - block: The block to execute + public func execute( + _ block: BasicThrowableClosure + ) rethrows { + try execute(input: (), block: block) + } + + /// Execute `block` after obtaining a lock on `lock`, returning the output of + /// `block` + /// + /// - Warning: Although this has `public` access, it is intended for internal use + /// and should not be used directly by host applications. The behaviors and names of + /// this type may change without warning. + /// - Parameters: + /// - block: The block to execute + public func execute( + _ block: () throws -> Output + ) rethrows -> Output { + try execute(input: (), block: block) + } + + private func execute( + input: Input, + block: (Input) throws -> Output + ) rethrows -> Output { + lock() + defer { self.unlock() } + return try block(input) + } + +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/JSONValue+KeyPath.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/JSONValue+KeyPath.swift new file mode 100644 index 0000000000..7e805fb09b --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/JSONValue+KeyPath.swift @@ -0,0 +1,31 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public extension JSONValue { + func value(at keyPath: String) -> JSONValue? { + value(at: keyPath, separatedBy: ".") + } + + func value(at keyPath: String, + separatedBy separator: T) -> JSONValue? { + let pathComponents = keyPath.components(separatedBy: separator) + let value = pathComponents.reduce(self) { currVal, nextVal in currVal?[nextVal] } + return value + } + + func value(at keyPath: String, withDefault defaultValue: JSONValue) -> JSONValue { + guard let jsonValue = value(at: keyPath) else { + return defaultValue + } + if case .null = jsonValue { + return defaultValue + } + return jsonValue + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/JSONValue+Subscript.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/JSONValue+Subscript.swift new file mode 100644 index 0000000000..65c72d570b --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/JSONValue+Subscript.swift @@ -0,0 +1,33 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public extension JSONValue { + + subscript(_ key: String) -> JSONValue? { + guard case .object(let object) = self else { + return nil + } + return object[key] + } + + subscript(_ key: Int) -> JSONValue? { + switch self { + case .array(let array): + return array[key] + case .object(let object): + return object["\(key)"] + default: + return nil + } + } + + subscript(dynamicMember member: String) -> JSONValue? { + self[member] + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/JSONValue.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/JSONValue.swift new file mode 100644 index 0000000000..afb1a243ad --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/JSONValue.swift @@ -0,0 +1,167 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// A utility type that allows us to represent an arbitrary JSON structure +@dynamicMemberLookup +public enum JSONValue { + case array([JSONValue]) + case boolean(Bool) + case number(Double) + case object([String: JSONValue]) + case string(String) + case null +} + +extension JSONValue: Codable { + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if let value = try? container.decode([String: JSONValue].self) { + self = .object(value) + } else if let value = try? container.decode([JSONValue].self) { + self = .array(value) + } else if let value = try? container.decode(Double.self) { + self = .number(value) + } else if let value = try? container.decode(Bool.self) { + self = .boolean(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else { + self = .null + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + switch self { + case .array(let value): + try container.encode(value) + case .boolean(let value): + try container.encode(value) + case .number(let value): + try container.encode(value) + case .object(let value): + try container.encode(value) + case .string(let value): + try container.encode(value) + case .null: + try container.encodeNil() + } + } + +} + +extension JSONValue: Equatable { } + +extension JSONValue: ExpressibleByArrayLiteral { + public init(arrayLiteral elements: JSONValue...) { + self = .array(elements) + } +} + +extension JSONValue: ExpressibleByBooleanLiteral { + public init(booleanLiteral value: Bool) { + self = .boolean(value) + } +} + +extension JSONValue: ExpressibleByDictionaryLiteral { + public init(dictionaryLiteral elements: (String, JSONValue)...) { + let dictionary = elements.reduce([String: JSONValue]()) { acc, curr in + var newValue = acc + newValue[curr.0] = curr.1 + return newValue + } + self = .object(dictionary) + } +} + +extension JSONValue: ExpressibleByFloatLiteral { + public init(floatLiteral value: Double) { + self = .number(value) + } +} + +extension JSONValue: ExpressibleByIntegerLiteral { + public init(integerLiteral value: Int) { + self = .number(Double(value)) + } +} + +extension JSONValue: ExpressibleByNilLiteral { + public init(nilLiteral: ()) { + self = .null + } +} + +extension JSONValue: ExpressibleByStringLiteral { + public init(stringLiteral value: String) { + self = .string(value) + } +} + +extension JSONValue { + + public var asObject: [String: JSONValue]? { + if case .object(let object) = self { + return object + } + + return nil + } + + public var asArray: [JSONValue]? { + if case .array(let array) = self { + return array + } + + return nil + } + + public var stringValue: String? { + if case .string(let string) = self { + return string + } + + return nil + } + + public var intValue: Int? { + if case .number(let double) = self, + double < Double(Int.max) && double >= Double(Int.min) { + return Int(double) + } + return nil + } + + public var doubleValue: Double? { + if case .number(let double) = self { + return double + } + + return nil + } + + public var booleanValue: Bool? { + if case .boolean(let bool) = self { + return bool + } + + return nil + } + + public var isNull: Bool { + if case .null = self { + return true + } + + return false + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/OperationCancelledError.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/OperationCancelledError.swift new file mode 100644 index 0000000000..6ce876b9a0 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/OperationCancelledError.swift @@ -0,0 +1,40 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// This is public so plugins can use it to indicate cancellations on arbitrary operations. +/// +/// - Warning: Although this has `public` access, it is intended for internal use +/// and should not be used directly by host applications. The behavior of this may +/// change without warning. +public struct OperationCancelledError: Error { + public init() { } +} + +/// This is public so plugins can use it to indicate cancellations on arbitrary operations. +/// +/// - Warning: Although this has `public` access, it is intended for internal use +/// and should not be used directly by host applications. The behavior of this may +/// change without warning. +public extension AmplifyError { + var isOperationCancelledError: Bool { + guard let underlyingError = underlyingError else { + return false + } + return underlyingError.isOperationCancelledError + } +} + +/// This is public so plugins can use it to indicate cancellations on arbitrary operations. +/// +/// - Warning: Although this has `public` access, it is intended for internal use +/// and should not be used directly by host applications. The behavior of this may +/// change without warning. +public extension Error { + var isOperationCancelledError: Bool { + self is OperationCancelledError + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/Operations+Combine.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/Operations+Combine.swift new file mode 100644 index 0000000000..f1cf40d789 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/Operations+Combine.swift @@ -0,0 +1,26 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#if canImport(Combine) +import Foundation +import Combine + +public extension AmplifyOperation { + /// Publishes the final result of the operation + var resultPublisher: AnyPublisher { + internalResultPublisher + } +} + +public extension AmplifyInProcessReportingOperation { + /// Publishes in-process updates + var inProcessPublisher: AnyPublisher { + internalInProcessPublisher + } +} + +#endif diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/Optional+Extension.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/Optional+Extension.swift new file mode 100644 index 0000000000..4efb0d736f --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/Optional+Extension.swift @@ -0,0 +1,21 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension Optional { + /// + /// Performing side effect function when data is exist + /// - parameters: + /// - then: a closure that takes wrapped data as a parameter + @_spi(OptionalExtension) + public func ifSome(_ then: (Wrapped) throws -> Void) rethrows { + if case .some(let wrapped) = self { + try then(wrapped) + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/RequestIdentifier.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/RequestIdentifier.swift new file mode 100644 index 0000000000..09d7b74a3c --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/RequestIdentifier.swift @@ -0,0 +1,12 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public protocol RequestIdentifier { + var requestID: String { get } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/Result+Void.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/Result+Void.swift new file mode 100644 index 0000000000..5c6060d230 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/Result+Void.swift @@ -0,0 +1,10 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +extension Result where Success == Void { + public static var successfulVoid: Result { .success(()) } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/Resumable.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/Resumable.swift new file mode 100644 index 0000000000..37fa42d4b8 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/Resumable.swift @@ -0,0 +1,14 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// The conforming type supports pausing and resuming of an in-process operation. The exact semantics of "pausing" and +/// "resuming" are not defined in the protocol. Specifically, there is no guarantee that a `pause` results in an +/// immediate suspention of activity, and no guarantee that `resume` will result in an immediate resumption of activity. +public protocol Resumable { + func pause() + func resume() +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/String+Extensions.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/String+Extensions.swift new file mode 100644 index 0000000000..995ddcac9d --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/String+Extensions.swift @@ -0,0 +1,38 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +extension String { + + /// Converts a "camelCase" value to "PascalCase". This is a very simple + /// and naive implementation that assumes the input as a "camelCase" value + /// and won't perform complex conversions, such as from "snake_case" + /// or "dash-case" to "PascalCase". + /// + /// - Note: this method simply transforms the first character to uppercase. + /// + /// - Returns: a string in "PascalCase" converted from "camelCase" + public func pascalCased() -> String { + return prefix(1).uppercased() + dropFirst() + } + + /// Converts a "PascalCase" value to "camelCase". This is a very simple + /// and naive implementation that assumes the input as a "PascalCase" value + /// and won't perform complex conversions, such as from "snake_case" + /// or "dash-case" to "pascalCase". + /// + /// - Note: this method simply transforms the first character to lowercase. + /// + /// - Returns: a string in "pascalCase" converted from "CamelCase" + public func camelCased() -> String { + return prefix(1).lowercased() + dropFirst() + } + + /// Appends "s" to the end of the string to represent the pluralized form. + public func pluralize() -> String { + return self + "s" + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/Task+Seconds.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/Task+Seconds.swift new file mode 100644 index 0000000000..c2a5a1ac22 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/Task+Seconds.swift @@ -0,0 +1,15 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public extension Task where Success == Never, Failure == Never { + static func sleep(seconds: Double) async throws { + let nanoseconds = UInt64(seconds * Double(NSEC_PER_SEC)) + try await Task.sleep(nanoseconds: nanoseconds) + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/TaskQueue.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/TaskQueue.swift new file mode 100644 index 0000000000..b7ba5c0553 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/TaskQueue.swift @@ -0,0 +1,69 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// A helper for executing asynchronous work serially. +public class TaskQueue { + typealias Block = @Sendable () async -> Void + private let streamContinuation: AsyncStream.Continuation + + public init() { + let (stream, continuation) = AsyncStream.makeStream(of: Block.self) + self.streamContinuation = continuation + + Task { + for await block in stream { + _ = await block() + } + } + } + + deinit { + streamContinuation.finish() + } + + /// Serializes asynchronous requests made from an async context + /// + /// Given an invocation like + /// ```swift + /// let tq = TaskQueue() + /// let v1 = try await tq.sync { try await doAsync1() } + /// let v2 = try await tq.sync { try await doAsync2() } + /// let v3 = try await tq.sync { try await doAsync3() } + /// ``` + /// TaskQueue serializes this work so that `doAsync1` is performed before `doAsync2`, + /// which is performed before `doAsync3`. + public func sync(block: @Sendable @escaping () async throws -> Success) async throws -> Success { + try await withCheckedThrowingContinuation { continuation in + streamContinuation.yield { + do { + let value = try await block() + continuation.resume(returning: value) + } catch { + continuation.resume(throwing: error) + } + } + } + } + + public func async(block: @Sendable @escaping () async throws -> Success) { + streamContinuation.yield { + do { + _ = try await block() + } catch { + Self.log.warn("Failed to handle async task in TaskQueue<\(Success.self)> with error: \(error)") + } + } + } +} + +extension TaskQueue { + public static var log: Logger { + Amplify.Logging.logger(forNamespace: String(describing: self)) + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/TimeInterval+Helper.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/TimeInterval+Helper.swift new file mode 100644 index 0000000000..b8baf2a3ef --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/TimeInterval+Helper.swift @@ -0,0 +1,32 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension TimeInterval { + + public static func milliseconds(_ value: Double) -> TimeInterval { + return value / 1_000 + } + + public static func seconds(_ value: Double) -> TimeInterval { + return value + } + + public static func minutes(_ value: Double) -> TimeInterval { + return value * 60 + } + + public static func hours(_ value: Double) -> TimeInterval { + return value * 60 * 60 + } + + public static func days(_ value: Double) -> TimeInterval { + return value * 60 * 60 * 24 + } + +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/Tree.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/Tree.swift new file mode 100644 index 0000000000..4c7a8f615d --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/Tree.swift @@ -0,0 +1,25 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// A Tree data type with a `value` of some type `E` and `children` subtrees. +public class Tree { + public var value: E + public var children: [Tree] = [] + public weak var parent: Tree? + + public init(value: E) { + self.value = value + } + + /// Add a child to the tree's children and set a weak reference from the child to the parent (`self`) + public func addChild(settingParentOf child: Tree) { + children.append(child) + child.parent = self + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/WeakRef.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/WeakRef.swift new file mode 100644 index 0000000000..6941ee056f --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/WeakRef.swift @@ -0,0 +1,15 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public class WeakRef { + public weak var value: T? + public init(_ value: T?) { + self.value = value + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/DefaultPlugins/AWSHubPlugin/AWSHubPlugin.swift b/packages/amplify_datastore/ios/internal/Amplify/DefaultPlugins/AWSHubPlugin/AWSHubPlugin.swift new file mode 100644 index 0000000000..33100b2ed8 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/DefaultPlugins/AWSHubPlugin/AWSHubPlugin.swift @@ -0,0 +1,85 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// The default Hub plugin provided with the Amplify Framework +/// +/// **No guaranteed delivery order** +/// +/// AWSHubPlugin distributes messages in order to listeners, but makes no guarantees about the order in +/// which a listener is called. +/// This plugin does not guarantee synchronization between message delivery and listener management. In other words, the +/// following sequence is not guaranteed to succeed: +/// +/// plugin.listen(to: .custom("MyChannel") { event in print("event received: \(event)") } +/// plugin.dispatch(to: .custom("MyChannel"), payload: HubPayload("MY_EVENT")) +/// +/// Instead, messages and listener states are guaranteed to be independently self-consistent. Callers can use +/// `hasListener(withToken:)` to check that a listener has been registered. +final public class AWSHubPlugin: HubCategoryPlugin { + /// Convenience property. Each instance of `AWSHubPlugin` has the same key + public static var key: String { + return "awsHubPlugin" + } + + private let dispatcher = HubChannelDispatcher() + + // MARK: - HubCategoryPlugin + + public var key: String { + return type(of: self).key + } + + /// For protocol conformance only--this plugin has no applicable configurations + public func configure(using configuration: Any?) throws { + // Do nothing + } + + /// Removes listeners and empties the message queue + public func reset() async { + await dispatcher.destroy() + } + + public func dispatch(to channel: HubChannel, payload: HubPayload) { + dispatcher.dispatch(to: channel, payload: payload) + } + + public func listen(to channel: HubChannel, + eventName: HubPayloadEventName, + listener: @escaping HubListener) -> UnsubscribeToken { + let filter = HubFilters.forEventName(eventName) + return listen(to: channel, isIncluded: filter, listener: listener) + } + + public func listen(to channel: HubChannel, + isIncluded filter: HubFilter? = nil, + listener: @escaping HubListener) -> UnsubscribeToken { + let filteredListener = FilteredListener(for: channel, filter: filter, listener: listener) + dispatcher.insert(filteredListener) + + let unsubscribeToken = UnsubscribeToken(channel: channel, id: filteredListener.id) + return unsubscribeToken + } + + public func removeListener(_ token: UnsubscribeToken) { + dispatcher.removeListener(withId: token.id) + } + + // MARK: - Custom Plugin methods + + /// Returns true if the dispatcher has a listener registered with `token` + /// + /// - Parameter token: The UnsubscribeToken of the listener to check + /// - Returns: True if the dispatcher has a listener registered with `token` + public func hasListener(withToken token: UnsubscribeToken) -> Bool { + return dispatcher.hasListener(withId: token.id) + } + +} + +extension AWSHubPlugin: AmplifyVersionable { } diff --git a/packages/amplify_datastore/ios/internal/Amplify/DefaultPlugins/AWSHubPlugin/Internal/ConcurrentDispatcher.swift b/packages/amplify_datastore/ios/internal/Amplify/DefaultPlugins/AWSHubPlugin/Internal/ConcurrentDispatcher.swift new file mode 100644 index 0000000000..59dcc97cea --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/DefaultPlugins/AWSHubPlugin/Internal/ConcurrentDispatcher.swift @@ -0,0 +1,45 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// A Dispatcher that processes the list of listeners concurrently on a background queue. In our tests, there is no +/// significant performance benefit using a concurrent dispatcher up to tests of 10,000 listeners. We are leaving this +/// for future reference in case real-world configurations reveal significant benefits, but there is currently no code +/// path that allows for this to be selected. +struct ConcurrentDispatcher: Dispatcher { + let channel: HubChannel + let payload: HubPayload + + var isCancelled: Bool + + init(channel: HubChannel, payload: HubPayload) { + self.channel = channel + self.payload = payload + self.isCancelled = false + } + + func dispatch(to filteredListeners: [FilteredListener]) { + DispatchQueue.concurrentPerform(iterations: filteredListeners.count) { iteration in + guard !isCancelled else { + return + } + + let listener = filteredListeners[iteration] + + guard channel == listener.channel else { + return + } + + if let filter = listener.filter, !filter(self.payload) { + return + } + + listener.listener(self.payload) + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/DefaultPlugins/AWSHubPlugin/Internal/FilteredListener.swift b/packages/amplify_datastore/ios/internal/Amplify/DefaultPlugins/AWSHubPlugin/Internal/FilteredListener.swift new file mode 100644 index 0000000000..c66c23820b --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/DefaultPlugins/AWSHubPlugin/Internal/FilteredListener.swift @@ -0,0 +1,37 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +struct FilteredListener { + /// A unique identifier for this listener + let id: UUID + + /// The HubChannel to which the listener is assigned + let channel: HubChannel + + /// An optional Filter for refining messages + let filter: HubFilter? + + /// The block to invoke with the HubPayload, if the channel matches and `filter` evaluates to true + let listener: HubListener + + /// A HubListener block assigned to a particular channel, with an optional filter. When a message is dispatched, + /// the Hub will first inspect the listener's channel and if it matches, invoke the filter, if any, to see if the + /// listener block should be invoked with the payload. + /// + /// - Parameters: + /// - channel: The HubChannel to which the listener is assigned + /// - filter: An optional Filter for refining messages + /// - listener: The block to invoke with the HubPayload, if the channel matches and `filter` evaluates to true + init(for channel: HubChannel, filter: HubFilter?, listener: @escaping HubListener) { + self.id = UUID() + self.channel = channel + self.filter = filter + self.listener = listener + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/DefaultPlugins/AWSHubPlugin/Internal/HubChannelDispatcher.swift b/packages/amplify_datastore/ios/internal/Amplify/DefaultPlugins/AWSHubPlugin/Internal/HubChannelDispatcher.swift new file mode 100644 index 0000000000..7a3b10d54a --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/DefaultPlugins/AWSHubPlugin/Internal/HubChannelDispatcher.swift @@ -0,0 +1,136 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// A convenience class for managing an Operation Queue that dispatches Hub messages +final class HubChannelDispatcher { + /// The message queue to which the message operations are added + private let messageQueue: OperationQueue + + /// A dictionary of listeners, keyed by their ID + private let listenersById = AtomicDictionary() + + init() { + self.messageQueue = OperationQueue() + messageQueue.name = "com.amazonaws.HubChannelDispatcher" + messageQueue.maxConcurrentOperationCount = 1 + } + + /// Returns true if the dispatcher has a listener registered with `id` + /// + /// - Parameter id: The ID of the listener to check + /// - Returns: True if the dispatcher has a listener registered with `id` + func hasListener(withId id: UUID) -> Bool { + return listenersById.getValue(forKey: id) != nil + } + + /// Inserts `listener` into the `listenersById` dictionary by its ID + /// + /// - Parameter listener: The listener to add + func insert(_ listener: FilteredListener) { + listenersById.set(value: listener, forKey: listener.id) + } + + /// Removes the listener identified by `id` from the `listeners` dictionary + /// + /// - Parameter id: The ID of the listener to remove + func removeListener(withId id: UUID) { + listenersById.removeValue(forKey: id) + } + + /// Dispatches `payload` to all listeners on `channel` + /// + /// Internally, this method creates a HubDispatchOperation and adds it to the OperationQueue + /// + /// - Parameters: + /// - channel: The channel to dispatch to + /// - payload: The HubPayload to dispatch + func dispatch(to channel: HubChannel, payload: HubPayload) { + let hubDispatchOperation = HubDispatchOperation(for: channel, payload: payload, delegate: self) + messageQueue.addOperation(hubDispatchOperation) + } + + /// Cancels all operation and removes listeners. + /// + /// This method is only used during the `reset` flow, which is only invoked during tests. Although the method + /// cancels in-process operations and waits for them to complete, it does not attempt to assert anything about + /// whether a given listener closure has completed. If your test encounters errors like "Hub is not configured" + /// after you issue an `await Amplify.reset()`, you may wish to add additional sleep around your code + /// that calls `await Amplify.reset()`. + func destroy() async { + listenersById.removeAll() + messageQueue.cancelAllOperations() + await withCheckedContinuation { continuation in + messageQueue.addBarrierBlock { + continuation.resume() + } + } + } +} + +extension HubChannelDispatcher: HubDispatchOperationDelegate { + var listeners: [FilteredListener] { + return Array(listenersById.values) + } +} + +protocol HubDispatchOperationDelegate: AnyObject { + /// Used to let a dispatch operation retrieve the list of listeners at the time of invocation, rather than the time + /// of queuing. + var listeners: [FilteredListener] { get } +} + +final class HubDispatchOperation: Operation { + + private static let thresholdForConcurrentPerform = 500 + + private var payload: HubPayload + private var channel: HubChannel + private var dispatcher: Dispatcher? + + weak var delegate: HubDispatchOperationDelegate? + + /// Creates a new HubDispatchOperation. When the operation is started, it will retrieve the current list of + /// listeners via the `getListeners` closure, then filter and invoke the payload for each listener. The listener + /// will be invoked on the main queue. + /// + /// - Parameters: + /// - channel: The channel on which this dispatch operation is delivering messages + /// - payload: The HubPayload to dispatch + /// - delegate: A delegate used to retrieve the listeners to dispatch to + init(for channel: HubChannel, payload: HubPayload, delegate: HubDispatchOperationDelegate) { + self.channel = channel + self.payload = payload + self.delegate = delegate + } + + override func cancel() { + super.cancel() + dispatcher?.isCancelled = true + } + + override func main() { + guard !isCancelled else { + return + } + + guard let listeners = delegate?.listeners else { + return + } + + let dispatcher = SerialDispatcher(channel: channel, payload: payload) + dispatcher.dispatch(to: listeners) + } + +} + +/// A Dispatcher fans out a single payload to a group of listeners +protocol Dispatcher { + var isCancelled: Bool { get set } + func dispatch(to listeners: [FilteredListener]) +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/DefaultPlugins/AWSHubPlugin/Internal/SerialDispatcher.swift b/packages/amplify_datastore/ios/internal/Amplify/DefaultPlugins/AWSHubPlugin/Internal/SerialDispatcher.swift new file mode 100644 index 0000000000..62a7a16aa2 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/DefaultPlugins/AWSHubPlugin/Internal/SerialDispatcher.swift @@ -0,0 +1,49 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// A Dispatcher that processes the list of listeners serially, although the work fo filter and actually invoke the +/// listener is on a background queue +struct SerialDispatcher: Dispatcher { + let channel: HubChannel + let payload: HubPayload + + var isCancelled: Bool + + init(channel: HubChannel, payload: HubPayload) { + self.channel = channel + self.payload = payload + self.isCancelled = false + } + + func dispatch(to filteredListeners: [FilteredListener]) { + for filteredListener in filteredListeners { + guard !isCancelled else { + return + } + + guard channel == filteredListener.channel else { + continue + } + + DispatchQueue.global().async { + guard !self.isCancelled else { + return + } + + if let filter = filteredListener.filter { + guard filter(self.payload) else { + return + } + } + + filteredListener.listener(self.payload) + } + } + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/DefaultPlugins/AWSUnifiedLoggingPlugin/AWSUnifiedLoggingPlugin.swift b/packages/amplify_datastore/ios/internal/Amplify/DefaultPlugins/AWSUnifiedLoggingPlugin/AWSUnifiedLoggingPlugin.swift new file mode 100644 index 0000000000..50f8c484ff --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/DefaultPlugins/AWSUnifiedLoggingPlugin/AWSUnifiedLoggingPlugin.swift @@ -0,0 +1,135 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import os.log + +/// A Logging category plugin that forwards calls to the OS's Unified Logging system +final public class AWSUnifiedLoggingPlugin: LoggingCategoryPlugin { + + /// Convenience property. Each instance of `AWSUnifiedLoggingPlugin` has the same key + public static var key: String { + return "AWSUnifiedLoggingPlugin" + } + + private static let defaultCategory = "Amplify" + + /// Synchronize access to the registeredLogs cache + private let concurrencyQueue = DispatchQueue(label: "com.amazonaws.amplify.AWSUnifiedLoggingPlugin.concurrency") + + /// A map of OSLogs objects created by subsystem and category. These are created on the fly, at the first instance + /// of a log message to that subsystem/category combination. This will also be populated with a `default` logger + /// at initialization. + private var registeredLogs = [String: OSLogWrapper]() + + let subsystem: String + var enabled: Bool = true + private let lock = NSLock() + + /// Initializes the logging system with a default log, and immediately registers a default logger + public init() { + self.subsystem = Bundle.main.bundleIdentifier ?? "com.amazonaws.amplify.AWSUnifiedLoggingPlugin" + + let defaultOSLog = OSLog(subsystem: subsystem, category: AWSUnifiedLoggingPlugin.defaultCategory) + let wrapper = OSLogWrapper(osLog: defaultOSLog, + getLogLevel: { Amplify.Logging.logLevel }) + registeredLogs["default"] = wrapper + } + + // MARK: - LoggingCategoryPlugin + + public var key: String { + return type(of: self).key + } + + /// Look for optional configuration to disable logging, console logging is enabled by default unless configured otherwise + public func configure(using configuration: Any?) throws { + if let consoleConfiguration = ConsoleLoggingConfiguration(bundle: Bundle.main), consoleConfiguration.enable == false { + self.disable() + } + } + + /// Removes listeners and empties the message queue + public func reset() async { + concurrencyQueue.sync { + registeredLogs = [:] + } + } + + // MARK: - Log wrapper caching + + private func logWrapper(for category: String = AWSUnifiedLoggingPlugin.defaultCategory) -> OSLogWrapper { + + let key = cacheKey(for: subsystem, category: category) + + return concurrencyQueue.sync { + if let wrapper = registeredLogs[key] { + return wrapper + } + + let osLog = OSLog(subsystem: subsystem, category: category) + let wrapper = OSLogWrapper(osLog: osLog, + getLogLevel: { Amplify.Logging.logLevel }) + wrapper.enabled = enabled + registeredLogs[key] = wrapper + return wrapper + } + } + + private func cacheKey(for subsystem: String, category: String) -> String { + "\(subsystem)|\(category)" + } +} + +extension AWSUnifiedLoggingPlugin { + public var `default`: Logger { + // We register the default logger at initialization, and protect access via a setter method, so this is safe + // to force-unwrap + registeredLogs["default"]! + } + + public func logger(forCategory category: String) -> Logger { + let wrapper = logWrapper(for: category) + return wrapper + } + + public func logger(forCategory category: String, logLevel: LogLevel) -> Logger { + let wrapper = logWrapper(for: category) + wrapper.logLevel = logLevel + return wrapper + } + + public func enable() { + enabled = true + lock.execute { + for (_, logger) in registeredLogs { + logger.enabled = enabled + } + } + } + + public func disable() { + enabled = false + lock.execute { + for (_, logger) in registeredLogs { + logger.enabled = enabled + } + } + } + + public func logger(forNamespace namespace: String) -> Logger { + let wrapper = logWrapper(for: namespace) + return wrapper + } + + public func logger(forCategory category: String, forNamespace namespace: String) -> Logger { + let wrapper = logWrapper(for: category + namespace) + return wrapper + } +} + +extension AWSUnifiedLoggingPlugin: AmplifyVersionable { } diff --git a/packages/amplify_datastore/ios/internal/Amplify/DefaultPlugins/AWSUnifiedLoggingPlugin/Internal/ConsoleLoggingConfiguration.swift b/packages/amplify_datastore/ios/internal/Amplify/DefaultPlugins/AWSUnifiedLoggingPlugin/Internal/ConsoleLoggingConfiguration.swift new file mode 100644 index 0000000000..751f4a9eda --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/DefaultPlugins/AWSUnifiedLoggingPlugin/Internal/ConsoleLoggingConfiguration.swift @@ -0,0 +1,80 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public struct ConsoleLoggingConfiguration: Codable { + public init(enable: Bool = true) { + self.enable = enable + } + + public let enable: Bool +} + +extension ConsoleLoggingConfiguration { + init?(bundle: Bundle) { + guard let path = bundle.path(forResource: "amplifyconfiguration_logging", ofType: "json") else { + return nil + } + + let url = URL(fileURLWithPath: path) + + if let config = try? ConsoleLoggingConfiguration.loadConfiguration(from: url) { + self = config + } else { + return nil + } + } + + static func loadConfiguration(from url: URL) throws -> ConsoleLoggingConfiguration? { + let fileData: Data + do { + fileData = try Data(contentsOf: url) + } catch { + throw LoggingError.configuration( + """ + Could not extract UTF-8 data from `\(url.path)` + """, + + """ + Could not load data from the file at `\(url.path)`. Inspect the file to ensure it is present. + The system reported the following error: + \(error.localizedDescription) + """, + error + ) + } + + return try decodeConfiguration(from: fileData) + } + + static func decodeConfiguration(from data: Data) throws -> ConsoleLoggingConfiguration? { + do { + if let configuration = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], + let configurationJson = configuration["consoleLoggingPlugin"] as? [String: Any] { + let decoder = JSONDecoder() + let data = try JSONSerialization.data(withJSONObject: configurationJson) + let consoleLoggingConfiguration = try decoder.decode(ConsoleLoggingConfiguration.self, from: data) + return consoleLoggingConfiguration + } + } catch { + throw LoggingError.configuration( + """ + Could not decode `amplifyconfiguration_logging.json` into a valid ConsoleLoggingConfiguration object + """, + + """ + `amplifyconfiguration_logging.json` was found, but could not be converted to an AmplifyConfiguration object + using the default JSONDecoder. The system reported the following error: + \(error.localizedDescription) + """, + error + ) + } + return nil + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/DefaultPlugins/AWSUnifiedLoggingPlugin/Internal/OSLogWrapper.swift b/packages/amplify_datastore/ios/internal/Amplify/DefaultPlugins/AWSUnifiedLoggingPlugin/Internal/OSLogWrapper.swift new file mode 100644 index 0000000000..ad3087d867 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/DefaultPlugins/AWSUnifiedLoggingPlugin/Internal/OSLogWrapper.swift @@ -0,0 +1,90 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import os.log + +final class OSLogWrapper: Logger { + private let osLog: OSLog + + var enabled: Bool = true + + var getLogLevel: () -> LogLevel + + public var logLevel: LogLevel { + get { + getLogLevel() + } + set { + getLogLevel = { newValue } + } + } + + init(osLog: OSLog, getLogLevel: @escaping () -> LogLevel) { + self.osLog = osLog + self.getLogLevel = getLogLevel + } + + public func error(_ message: @autoclosure () -> String) { + guard enabled else { return } + os_log("%@", + log: osLog, + type: OSLogType.error, + message()) + } + + public func error(error: Error) { + guard enabled else { return } + os_log("%@", + log: osLog, + type: OSLogType.error, + error.localizedDescription) + } + + public func warn(_ message: @autoclosure () -> String) { + guard enabled, logLevel.rawValue >= LogLevel.warn.rawValue else { + return + } + + os_log("%@", + log: osLog, + type: OSLogType.info, + message()) + } + + public func info(_ message: @autoclosure () -> String) { + guard enabled, logLevel.rawValue >= LogLevel.info.rawValue else { + return + } + + os_log("%@", + log: osLog, + type: OSLogType.info, + message()) + } + + public func debug(_ message: @autoclosure () -> String) { + guard enabled, logLevel.rawValue >= LogLevel.debug.rawValue else { + return + } + + os_log("%@", + log: osLog, + type: OSLogType.debug, + message()) + } + + public func verbose(_ message: @autoclosure () -> String) { + guard enabled, logLevel.rawValue >= LogLevel.verbose.rawValue else { + return + } + + os_log("%@", + log: osLog, + type: OSLogType.debug, + message()) + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Amplify+DevMenu.swift b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Amplify+DevMenu.swift new file mode 100644 index 0000000000..856e00d815 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Amplify+DevMenu.swift @@ -0,0 +1,66 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Extension of `Amplify` for supporting Developer Menu feature +extension Amplify { +#if os(iOS) + static var devMenu: AmplifyDevMenu? + + @MainActor + public static func enableDevMenu(contextProvider: DevMenuPresentationContextProvider) { +#if DEBUG + devMenu = AmplifyDevMenu(devMenuPresentationContextProvider: contextProvider) +#else + Logging.warn(DevMenuStringConstants.logTag + "Developer Menu is available only in debug mode") +#endif + + } + + /// Checks whether developer menu is enabled by developer + static func isDevMenuEnabled() -> Bool { + return devMenu != nil + } +#endif + + /// Returns a `PersistentLoggingPlugin` if developer menu feature is enabled in debug mode + static func getLoggingCategoryPlugin(loggingPlugin: LoggingCategoryPlugin) -> LoggingCategoryPlugin { +#if os(iOS) +#if DEBUG + if isDevMenuEnabled() { + return PersistentLoggingPlugin(plugin: loggingPlugin) + } else { + return loggingPlugin + } +#else + return loggingPlugin +#endif +#else + return loggingPlugin +#endif + } + + static func getLoggingCategoryPluginLookup( + loggingPlugin: LoggingCategoryPlugin + ) -> [PluginKey: LoggingCategoryPlugin] { +#if os(iOS) +#if DEBUG + if isDevMenuEnabled() { + let persistentLoggingPlugin = PersistentLoggingPlugin(plugin: loggingPlugin) + return [persistentLoggingPlugin.key: persistentLoggingPlugin] + } else { + return [loggingPlugin.key: loggingPlugin] + } +#else + return [loggingPlugin.key: loggingPlugin] +#endif +#else + return [loggingPlugin.key: loggingPlugin] +#endif + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/DevMenu/AmplifyDevMenu.swift b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/AmplifyDevMenu.swift new file mode 100644 index 0000000000..b54767e3db --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/AmplifyDevMenu.swift @@ -0,0 +1,43 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#if os(iOS) +import Foundation +import SwiftUI +import UIKit + +/// Presents a developer menu using the provided `DevMenuPresentationContextProvider` +/// upon notification from a `TriggerRecognizer`. Default recognizer is a `LongPressGestureRecognizer` +@MainActor +public final class AmplifyDevMenu: DevMenuBehavior, TriggerDelegate { + + weak var devMenuPresentationContextProvider: DevMenuPresentationContextProvider? + var triggerRecognizer: TriggerRecognizer? + + init(devMenuPresentationContextProvider: DevMenuPresentationContextProvider) { + self.devMenuPresentationContextProvider = devMenuPresentationContextProvider + self.triggerRecognizer = LongPressGestureRecognizer( + uiWindow: devMenuPresentationContextProvider.devMenuPresentationContext()) + triggerRecognizer?.updateTriggerDelegate(delegate: self) + } + + public func onTrigger(triggerRecognizer: TriggerRecognizer) { + showMenu() + } + + public func showMenu() { + guard let rootViewController = + devMenuPresentationContextProvider?.devMenuPresentationContext().rootViewController else { + Amplify.Logging.warn(DevMenuStringConstants.logTag + + "RootViewController of the UIWindow is nil") + return + } + let viewController = UIHostingController(rootView: DevMenuList()) + rootViewController.present(viewController, animated: true) + } +} +#endif diff --git a/packages/amplify_datastore/ios/internal/Amplify/DevMenu/AmplifyVersionable.swift b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/AmplifyVersionable.swift new file mode 100644 index 0000000000..afaf4cbe59 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/AmplifyVersionable.swift @@ -0,0 +1,21 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Implement this protocol to support versioning in your plugin +public protocol AmplifyVersionable { + var version: String { get } +} + +extension AmplifyVersionable where Self: AnyObject { + public var version: String { + let bundle = Bundle(for: type(of: self)) + let version = bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String + return version ?? "Not Available" + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Data/DevMenuItem.swift b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Data/DevMenuItem.swift new file mode 100644 index 0000000000..5ab021548c --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Data/DevMenuItem.swift @@ -0,0 +1,20 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#if os(iOS) +import Foundation + +/// Data class for a row shown in the Developer Menu +struct DevMenuItem: Identifiable { + let id = UUID() + let type: DevMenuItemType + + init(type: DevMenuItemType) { + self.type = type + } +} +#endif diff --git a/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Data/DevMenuItemType.swift b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Data/DevMenuItemType.swift new file mode 100644 index 0000000000..9acf7e85ca --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Data/DevMenuItemType.swift @@ -0,0 +1,45 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#if os(iOS) +import Foundation + +/// Item types for each row in the Developer Menu +enum DevMenuItemType { + case environmentInformation + case deviceInformation + case logViewer + case reportIssue + + var stringValue: String { + switch self { + case .environmentInformation: + return "Environment Information" + case .deviceInformation: + return "Device Information" + case .logViewer: + return "Log Viewer" + case .reportIssue: + return "Report Issue" + } + } + + // systemName parameter for SFSymbols used in `UIImage(systemName:)` initializer + var iconName: String { + switch self { + case .environmentInformation: + return "globe" + case .deviceInformation: + return "desktopcomputer" + case .logViewer: + return "eyeglasses" + case .reportIssue: + return "exclamationmark.circle" + } + } +} +#endif diff --git a/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Data/DeviceInfoHelper.swift b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Data/DeviceInfoHelper.swift new file mode 100644 index 0000000000..7f686a0a36 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Data/DeviceInfoHelper.swift @@ -0,0 +1,30 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#if os(iOS) +import Foundation +import UIKit + +/// Helper class to fetch information for Device Information Screen +struct DeviceInfoHelper { + + static func getDeviceInformation() -> [DeviceInfoItem] { + var isSimulator = false + #if targetEnvironment(simulator) + isSimulator = true + #endif + return [ + DeviceInfoItem(type: .deviceName(UIDevice.current.name)), + DeviceInfoItem(type: .systemName(UIDevice.current.systemName)), + DeviceInfoItem(type: .systemVersion(UIDevice.current.systemVersion)), + DeviceInfoItem(type: .modelName(UIDevice.current.model)), + DeviceInfoItem(type: .localizedModelName(UIDevice.current.localizedModel)), + DeviceInfoItem(type: .isSimulator(isSimulator)) + ] + } +} +#endif diff --git a/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Data/DeviceInfoItem.swift b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Data/DeviceInfoItem.swift new file mode 100644 index 0000000000..708185c379 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Data/DeviceInfoItem.swift @@ -0,0 +1,53 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#if os(iOS) +import Foundation + +/// Data class for each item shown in the Device Info screen +struct DeviceInfoItem: Identifiable, InfoItemProvider { + let id = UUID() + let type: DeviceInfoItemType + + var displayName: String { + switch type { + case .deviceName: + return "Device Name" + case .systemName: + return "System Name" + case .systemVersion: + return "System Version" + case .modelName: + return "Model Name" + case .localizedModelName: + return "Localized Model Name" + case .isSimulator: + return "Running on simulator" + } + } + + var information: String { + switch type { + case .deviceName(let value): + return value ?? DevMenuStringConstants.notAvailable + case .systemName(let value): + return value ?? DevMenuStringConstants.notAvailable + case .systemVersion(let value): + return value ?? DevMenuStringConstants.notAvailable + case .modelName(let value): + return value ?? DevMenuStringConstants.notAvailable + case .localizedModelName(let value): + return value ?? DevMenuStringConstants.notAvailable + case .isSimulator(let value): + guard let value = value else { + return DevMenuStringConstants.notAvailable + } + return value ? "Yes" : "No" + } + } +} +#endif diff --git a/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Data/DeviceInfoItemType.swift b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Data/DeviceInfoItemType.swift new file mode 100644 index 0000000000..6dc395ca59 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Data/DeviceInfoItemType.swift @@ -0,0 +1,30 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#if os(iOS) +import Foundation + +/// Item types for a row in the Device Info screen +enum DeviceInfoItemType { + + /// Device name represents the name of the device it is running. + /// + /// For example if the app is running in a device named "John's iPhone" the associated value of + /// .deviceName will be "John's iPhone". + case deviceName(String?) + + case systemName(String?) + + case systemVersion(String?) + + case modelName(String?) + + case localizedModelName(String?) + + case isSimulator(Bool?) +} +#endif diff --git a/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Data/EnvironmentInfoHelper.swift b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Data/EnvironmentInfoHelper.swift new file mode 100644 index 0000000000..89cce0eaf3 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Data/EnvironmentInfoHelper.swift @@ -0,0 +1,45 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#if os(iOS) +import Foundation +import UIKit + +/// Helper class to fetch Developer Environment Information +struct EnvironmentInfoHelper { + + static let environmentInfoSourceFileName = "local-env-info" + + static func fetchDeveloperInformationFromJson(filename: String) -> [EnvironmentInfoItem] { + guard let url = Bundle.main.url(forResource: filename, withExtension: "json") else { + Amplify.Logging.error(DevMenuStringConstants.logTag + " json file doesn't exist") + return [EnvironmentInfoItem]() + } + + do { + let jsonData = try Data(contentsOf: url) + let decoder = JSONDecoder() + let environmentInfo = try decoder.decode(DevEnvironmentInfo.self, from: jsonData) + return getDeveloperEnvironmentInformation(devEnvInfo: environmentInfo) + } catch { + Amplify.Logging.error(DevMenuStringConstants.logTag + " json file parsing failed") + return [EnvironmentInfoItem]() + } + } + + static func getDeveloperEnvironmentInformation(devEnvInfo: DevEnvironmentInfo) -> [EnvironmentInfoItem] { + return [ + EnvironmentInfoItem(type: .nodejsVersion(devEnvInfo.nodejsVersion)), + EnvironmentInfoItem(type: .npmVersion(devEnvInfo.npmVersion)), + EnvironmentInfoItem(type: .amplifyCLIVersion(devEnvInfo.amplifyCLIVersion)), + EnvironmentInfoItem(type: .podVersion(devEnvInfo.podVersion)), + EnvironmentInfoItem(type: .xcodeVersion(devEnvInfo.xcodeVersion)), + EnvironmentInfoItem(type: .osVersion(devEnvInfo.osVersion)) + ] + } +} +#endif diff --git a/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Data/EnvironmentInfoItem.swift b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Data/EnvironmentInfoItem.swift new file mode 100644 index 0000000000..c3297a545d --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Data/EnvironmentInfoItem.swift @@ -0,0 +1,51 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#if os(iOS) +import Foundation + +/// Data class for each item showing Developer Environment Information +struct EnvironmentInfoItem: Identifiable, InfoItemProvider { + + let id = UUID() + let type: EnvironmentInfoItemType + + var displayName: String { + switch type { + case .nodejsVersion: + return "Node.js version" + case .npmVersion: + return "npm version" + case .amplifyCLIVersion: + return "Amplify CLI version" + case .podVersion: + return "CocoaPods version" + case .xcodeVersion: + return "Xcode version" + case .osVersion: + return "macOS version" + } + } + + var information: String { + switch type { + case .nodejsVersion(let value): + return value ?? DevMenuStringConstants.notAvailable + case .npmVersion(let value): + return value ?? DevMenuStringConstants.notAvailable + case .amplifyCLIVersion(let value): + return value ?? DevMenuStringConstants.notAvailable + case .podVersion(let value): + return value ?? DevMenuStringConstants.notAvailable + case .xcodeVersion(let value): + return value ?? DevMenuStringConstants.notAvailable + case .osVersion(let value): + return value ?? DevMenuStringConstants.notAvailable + } + } +} +#endif diff --git a/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Data/EnvironmentInfoItemType.swift b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Data/EnvironmentInfoItemType.swift new file mode 100644 index 0000000000..bfd5ebcebb --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Data/EnvironmentInfoItemType.swift @@ -0,0 +1,20 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#if os(iOS) +import Foundation + +/// Item types for each row displaying Developer Environment Information +enum EnvironmentInfoItemType { + case nodejsVersion(String?) + case npmVersion(String?) + case amplifyCLIVersion(String?) + case podVersion(String?) + case xcodeVersion(String?) + case osVersion(String?) +} +#endif diff --git a/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Data/InfoItemProvider.swift b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Data/InfoItemProvider.swift new file mode 100644 index 0000000000..c4ab738112 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Data/InfoItemProvider.swift @@ -0,0 +1,16 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#if os(iOS) +import Foundation + +/// Implement this protocol to display information for each row in Device / Environment Information screen +protocol InfoItemProvider { + var displayName: String { get } + var information: String { get } +} +#endif diff --git a/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Data/IssueInfo.swift b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Data/IssueInfo.swift new file mode 100644 index 0000000000..8e17eb4193 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Data/IssueInfo.swift @@ -0,0 +1,83 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#if os(iOS) +import Foundation + +/// Struct consisting of information required to report an issue +struct IssueInfo { + + private var includeEnvironmentInfo: Bool + private var includeDeviceInfo: Bool + + private var issueDescription: String + private var environmentInfoItems: [EnvironmentInfoItem] = [] + private var pluginInfoItems: [PluginInfoItem] = [] + private var deviceInfoItems: [DeviceInfoItem] = [] + + private let infoNotAvailable = "Information not available" + + init(issueDescription: String, includeEnvInfo: Bool, includeDeviceInfo: Bool) { + self.issueDescription = issueDescription.isEmpty ? infoNotAvailable : issueDescription + self.includeEnvironmentInfo = includeEnvInfo + self.includeDeviceInfo = includeDeviceInfo + initializeEnvironmentInfo() + initializePluginInfo() + initializeDeviceInfo() + } + + private mutating func initializeEnvironmentInfo() { + if includeEnvironmentInfo { + environmentInfoItems = EnvironmentInfoHelper.fetchDeveloperInformationFromJson( + filename: EnvironmentInfoHelper.environmentInfoSourceFileName) + } + } + + private mutating func initializePluginInfo() { + if includeEnvironmentInfo { + pluginInfoItems = PluginInfoHelper.getPluginInformation() + } + } + + private mutating func initializeDeviceInfo() { + if includeDeviceInfo { + deviceInfoItems = DeviceInfoHelper.getDeviceInformation() + } + } + + /// Returns issue description entered by customer if any + func getIssueDescription() -> String { + return issueDescription + } + + /// Returns environment information of customer in the form of text + func getEnvironmentInfoDescription() -> String { + return getItemsDescription(items: environmentInfoItems) + } + + /// Returns plugin information in the form of text + func getPluginInfoDescription() -> String { + return getItemsDescription(items: pluginInfoItems) + } + + /// Returns device information in the form of text + func getDeviceInfoDescription() -> String { + return getItemsDescription(items: deviceInfoItems) + } + + private func getItemsDescription(items: [InfoItemProvider]) -> String { + + guard !items.isEmpty else { + return infoNotAvailable + } + + return items.reduce("") {(description, item) -> String in + return ("\(description)\(item.displayName) - \(item.information) \n") + } + } +} +#endif diff --git a/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Data/IssueInfoHelper.swift b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Data/IssueInfoHelper.swift new file mode 100644 index 0000000000..4acd436b05 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Data/IssueInfoHelper.swift @@ -0,0 +1,38 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#if os(iOS) +import Foundation + +/// Helper class to generate markdown text for issue reporting +struct IssueInfoHelper { + + private static let issueDescTitle = "Issue Description" + private static let pluginDescTitle = "Amplify Plugins Information" + private static let envDescTitle = "Developer Environment Information" + private static let deviceDescTitle = "Device Information" + private static let logEntryTitle = "Logs" + + static func generateMarkdownForIssue(issue: IssueInfo) -> String { + return """ + **\(issueDescTitle)** + \(issue.getIssueDescription()) + + **\(pluginDescTitle)** + \(issue.getPluginInfoDescription()) + + **\(envDescTitle)** + \(issue.getEnvironmentInfoDescription()) + + **\(deviceDescTitle)** + \(issue.getDeviceInfoDescription()) + + """ + } + +} +#endif diff --git a/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Data/LogEntryHelper.swift b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Data/LogEntryHelper.swift new file mode 100644 index 0000000000..65fa2229da --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Data/LogEntryHelper.swift @@ -0,0 +1,38 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#if os(iOS) +import Foundation + +/// Helper class to fetch log entry related information +struct LogEntryHelper { + + /// Date formatter instance for date formatting + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSS" + return formatter + }() + + /// Helper function to get current time in a specified format + static func dateString(from date: Date) -> String { + return dateFormatter.string(from: date) + } + + /// Helper function to fetch logs from `PersistentLoggingPlugin` + static func getLogHistory() -> [LogEntryItem] { + if let loggingPlugin: PersistentLoggingPlugin = Amplify.Logging.plugins.first(where: { + $0.key == DevMenuStringConstants.persistentLoggingPluginKey})?.value as? PersistentLoggingPlugin { + if let logger: PersistentLogWrapper = loggingPlugin.default as? PersistentLogWrapper { + return logger.getLogHistory() + } + } + + return [LogEntryItem]() + } +} +#endif diff --git a/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Data/LogEntryItem.swift b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Data/LogEntryItem.swift new file mode 100644 index 0000000000..4747891668 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Data/LogEntryItem.swift @@ -0,0 +1,62 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#if os(iOS) +import Foundation +import SwiftUI + +/// Data class for each log item in Log Viewer Screen + +struct LogEntryItem: Identifiable, Hashable { + var id = UUID() + + /// Log message + var message: String + + /// Level of the log entry + var logLevel: LogLevel + + /// Timestamp of the log entry + var timeStamp: Date + + /// String to display corresponding to `LogLevel` + var logLevelString: String { + switch logLevel { + case .debug: + return "[debug]" + case .verbose: + return "[verbose]" + case .error: + return "[error]" + case .warn: + return "[warn]" + case .info: + return "[info]" + case .none: + return "[none]" + } + } + + /// Color of `logLevelString` corresponding to `LogLevel` + var logLevelTextColor: Color { + switch logLevel { + case .debug: + return Color.gray + case .verbose: + return Color.green + case .error: + return Color.red + case .warn: + return Color.yellow + case .info: + return Color.blue + case .none: + return Color.black + } + } +} +#endif diff --git a/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Data/PluginInfoHelper.swift b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Data/PluginInfoHelper.swift new file mode 100644 index 0000000000..c14d4a05e8 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Data/PluginInfoHelper.swift @@ -0,0 +1,61 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#if os(iOS) +import Foundation + +/// Helper class to fetch Amplify plugin information +struct PluginInfoHelper { + + static func getPluginInformation() -> [PluginInfoItem] { + var pluginList = [PluginInfoItem]() + + pluginList.append(contentsOf: Amplify.Analytics.plugins.map { + makePluginInfoItem(for: $0.key, versionable: $0.value as? AmplifyVersionable) + }) + + pluginList.append(contentsOf: Amplify.API.plugins.map { + makePluginInfoItem(for: $0.key, versionable: $0.value as? AmplifyVersionable) + }) + + pluginList.append(contentsOf: Amplify.Auth.plugins.map { + makePluginInfoItem(for: $0.key, versionable: $0.value as? AmplifyVersionable) + }) + + pluginList.append(contentsOf: Amplify.DataStore.plugins.map { + makePluginInfoItem(for: $0.key, versionable: $0.value as? AmplifyVersionable) + }) + + pluginList.append(contentsOf: Amplify.Hub.plugins.map { + makePluginInfoItem(for: $0.key, versionable: $0.value as? AmplifyVersionable) + }) + + pluginList.append(contentsOf: Amplify.Logging.plugins.map { + makePluginInfoItem(for: $0.key, versionable: $0.value as? AmplifyVersionable) + }) + + pluginList.append(contentsOf: Amplify.Predictions.plugins.map { + makePluginInfoItem(for: $0.key, versionable: $0.value as? AmplifyVersionable) + }) + + pluginList.append(contentsOf: Amplify.Storage.plugins.map { + makePluginInfoItem(for: $0.key, versionable: $0.value as? AmplifyVersionable) + }) + + return pluginList + } + + private static func makePluginInfoItem( + for pluginKey: String, + versionable: AmplifyVersionable? + ) -> PluginInfoItem { + let version = versionable?.version ?? DevMenuStringConstants.versionNotAvailable + return PluginInfoItem(displayName: pluginKey, information: version) + } + +} +#endif diff --git a/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Data/PluginInfoItem.swift b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Data/PluginInfoItem.swift new file mode 100644 index 0000000000..1c07627646 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Data/PluginInfoItem.swift @@ -0,0 +1,22 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#if os(iOS) +import Foundation + +struct PluginInfoItem: Identifiable, InfoItemProvider { + + let id = UUID() + var displayName: String + var information: String + + init(displayName: String, information: String) { + self.displayName = displayName.isEmpty ? DevMenuStringConstants.unknownPlugin : displayName + self.information = information.isEmpty ? DevMenuStringConstants.notAvailable : information + } +} +#endif diff --git a/packages/amplify_datastore/ios/internal/Amplify/DevMenu/DevEnvironmentInfo.swift b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/DevEnvironmentInfo.swift new file mode 100644 index 0000000000..645b756fd7 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/DevEnvironmentInfo.swift @@ -0,0 +1,29 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#if os(iOS) +import Foundation + +// struct to decode/encode information about developer environment in json format +struct DevEnvironmentInfo: Codable { + let nodejsVersion: String? + let npmVersion: String? + let amplifyCLIVersion: String? + let podVersion: String? + let xcodeVersion: String? + let osVersion: String? + + enum CodingKeys: String, CodingKey { + case nodejsVersion + case npmVersion + case amplifyCLIVersion = "amplifyCliVersion" + case podVersion + case xcodeVersion + case osVersion + } +} +#endif diff --git a/packages/amplify_datastore/ios/internal/Amplify/DevMenu/DevMenuBehavior.swift b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/DevMenuBehavior.swift new file mode 100644 index 0000000000..79855c2880 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/DevMenuBehavior.swift @@ -0,0 +1,16 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#if os(iOS) +import Foundation + +/// A protocol describing the behaviors of a Developer Menu +public protocol DevMenuBehavior { + /// Display the menu + func showMenu() +} +#endif diff --git a/packages/amplify_datastore/ios/internal/Amplify/DevMenu/DevMenuPresentationContextProvider.swift b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/DevMenuPresentationContextProvider.swift new file mode 100644 index 0000000000..0ccaba3431 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/DevMenuPresentationContextProvider.swift @@ -0,0 +1,16 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#if os(iOS) +import Foundation +import UIKit + +/// A protocol which provides a UI context over which views can be presented +public protocol DevMenuPresentationContextProvider: AnyObject { + func devMenuPresentationContext() -> UIWindow +} +#endif diff --git a/packages/amplify_datastore/ios/internal/Amplify/DevMenu/DevMenuStringConstants.swift b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/DevMenuStringConstants.swift new file mode 100644 index 0000000000..496e44cdcd --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/DevMenuStringConstants.swift @@ -0,0 +1,19 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#if os(iOS) +import Foundation + +/// String constants used in the developer menu +struct DevMenuStringConstants { + static let notAvailable = "Not available" + static let unknownPlugin = "Unknown Plugin" + static let versionNotAvailable = "Version not available" + static let logTag = "DevMenu" + static let persistentLoggingPluginKey = "PersistentLoggingPlugin" +} +#endif diff --git a/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Logging/PersistentLogWrapper.swift b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Logging/PersistentLogWrapper.swift new file mode 100644 index 0000000000..85fcb6c02c --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Logging/PersistentLogWrapper.swift @@ -0,0 +1,71 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#if os(iOS) +import Foundation + +/// Class that wraps another `Logger` and saves the logs in memory +class PersistentLogWrapper: Logger { + var logLevel: LogLevel + + var wrapper: Logger + + /// Array of `LogEntry` containing the history of logs + private var logHistory: [LogEntryItem] = [] + + /// Maximum number of `LogEntryItem` stored + static let logLimit = 2_000 + + init(logWrapper: Logger) { + self.wrapper = logWrapper + self.logLevel = logWrapper.logLevel + } + + func error(_ message: @autoclosure () -> String) { + addToLogHistory(logItem: LogEntryItem(message: message(), logLevel: .error, timeStamp: Date())) + wrapper.error(message()) + } + + func warn(_ message: @autoclosure () -> String) { + addToLogHistory(logItem: LogEntryItem(message: message(), logLevel: .warn, timeStamp: Date())) + wrapper.warn(message()) + } + + func error(error: Error) { + addToLogHistory(logItem: LogEntryItem(message: error.localizedDescription, logLevel: .error, timeStamp: Date())) + wrapper.error(error: error) + } + + func info(_ message: @autoclosure () -> String) { + addToLogHistory(logItem: LogEntryItem(message: message(), logLevel: .info, timeStamp: Date())) + wrapper.info(message()) + } + + func debug(_ message: @autoclosure () -> String) { + addToLogHistory(logItem: LogEntryItem(message: message(), logLevel: .debug, timeStamp: Date())) + wrapper.debug(message()) + } + + func verbose(_ message: @autoclosure () -> String) { + addToLogHistory(logItem: LogEntryItem(message: message(), logLevel: .verbose, timeStamp: Date())) + wrapper.verbose(message()) + } + + func getLogHistory() -> [LogEntryItem] { + return logHistory + } + + private func addToLogHistory(logItem: LogEntryItem) { + if logHistory.count == PersistentLogWrapper.logLimit { + logHistory.removeFirst() + } + + logHistory.append(logItem) + } + +} +#endif diff --git a/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Logging/PersistentLoggingPlugin.swift b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Logging/PersistentLoggingPlugin.swift new file mode 100644 index 0000000000..04f41016a1 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Logging/PersistentLoggingPlugin.swift @@ -0,0 +1,66 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#if os(iOS) +import Foundation + +/// `LoggingCategoryPlugin` that wraps another`LoggingCategoryPlugin` and saves the logs in memory +public class PersistentLoggingPlugin: LoggingCategoryPlugin { + + var plugin: LoggingCategoryPlugin + var persistentLogWrapper: PersistentLogWrapper? + + public let key: String = DevMenuStringConstants.persistentLoggingPluginKey + + public func configure(using configuration: Any?) throws { + try plugin.configure(using: configuration) + } + + public func logger(forCategory category: String, logLevel: LogLevel) -> Logger { + return plugin.logger(forCategory: category, logLevel: logLevel) + } + + public func logger(forCategory category: String) -> Logger { + return plugin.logger(forCategory: category) + } + + public func enable() { + plugin.enable() + } + + public func disable() { + plugin.disable() + } + + public func logger(forNamespace namespace: String) -> Logger { + plugin.logger(forNamespace: namespace) + } + + public func logger(forCategory category: String, forNamespace namespace: String) -> Logger { + plugin.logger(forCategory: category, forNamespace: namespace) + } + + public func reset() async { + persistentLogWrapper = nil + await plugin.reset() + } + + init(plugin: LoggingCategoryPlugin) { + self.plugin = plugin + } + + public var `default`: Logger { + if persistentLogWrapper == nil { + persistentLogWrapper = PersistentLogWrapper(logWrapper: plugin.default) + } + + return persistentLogWrapper! + } +} + +extension PersistentLoggingPlugin: AmplifyVersionable { } +#endif diff --git a/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Trigger/LongPressGestureRecognizer.swift b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Trigger/LongPressGestureRecognizer.swift new file mode 100644 index 0000000000..9681a02e64 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Trigger/LongPressGestureRecognizer.swift @@ -0,0 +1,59 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#if os(iOS) +import Foundation +import UIKit + +/// A class for recognizing long press gesture which notifies a `TriggerDelegate` of the event +class LongPressGestureRecognizer: NSObject, TriggerRecognizer, UIGestureRecognizerDelegate { + + weak var triggerDelegate: TriggerDelegate? + weak var uiWindow: UIWindow? + let recognizer: UILongPressGestureRecognizer + + init(uiWindow: UIWindow) { + self.uiWindow = uiWindow + self.recognizer = UILongPressGestureRecognizer(target: nil, action: nil) + self.triggerDelegate = nil + super.init() + registerLongPressRecognizer() + } + + public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) + -> Bool { + return true + } + + @objc private func longPressed(sender: UILongPressGestureRecognizer) { + if sender.state == .ended { + triggerDelegate?.onTrigger(triggerRecognizer: self) + } + } + + func updateTriggerDelegate(delegate: TriggerDelegate) { + triggerDelegate = delegate + } + + /// Register a `UILongPressGestureRecognizer` to `uiWindow` + /// to listen to long press events + private func registerLongPressRecognizer() { + recognizer.addTarget(self, action: #selector(longPressed(sender:))) + uiWindow?.addGestureRecognizer(recognizer) + recognizer.delegate = self + } + + deinit { + if let window = uiWindow { + window.removeGestureRecognizer(recognizer) + } + uiWindow = nil + triggerDelegate = nil + } +} +#endif diff --git a/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Trigger/TriggerDelegate.swift b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Trigger/TriggerDelegate.swift new file mode 100644 index 0000000000..ca20876ebf --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Trigger/TriggerDelegate.swift @@ -0,0 +1,16 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#if os(iOS) +import Foundation + +/// Implement this protocol to get notified of the trigger events recognized by +/// a `TriggerRecognizer` +public protocol TriggerDelegate: AnyObject { + func onTrigger(triggerRecognizer: TriggerRecognizer) +} +#endif diff --git a/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Trigger/TriggerRecognizer.swift b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Trigger/TriggerRecognizer.swift new file mode 100644 index 0000000000..803fa9f1a4 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/Trigger/TriggerRecognizer.swift @@ -0,0 +1,17 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#if os(iOS) +import Foundation + +/// A protocol to be implemented for recognizing user interaction events +/// which notifies a `TriggerDelegate` if it has one +public protocol TriggerRecognizer { + /// Update trigger delegate so that it can be notified in case a trigger happens + func updateTriggerDelegate(delegate: TriggerDelegate) +} +#endif diff --git a/packages/amplify_datastore/ios/internal/Amplify/DevMenu/View/DetailViewFactory.swift b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/View/DetailViewFactory.swift new file mode 100644 index 0000000000..f791ff3db7 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/View/DetailViewFactory.swift @@ -0,0 +1,28 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#if os(iOS) +import SwiftUI + +/// A factory to create detail views based on `DevMenuItemType` +class DetailViewFactory { + + static func getDetailView(type: DevMenuItemType) -> AnyView { + switch type { + case .deviceInformation: + return AnyView(DeviceInfoDetailView()) + case .environmentInformation: + return AnyView(EnvironmentInfoDetailView()) + case .logViewer: + return AnyView(LogViewer()) + case .reportIssue: + return AnyView(IssueReporter()) + } + } + +} +#endif diff --git a/packages/amplify_datastore/ios/internal/Amplify/DevMenu/View/DevMenuList.swift b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/View/DevMenuList.swift new file mode 100644 index 0000000000..d0ef438ca3 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/View/DevMenuList.swift @@ -0,0 +1,43 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#if os(iOS) +import SwiftUI + +/// View containing a list of developer menu items +struct DevMenuList: View { + + private let screenTitle = "Amplify Developer Menu" + private let amplifyDevMenuListItems: [DevMenuItem] = + [ + DevMenuItem(type: .environmentInformation), + DevMenuItem(type: .deviceInformation), + DevMenuItem(type: .logViewer), + DevMenuItem(type: .reportIssue) + ] + + var body: some View { + NavigationView { + SwiftUI.List(amplifyDevMenuListItems) { listItem in + NavigationLink(destination: DetailViewFactory.getDetailView(type: listItem.type)) { + DevMenuRow(rowItem: listItem) + } + } + .navigationBarTitle( + Text(screenTitle), + displayMode: .inline) + }.navigationViewStyle(StackNavigationViewStyle()) + } +} + +@available(iOS 13.0.0, *) +struct AmplifyDevMenuList_Previews: PreviewProvider { + static var previews: some View { + DevMenuList() + } +} +#endif diff --git a/packages/amplify_datastore/ios/internal/Amplify/DevMenu/View/DevMenuRow.swift b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/View/DevMenuRow.swift new file mode 100644 index 0000000000..613aea858c --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/View/DevMenuRow.swift @@ -0,0 +1,31 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#if os(iOS) +import SwiftUI + +/// View corresponding to each row in Developer Menu +struct DevMenuRow: View { + var rowItem: DevMenuItem + + var body: some View { + HStack { + Image(systemName: rowItem.type.iconName) + .frame(width: 50.0, height: 50.0) + .foregroundColor(.secondary) + Text(rowItem.type.stringValue) + Spacer() + } + } +} + +struct DevMenuRow_Previews: PreviewProvider { + static var previews: some View { + DevMenuRow(rowItem: DevMenuItem(type: .environmentInformation)) + } +} +#endif diff --git a/packages/amplify_datastore/ios/internal/Amplify/DevMenu/View/DeviceInfoDetailView.swift b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/View/DeviceInfoDetailView.swift new file mode 100644 index 0000000000..ea06eb449a --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/View/DeviceInfoDetailView.swift @@ -0,0 +1,29 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#if os(iOS) +import SwiftUI + +/// Detail view containing device information +struct DeviceInfoDetailView: View { + + private let screenTitle = "Device Information" + + var body: some View { + SwiftUI.List(DeviceInfoHelper.getDeviceInformation()) { listItem in + InfoRow(infoItem: listItem) + } + .navigationBarTitle(Text(screenTitle)) + } +} + +struct DeviceInfoDetailView_Previews: PreviewProvider { + static var previews: some View { + DeviceInfoDetailView() + } +} +#endif diff --git a/packages/amplify_datastore/ios/internal/Amplify/DevMenu/View/EnvironmentInfoDetailView.swift b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/View/EnvironmentInfoDetailView.swift new file mode 100644 index 0000000000..00fdf04cc4 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/View/EnvironmentInfoDetailView.swift @@ -0,0 +1,67 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#if os(iOS) +import SwiftUI + +/// Detail view showing environment information +struct EnvironmentInfoDetailView: View { + private let screenTitle = "Environment Information" + private let amplifyPluginSectionTitle = "Amplify Plugins Information" + private let devEnvSectionTitle = "Developer Environment Information" + + /// Lists containing items belonging to two sections : Amplify Plugins and Developer Environment Informatoin + private var amplifyPluginSectionItems = [PluginInfoItem]() + private var devEnvSectionItems = [EnvironmentInfoItem]() + + init() { + self.devEnvSectionItems = EnvironmentInfoHelper.fetchDeveloperInformationFromJson( + filename: EnvironmentInfoHelper.environmentInfoSourceFileName) + self.amplifyPluginSectionItems = PluginInfoHelper.getPluginInformation() + } + + var body: some View { + SwiftUI.List { + Section(header: Text(amplifyPluginSectionTitle), footer: EmptyView()) { + if amplifyPluginSectionItems.isEmpty { + NoItemView() + } else { + ForEach(amplifyPluginSectionItems) { listItem in + InfoRow(infoItem: listItem) + } + } + } + Section(header: Text(devEnvSectionTitle), footer: EmptyView()) { + if devEnvSectionItems.isEmpty { + NoItemView() + } else { + ForEach(devEnvSectionItems) { listItem in + InfoRow(infoItem: listItem) + } + } + } + } + .navigationBarTitle(screenTitle) + .listStyle(GroupedListStyle()) + } +} + +struct EnvironmentInfoDetailView_Previews: PreviewProvider { + static var previews: some View { + EnvironmentInfoDetailView() + } +} + +struct NoItemView: View { + var body: some View { + HStack { + Text("Information not available").padding(15) + Spacer() + } + } +} +#endif diff --git a/packages/amplify_datastore/ios/internal/Amplify/DevMenu/View/InfoRow.swift b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/View/InfoRow.swift new file mode 100644 index 0000000000..ce062b05cf --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/View/InfoRow.swift @@ -0,0 +1,28 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#if os(iOS) +import SwiftUI + +/// View corresponding to each row in Device Information Screen / Environment Information Screen +struct InfoRow: View { + var infoItem: InfoItemProvider + + var body: some View { + VStack(alignment: .leading) { + Text(self.infoItem.displayName).bold() + Text(self.infoItem.information) + } + } +} + +struct DeviceInfoRow_Previews: PreviewProvider { + static var previews: some View { + InfoRow(infoItem: DeviceInfoItem(type: .deviceName("iPhone"))) + } +} +#endif diff --git a/packages/amplify_datastore/ios/internal/Amplify/DevMenu/View/IssueReporter.swift b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/View/IssueReporter.swift new file mode 100644 index 0000000000..6aa2ee9b6e --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/View/IssueReporter.swift @@ -0,0 +1,200 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import SwiftUI +#if os(iOS) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif + +/// Issue report screen in developer menu +#if os(iOS) +struct IssueReporter: View { + @State var issueDescription: String = "" + @State var includeLogs = true + @State var includeEnvInfo = true + @State var includeDeviceInfo = true + @State var showAlertIfInvalidURL = false + + private static let minCharacterLimit = 140 + private let issueDescriptionHint = "Please provide a short description of the issue.." + private let includeLogsText = "Include logs" + private let includeEnvInfoText = "Include environment information" + private let includeDeviceInfoText = "Include device information" + private let reportOnGithubButtonText = "Report on Github" + private let copyToClipboardButtonText = "Copy to Clipboard" + private let screenTitle = "Report Issue" + private let amplifyIosNewIssueUrl = "https://github.com/aws-amplify/amplify-ios/issues/new?&title=&body=" + private let githubURLErrorTitle = "Error" + private let githubURLErrorMessage = "Unable to parse Github URL. Please use the Copy to Clipboard option." + private let moreCharactersRequired = "Characters required" + + var body: some View { + ScrollView { + VStack { + MultilineTextField(text: $issueDescription, placeHolderText: issueDescriptionHint) + .border(Color.gray) + .frame(height: 350) + + HStack { + Text("\(moreCharactersRequired): \(remainingCharactersRequired())") + .foregroundColor(Color.secondary) + .font(.system(size: 15)) + Spacer() + }.padding(.bottom) + + Toggle(isOn: $includeEnvInfo) { + Text(includeEnvInfoText).bold() + }.padding(.bottom) + + Toggle(isOn: $includeDeviceInfo) { + Text(includeDeviceInfoText).bold() + }.padding(.bottom) + + Spacer() + + Button(reportOnGithubButtonText, action: reportToGithub) + .padding() + .font(.subheadline) + .frame(maxWidth: .infinity) + .border(Color.blue) + .padding(.bottom) + .alert(isPresented: $showAlertIfInvalidURL) { + Alert(title: Text(githubURLErrorTitle), + message: Text(githubURLErrorMessage), + dismissButton: .default(Text("OK"))) + } + .disabled(shouldDisableReporting()) + + Button(copyToClipboardButtonText, action: copyToClipboard) + .padding() + .font(.subheadline) + .frame(maxWidth: .infinity) + .border(Color.blue) + .disabled(shouldDisableReporting()) + + }.padding() + .navigationBarTitle(Text(screenTitle)) + } + } + + /// Get number of extra characters required for a valid issue description length + /// Returns 0 if issue description length fulfills `minCharacterLimit` criteria + private func remainingCharactersRequired() -> Int { + return max(IssueReporter.minCharacterLimit - issueDescription.count, 0) + } + + private func shouldDisableReporting() -> Bool { + return remainingCharactersRequired() > 0 + } + + /// Open Amplify iOS issue logging screen on Github + private func reportToGithub() { + let issueDescriptionMarkdown = + IssueInfoHelper.generateMarkdownForIssue( + issue: IssueInfo(issueDescription: issueDescription, + includeEnvInfo: includeEnvInfo, + includeDeviceInfo: includeDeviceInfo)) + + let urlString = amplifyIosNewIssueUrl + issueDescriptionMarkdown + guard let url = urlString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { + showAlertIfInvalidURL = true + return + } + guard let urlToOpen = URL(string: url) else { + showAlertIfInvalidURL = true + return + } + + UIApplication.shared.open(urlToOpen) + } + + /// Copy issue as a markdown string to clipboard + private func copyToClipboard() { + let issue = IssueInfo(issueDescription: issueDescription, + includeEnvInfo: includeEnvInfo, + includeDeviceInfo: includeDeviceInfo) + let value = IssueInfoHelper.generateMarkdownForIssue(issue: issue) +#if os(iOS) + UIPasteboard.general.string = value +#elseif canImport(AppKit) + NSPasteboard.general.setString(value, forType: .string) +#endif + } +} + +final class IssueReporter_Previews: PreviewProvider { + static var previews: some View { + IssueReporter() + } +} + +/// Custom defined view for multi line text field +final class MultilineTextField: UIViewRepresentable { + @Binding var text: String + var placeHolderText: String = "" + + init(text: Binding, placeHolderText: String) { + self._text = text + self.placeHolderText = placeHolderText + } + + func makeUIView(context: UIViewRepresentableContext) -> UITextView { + let view = UITextView() + view.isScrollEnabled = true + view.isEditable = true + view.isUserInteractionEnabled = true + view.delegate = context.coordinator + view.font = .systemFont(ofSize: 15) + view.textColor = .secondaryLabel + view.text = placeHolderText + + /// add a dismiss button in UIToolbar for keyboard + let toolbar = UIToolbar(frame: CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width, height: 35)) + let emptySpace = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil) + let dismissButton = UIBarButtonItem(title: "Dismiss", style: .done, + target: self, action: #selector(dismissKeyboard)) + toolbar.setItems([emptySpace, dismissButton], animated: false) + toolbar.sizeToFit() + + view.inputAccessoryView = toolbar + return view + } + + @objc func dismissKeyboard() { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), + to: nil, from: nil, for: nil) + } + + func updateUIView(_ uiView: UITextView, context: Context) { + } + + func makeCoordinator() -> Coordinator { + return MultilineTextField.Coordinator(parent: self) + } + + class Coordinator: NSObject, UITextViewDelegate { + var parent: MultilineTextField + + init(parent: MultilineTextField) { + self.parent = parent + } + + func textViewDidChange(_ textView: UITextView) { + parent.text = textView.text + } + + func textViewDidBeginEditing(_ textView: UITextView) { + if textView.textColor == .secondaryLabel { + textView.text = nil + textView.textColor = .label + } + } + } +} +#endif diff --git a/packages/amplify_datastore/ios/internal/Amplify/DevMenu/View/LogEntryRow.swift b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/View/LogEntryRow.swift new file mode 100644 index 0000000000..a5aa7f2cfd --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/View/LogEntryRow.swift @@ -0,0 +1,29 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#if os(iOS) +import SwiftUI + +/// View for each row in Log Viewer screen +struct LogEntryRow: View { + var logEntryItem: LogEntryItem + + var body: some View { + VStack(alignment: .leading) { + Group { + Text(logEntryItem.logLevelString).foregroundColor(logEntryItem.logLevelTextColor).bold() + + Text(" ") + + Text(logEntryItem.message) + }.lineLimit(2).padding(.bottom, 5) + + Text(LogEntryHelper.dateString(from: logEntryItem.timeStamp)) + .font(.system(size: 15)) + .foregroundColor(Color.secondary) + }.padding(5) + } +} +#endif diff --git a/packages/amplify_datastore/ios/internal/Amplify/DevMenu/View/LogViewer.swift b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/View/LogViewer.swift new file mode 100644 index 0000000000..3a01f2358e --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/DevMenu/View/LogViewer.swift @@ -0,0 +1,114 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#if os(iOS) +import SwiftUI + +struct LogViewer: View { + + @State private var searchText: String = "" + @State private var filter: FilterType = .all + private let screenTitle = "Log Viewer" + private let searchHint = "Search logs..." + private let noLogsAvailable = "No logs available" + + private var logHistory: [LogEntryItem] = LogEntryHelper.getLogHistory() + + enum FilterType: String, CaseIterable, Identifiable { + case all + case error + case warn + case info + case debug + case verbose + + var id: String { rawValue } + } + + var filteredList: [LogEntryItem] { + switch filter { + case .all: + return logHistory + case .error: + return logHistory.filter {$0.logLevel == LogLevel.error} + case .warn: + return logHistory.filter {$0.logLevel == LogLevel.warn} + case .info: + return logHistory.filter {$0.logLevel == LogLevel.info} + case .debug: + return logHistory.filter {$0.logLevel == LogLevel.debug} + case .verbose: + return logHistory.filter {$0.logLevel == LogLevel.verbose} + } + } + + var body: some View { + VStack { + if logHistory.isEmpty { + Spacer() + Text(noLogsAvailable) + Spacer() + } else { + SearchBar(text: $searchText, searchHint: searchHint).padding(.horizontal) + Picker("Filter", selection: $filter) { + ForEach(FilterType.allCases) { filter in + Text(filter.rawValue.capitalized).tag(filter) + } + }.pickerStyle(SegmentedPickerStyle()).padding(.horizontal, 25) + SwiftUI.List(filteredList.filter { + searchText.isEmpty ? true : $0.message.lowercased().contains(searchText.lowercased()) + }) { logEntry in + LogEntryRow(logEntryItem: logEntry) + } + } + }.navigationBarTitle(Text(screenTitle)) + } +} + +struct LogViewer_Previews: PreviewProvider { + static var previews: some View { + LogViewer() + } +} + +/// Search bar view +struct SearchBar: UIViewRepresentable { + + @Binding var text: String + var searchHint: String + + class Coordinator: NSObject, UISearchBarDelegate { + + @Binding var text: String + + init(text: Binding) { + _text = text + } + + func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { + text = searchText + } + } + + func makeCoordinator() -> SearchBar.Coordinator { + return Coordinator(text: $text) + } + + func makeUIView(context: UIViewRepresentableContext) -> UISearchBar { + let searchBar = UISearchBar(frame: .zero) + searchBar.delegate = context.coordinator + searchBar.placeholder = searchHint + searchBar.searchBarStyle = .minimal + searchBar.autocapitalizationType = .none + return searchBar + } + + func updateUIView(_ uiView: UISearchBar, context: UIViewRepresentableContext) { + uiView.text = text + } +} +#endif diff --git a/packages/amplify_datastore/ios/internal/Amplify/Resources/PrivacyInfo.xcprivacy b/packages/amplify_datastore/ios/internal/Amplify/Resources/PrivacyInfo.xcprivacy new file mode 100644 index 0000000000..190d0d18e4 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Resources/PrivacyInfo.xcprivacy @@ -0,0 +1,10 @@ + + + + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + diff --git a/packages/amplify_datastore/lib/amplify_datastore.dart b/packages/amplify_datastore/lib/amplify_datastore.dart index b4a35232dd..40c2ad1579 100644 --- a/packages/amplify_datastore/lib/amplify_datastore.dart +++ b/packages/amplify_datastore/lib/amplify_datastore.dart @@ -9,6 +9,7 @@ import 'package:amplify_datastore/src/amplify_datastore_stream_controller.dart'; import 'package:amplify_datastore/src/datastore_plugin_options.dart'; import 'package:amplify_datastore/src/method_channel_datastore.dart'; import 'package:amplify_datastore/src/native_plugin.g.dart'; +import 'package:amplify_datastore/src/utils/native_api_helpers.dart'; import 'package:collection/collection.dart'; import 'package:flutter/services.dart'; import 'package:meta/meta.dart'; @@ -296,6 +297,12 @@ class _NativeAmplifyApi final Map, APIAuthProvider> _authProviders; + final Map>> + _subscriptionsCache = {}; + + @override + String get runtimeTypeName => '_NativeAmplifyApi'; + @override Future getLatestAuthToken(String providerName) { final provider = APIAuthorizationTypeX.from(providerName); @@ -313,5 +320,48 @@ class _NativeAmplifyApi } @override - String get runtimeTypeName => '_NativeAmplifyApi'; + Future mutate(NativeGraphQLRequest request) async { + final flutterRequest = nativeRequestToGraphQLRequest(request); + final response = await Amplify.API.mutate(request: flutterRequest).response; + return graphQLResponseToNativeResponse(response); + } + + @override + Future query(NativeGraphQLRequest request) async { + final flutterRequest = nativeRequestToGraphQLRequest(request); + final response = await Amplify.API.query(request: flutterRequest).response; + return graphQLResponseToNativeResponse(response); + } + + @override + Future subscribe( + NativeGraphQLRequest request) async { + final flutterRequest = nativeRequestToGraphQLRequest(request); + + final operation = Amplify.API.subscribe(flutterRequest, + onEstablished: () => sendNativeStartAckEvent(flutterRequest.id)); + + final subscription = operation.listen( + (GraphQLResponse event) => + sendNativeDataEvent(flutterRequest.id, event.data), + onError: (error) { + // TODO(equartey): verify that error.toString() is the correct payload format. Should match AppSync + final errorPayload = error.toString(); + sendNativeErrorEvent(flutterRequest.id, errorPayload); + }, + onDone: () => sendNativeCompleteEvent(flutterRequest.id)); + + _subscriptionsCache[flutterRequest.id] = subscription; + + return getConnectingEvent(flutterRequest.id); + } + + @override + Future unsubscribe(String subscriptionId) async { + final subscription = _subscriptionsCache[subscriptionId]; + if (subscription != null) { + await subscription.cancel(); + _subscriptionsCache.remove(subscriptionId); + } + } } diff --git a/packages/amplify_datastore/lib/src/native_plugin.g.dart b/packages/amplify_datastore/lib/src/native_plugin.g.dart index d4116ddb6b..f06960829b 100644 --- a/packages/amplify_datastore/lib/src/native_plugin.g.dart +++ b/packages/amplify_datastore/lib/src/native_plugin.g.dart @@ -1,7 +1,7 @@ // // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -// Autogenerated from Pigeon (v11.0.0), do not edit directly. +// Autogenerated from Pigeon (v11.0.1), do not edit directly. // See also: https://pub.dev/packages/pigeon // ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import @@ -149,6 +149,109 @@ class NativeAWSCredentials { } } +class NativeGraphQLResponse { + NativeGraphQLResponse({ + this.payloadJson, + this.errorsJson, + }); + + String? payloadJson; + + String? errorsJson; + + Object encode() { + return [ + payloadJson, + errorsJson, + ]; + } + + static NativeGraphQLResponse decode(Object result) { + result as List; + return NativeGraphQLResponse( + payloadJson: result[0] as String?, + errorsJson: result[1] as String?, + ); + } +} + +class NativeGraphQLSubscriptionResponse { + NativeGraphQLSubscriptionResponse({ + required this.type, + required this.subscriptionId, + this.payloadJson, + }); + + String type; + + String subscriptionId; + + String? payloadJson; + + Object encode() { + return [ + type, + subscriptionId, + payloadJson, + ]; + } + + static NativeGraphQLSubscriptionResponse decode(Object result) { + result as List; + return NativeGraphQLSubscriptionResponse( + type: result[0]! as String, + subscriptionId: result[1]! as String, + payloadJson: result[2] as String?, + ); + } +} + +class NativeGraphQLRequest { + NativeGraphQLRequest({ + required this.document, + this.apiName, + this.variablesJson, + this.responseType, + this.decodePath, + this.options, + }); + + String document; + + String? apiName; + + String? variablesJson; + + String? responseType; + + String? decodePath; + + String? options; + + Object encode() { + return [ + document, + apiName, + variablesJson, + responseType, + decodePath, + options, + ]; + } + + static NativeGraphQLRequest decode(Object result) { + result as List; + return NativeGraphQLRequest( + document: result[0]! as String, + apiName: result[1] as String?, + variablesJson: result[2] as String?, + responseType: result[3] as String?, + decodePath: result[4] as String?, + options: result[5] as String?, + ); + } +} + class _NativeAuthPluginCodec extends StandardMessageCodec { const _NativeAuthPluginCodec(); @override @@ -182,6 +285,7 @@ class _NativeAuthPluginCodec extends StandardMessageCodec { } } +/// Bridge for calling Auth from Native into Flutter abstract class NativeAuthPlugin { static const MessageCodec codec = _NativeAuthPluginCodec(); @@ -206,11 +310,54 @@ abstract class NativeAuthPlugin { } } +class _NativeApiPluginCodec extends StandardMessageCodec { + const _NativeApiPluginCodec(); + @override + void writeValue(WriteBuffer buffer, Object? value) { + if (value is NativeGraphQLRequest) { + buffer.putUint8(128); + writeValue(buffer, value.encode()); + } else if (value is NativeGraphQLResponse) { + buffer.putUint8(129); + writeValue(buffer, value.encode()); + } else if (value is NativeGraphQLSubscriptionResponse) { + buffer.putUint8(130); + writeValue(buffer, value.encode()); + } else { + super.writeValue(buffer, value); + } + } + + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + case 128: + return NativeGraphQLRequest.decode(readValue(buffer)!); + case 129: + return NativeGraphQLResponse.decode(readValue(buffer)!); + case 130: + return NativeGraphQLSubscriptionResponse.decode(readValue(buffer)!); + default: + return super.readValueOfType(type, buffer); + } + } +} + +/// Bridge for calling API plugin from Native into Flutter abstract class NativeApiPlugin { - static const MessageCodec codec = StandardMessageCodec(); + static const MessageCodec codec = _NativeApiPluginCodec(); Future getLatestAuthToken(String providerName); + Future mutate(NativeGraphQLRequest request); + + Future query(NativeGraphQLRequest request); + + Future subscribe( + NativeGraphQLRequest request); + + Future unsubscribe(String subscriptionId); + static void setup(NativeApiPlugin? api, {BinaryMessenger? binaryMessenger}) { { final BasicMessageChannel channel = BasicMessageChannel( @@ -233,9 +380,92 @@ abstract class NativeApiPlugin { }); } } + { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.amplify_datastore.NativeApiPlugin.mutate', codec, + binaryMessenger: binaryMessenger); + if (api == null) { + channel.setMessageHandler(null); + } else { + channel.setMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.amplify_datastore.NativeApiPlugin.mutate was null.'); + final List args = (message as List?)!; + final NativeGraphQLRequest? arg_request = + (args[0] as NativeGraphQLRequest?); + assert(arg_request != null, + 'Argument for dev.flutter.pigeon.amplify_datastore.NativeApiPlugin.mutate was null, expected non-null NativeGraphQLRequest.'); + final NativeGraphQLResponse output = await api.mutate(arg_request!); + return output; + }); + } + } + { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.amplify_datastore.NativeApiPlugin.query', codec, + binaryMessenger: binaryMessenger); + if (api == null) { + channel.setMessageHandler(null); + } else { + channel.setMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.amplify_datastore.NativeApiPlugin.query was null.'); + final List args = (message as List?)!; + final NativeGraphQLRequest? arg_request = + (args[0] as NativeGraphQLRequest?); + assert(arg_request != null, + 'Argument for dev.flutter.pigeon.amplify_datastore.NativeApiPlugin.query was null, expected non-null NativeGraphQLRequest.'); + final NativeGraphQLResponse output = await api.query(arg_request!); + return output; + }); + } + } + { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.amplify_datastore.NativeApiPlugin.subscribe', + codec, + binaryMessenger: binaryMessenger); + if (api == null) { + channel.setMessageHandler(null); + } else { + channel.setMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.amplify_datastore.NativeApiPlugin.subscribe was null.'); + final List args = (message as List?)!; + final NativeGraphQLRequest? arg_request = + (args[0] as NativeGraphQLRequest?); + assert(arg_request != null, + 'Argument for dev.flutter.pigeon.amplify_datastore.NativeApiPlugin.subscribe was null, expected non-null NativeGraphQLRequest.'); + final NativeGraphQLSubscriptionResponse output = + await api.subscribe(arg_request!); + return output; + }); + } + } + { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.amplify_datastore.NativeApiPlugin.unsubscribe', + codec, + binaryMessenger: binaryMessenger); + if (api == null) { + channel.setMessageHandler(null); + } else { + channel.setMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.amplify_datastore.NativeApiPlugin.unsubscribe was null.'); + final List args = (message as List?)!; + final String? arg_subscriptionId = (args[0] as String?); + assert(arg_subscriptionId != null, + 'Argument for dev.flutter.pigeon.amplify_datastore.NativeApiPlugin.unsubscribe was null, expected non-null String.'); + await api.unsubscribe(arg_subscriptionId!); + return; + }); + } + } } } +/// Bridge for calling Amplify from Flutter into Native class NativeAmplifyBridge { /// Constructor for [NativeAmplifyBridge]. The [binaryMessenger] named argument is /// available for dependency injection. If it is left null, the default @@ -293,6 +523,7 @@ class _NativeAuthBridgeCodec extends StandardMessageCodec { } } +/// Bridge for calling Auth plugin from Flutter into Native class NativeAuthBridge { /// Constructor for [NativeAuthBridge]. The [binaryMessenger] named argument is /// available for dependency injection. If it is left null, the default @@ -349,6 +580,30 @@ class NativeAuthBridge { } } +class _NativeApiBridgeCodec extends StandardMessageCodec { + const _NativeApiBridgeCodec(); + @override + void writeValue(WriteBuffer buffer, Object? value) { + if (value is NativeGraphQLSubscriptionResponse) { + buffer.putUint8(128); + writeValue(buffer, value.encode()); + } else { + super.writeValue(buffer, value); + } + } + + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + case 128: + return NativeGraphQLSubscriptionResponse.decode(readValue(buffer)!); + default: + return super.readValueOfType(type, buffer); + } + } +} + +/// Bridge for calling API methods from Flutter into Native class NativeApiBridge { /// Constructor for [NativeApiBridge]. The [binaryMessenger] named argument is /// available for dependency injection. If it is left null, the default @@ -357,7 +612,7 @@ class NativeApiBridge { : _binaryMessenger = binaryMessenger; final BinaryMessenger? _binaryMessenger; - static const MessageCodec codec = StandardMessageCodec(); + static const MessageCodec codec = _NativeApiBridgeCodec(); Future addApiPlugin(List arg_authProvidersList) async { final BasicMessageChannel channel = BasicMessageChannel( @@ -381,4 +636,28 @@ class NativeApiBridge { return; } } + + Future sendSubscriptionEvent( + NativeGraphQLSubscriptionResponse arg_event) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.amplify_datastore.NativeApiBridge.sendSubscriptionEvent', + codec, + binaryMessenger: _binaryMessenger); + final List? replyList = + await channel.send([arg_event]) as List?; + if (replyList == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + ); + } else if (replyList.length > 1) { + throw PlatformException( + code: replyList[0]! as String, + message: replyList[1] as String?, + details: replyList[2], + ); + } else { + return; + } + } } diff --git a/packages/amplify_datastore/lib/src/utils/native_api_helpers.dart b/packages/amplify_datastore/lib/src/utils/native_api_helpers.dart new file mode 100644 index 0000000000..f283f4a435 --- /dev/null +++ b/packages/amplify_datastore/lib/src/utils/native_api_helpers.dart @@ -0,0 +1,78 @@ +import 'dart:convert'; + +import 'package:amplify_core/amplify_core.dart'; +import 'package:amplify_datastore/src/native_plugin.g.dart'; +import 'package:collection/collection.dart'; + +/// Convert a [NativeGraphQLResponse] to a [GraphQLResponse] +GraphQLRequest nativeRequestToGraphQLRequest( + NativeGraphQLRequest request) { + return GraphQLRequest( + document: request.document, + variables: jsonDecode(request.variablesJson ?? '{}'), + apiName: request.apiName, + ); +} + +/// Convert a [GraphQLResponse] to a [NativeGraphQLResponse] +NativeGraphQLResponse graphQLResponseToNativeResponse( + GraphQLResponse response) { + final errorJson = jsonEncode( + response.errors.whereNotNull().map((e) => e.toJson()).toList()); + return NativeGraphQLResponse( + payloadJson: response.data, + errorsJson: errorJson, + ); +} + +/// Returns a connecting event [NativeGraphQLResponse] for the given [subscriptionId] +NativeGraphQLSubscriptionResponse getConnectingEvent(String subscriptionId) { + final event = NativeGraphQLSubscriptionResponse( + subscriptionId: subscriptionId, + type: 'connecting', + ); + return event; +} + +/// Send a subscription event to the platform side +void _sendSubscriptionEvent(NativeGraphQLSubscriptionResponse event) { + NativeApiBridge().sendSubscriptionEvent(event); +} + +/// Send a start_ack event for the given [subscriptionId] +void sendNativeStartAckEvent(String subscriptionId) { + final event = NativeGraphQLSubscriptionResponse( + subscriptionId: subscriptionId, + type: 'start_ack', + ); + _sendSubscriptionEvent(event); +} + +/// Send a data event for the given [subscriptionId] and [payloadJson] +void sendNativeDataEvent(String subscriptionId, String? payloadJson) { + final event = NativeGraphQLSubscriptionResponse( + subscriptionId: subscriptionId, + payloadJson: payloadJson, + type: 'data', + ); + _sendSubscriptionEvent(event); +} + +/// Send an error event for the given [subscriptionId] and [errorPayload] +void sendNativeErrorEvent(String subscriptionId, String errorPayload) { + final event = NativeGraphQLSubscriptionResponse( + subscriptionId: subscriptionId, + payloadJson: errorPayload, + type: 'error', + ); + _sendSubscriptionEvent(event); +} + +/// Send a complete event for the given [subscriptionId] +void sendNativeCompleteEvent(String subscriptionId) { + final event = NativeGraphQLSubscriptionResponse( + subscriptionId: subscriptionId, + type: 'complete', + ); + _sendSubscriptionEvent(event); +} diff --git a/packages/amplify_datastore/pigeons/native_plugin.dart b/packages/amplify_datastore/pigeons/native_plugin.dart index 9a0c62459e..25584aa1ce 100644 --- a/packages/amplify_datastore/pigeons/native_plugin.dart +++ b/packages/amplify_datastore/pigeons/native_plugin.dart @@ -17,24 +17,40 @@ library native_plugin; import 'package:pigeon/pigeon.dart'; +/// Bridge for calling Auth from Native into Flutter @FlutterApi() abstract class NativeAuthPlugin { @async NativeAuthSession fetchAuthSession(); } +/// Bridge for calling API plugin from Native into Flutter @FlutterApi() abstract class NativeApiPlugin { @async String? getLatestAuthToken(String providerName); + + @async + NativeGraphQLResponse mutate(NativeGraphQLRequest request); + + @async + NativeGraphQLResponse query(NativeGraphQLRequest request); + + @async + NativeGraphQLSubscriptionResponse subscribe(NativeGraphQLRequest request); + + @async + void unsubscribe(String subscriptionId); } +/// Bridge for calling Amplify from Flutter into Native @HostApi() abstract class NativeAmplifyBridge { @async void configure(String version, String config); } +/// Bridge for calling Auth plugin from Flutter into Native @HostApi() abstract class NativeAuthBridge { @async @@ -43,10 +59,14 @@ abstract class NativeAuthBridge { void updateCurrentUser(NativeAuthUser? user); } +/// Bridge for calling API methods from Flutter into Native @HostApi() abstract class NativeApiBridge { @async void addApiPlugin(List authProvidersList); + + @async + void sendSubscriptionEvent(NativeGraphQLSubscriptionResponse event); } class NativeAuthSession { @@ -87,3 +107,23 @@ class LegacyCredentialStoreData { String? refreshToken; String? idToken; } + +class NativeGraphQLResponse { + String? payloadJson; + String? errorsJson; +} + +class NativeGraphQLSubscriptionResponse { + late String type; + late String subscriptionId; + String? payloadJson; +} + +class NativeGraphQLRequest { + late String document; + String? apiName; + String? variablesJson; + String? responseType; + String? decodePath; + String? options; +} diff --git a/packages/amplify_datastore/pubspec.yaml b/packages/amplify_datastore/pubspec.yaml index e198ae229b..df8ead82e3 100644 --- a/packages/amplify_datastore/pubspec.yaml +++ b/packages/amplify_datastore/pubspec.yaml @@ -35,4 +35,4 @@ flutter: package: com.amazonaws.amplify.amplify_datastore pluginClass: AmplifyDataStorePlugin ios: - pluginClass: AmplifyDataStorePlugin + pluginClass: SwiftAmplifyDataStorePlugin