diff --git a/cspell.json b/cspell.json index df50ce23af3..972cbabc8d8 100644 --- a/cspell.json +++ b/cspell.json @@ -1626,22 +1626,19 @@ "preconfigured", "manylinux", "GOARCH", - "norpc" - ], - "flagWords": [ - "hte", - "full-stack", - "Full-stack", - "Full-Stack", - "sudo" + "norpc", + "AWSSDKHTTP", + "HTTPAPI", + "AWSSDK", + "uppercased", + "autoclosure" ], + "flagWords": ["hte", "full-stack", "Full-stack", "Full-Stack", "sudo"], "patterns": [ { "name": "youtube-embed-ids", "pattern": "/embedId=\".*\" /" } ], - "ignoreRegExpList": [ - "youtube-embed-ids" - ] + "ignoreRegExpList": ["youtube-embed-ids"] } diff --git a/src/pages/[platform]/build-a-backend/data/connect-event-api/index.mdx b/src/pages/[platform]/build-a-backend/data/connect-event-api/index.mdx index b852a1efd74..45b62818ff6 100644 --- a/src/pages/[platform]/build-a-backend/data/connect-event-api/index.mdx +++ b/src/pages/[platform]/build-a-backend/data/connect-event-api/index.mdx @@ -11,7 +11,8 @@ export const meta = { 'react', 'react-native', 'vue', - 'android' + 'android', + 'swift' ] }; @@ -28,7 +29,7 @@ export function getStaticProps(context) { }; } - + This guide walks through how you can connect to AWS AppSync Events using the Amplify library. @@ -191,6 +192,309 @@ val authorizer = AmplifyIamAuthorizer("{REGION}") + +## Install the AWS AppSync Events library + + + + + +- Add `AWS AppSync Events Library for Swift` into your project using Swift Package Manager. + +- Enter its Github URL (https://github.com/aws-amplify/aws-appsync-events-swift), select `Up to Next Major Version` and click `Add Package`. + +- Select the following product and add it to your target: + - `AWSAppSyncEvents` + + + + + +- Add `AWS AppSync Events Library for Swift` into your project using Swift Package Manager. + - Enter its Github URL (https://github.com/aws-amplify/aws-appsync-events-swift), select `Up to Next Major Version` and click `Add Package`. + - Select the following product and add it to your target: + - `AWSAppSyncEvents` + +- Add `Amplify Library for Swift` into your project using Swift Package Manager. + - Enter its Github URL (https://github.com/aws-amplify/amplify-swift), select `Up to Next Major Version` and click `Add Package`. + - Select the following product and add it to your target: + - `Amplify` + - `AWSCognitoAuthPlugin` + + + + + +### Providing AppSync Authorizers + + + + + +The AWS AppSync Events library imports a number of Authorizer classes to match the various authorization strategies that may be used for your Events API. You should choose the appropriate Authorizer type for your authorization strategy. + +* API KEY authorization, **APIKeyAuthorizer** +* AWS IAM authorization, **IAMAuthorizer** +* AMAZON COGNITO USER POOLS authorization, **AuthTokenAuthorizer** + +You can create as many `Events` clients as necessary if you require multiple authorization types. + +#### API KEY + +An `APIKeyAuthorizer` can be used with a hardcoded API key or by fetching the key from some source. + +```swift +// highlight-start +// Use a hard-coded API key +let apiKeyAuthorizer = APIKeyAuthorizer(apiKey: "apiKey") +//highlight-end +// or +// highlight-start +// Fetch the API key from some source. This function may be called many times, +// so it should implement appropriate caching internally. +let authorizer = APIKeyAuthorizer(fetchAPIKey: { + // fetch your api key + }) +//highlight-end +``` + +#### AMAZON COGNITO USER POOLS + +When working directly with AppSync, you must implement the token fetching yourself. + +```swift +// highlight-start +// Use your own token fetching. This function may be called many times, +// so it should implement appropriate caching internally. +let authorizer = AuthTokenAuthorizer(fetchLatestAuthToken: { + // fetch your auth token +}) +//highlight-end +``` + +#### AWS IAM + +When working directly with AppSync, you must implement the request signing yourself. + +```swift +// highlight-start +// Provide an implementation of the signing function. This function should implement the +// AWS Sig-v4 signing logic and return the authorization headers containing the token and signature. +let authorizer = IAMAuthorizer(signRequest: { + // implement your `URLRequest` signing logic +}) +// highlight-end +``` + + + + + +The AWS AppSync Events library imports a number of Authorizer classes to match the various authorization strategies that may be used for your Events API. You should choose the appropriate Authorizer type for your authorization strategy. + +* API KEY authorization, **APIKeyAuthorizer** +* AWS IAM authorization, **IAMAuthorizer** +* AMAZON COGNITO USER POOLS authorization, **AuthTokenAuthorizer** + +You can create as many `Events` clients as necessary if you require multiple authorization types. + +#### API KEY + +An `APIKeyAuthorizer` can be used with a hardcoded API key or by fetching the key from some source. + +```swift +// highlight-start +// Use a hard-coded API key +let apiKeyAuthorizer = APIKeyAuthorizer(apiKey: "apiKey") +//highlight-end +// or +// highlight-start +// Fetch the API key from some source. This function may be called many times, +// so it should implement appropriate caching internally. +let authorizer = APIKeyAuthorizer(fetchAPIKey: { + // fetch your api key + }) +//highlight-end +``` + +#### AMAZON COGNITO USER POOLS + +If you are using Amplify Auth, you can create a method that retrieves the Cognito access token. + +```swift +import Amplify + +func getUserPoolAccessToken() async throws -> String { + let authSession = try await Amplify.Auth.fetchAuthSession() + if let result = (authSession as? AuthCognitoTokensProvider)?.getCognitoTokens() { + switch result { + case .success(let tokens): + return tokens.accessToken + case .failure(let error): + throw error + } + } + throw AuthError.unknown("Did not receive a valid response from fetchAuthSession for get token.") +} +``` + +Then create the `AuthTokenAuthorizer` with this method. + +```swift +let authorizer = AuthTokenAuthorizer(fetchLatestAuthToken: getUserPoolAccessToken) +``` + +#### AWS IAM + +If you are using Amplify Auth, you can use the following class to implement SigV4 signing logic: + +```swift +import Foundation +import Amplify +import AWSPluginsCore +import AwsCommonRuntimeKit +import AWSSDKHTTPAuth +import Smithy +import SmithyHTTPAPI +import SmithyHTTPAuth +import SmithyHTTPAuthAPI +import SmithyIdentity + +class AppSyncEventsSigner { + + public static func createAppSyncSigner(region: String) -> ((URLRequest) async throws -> URLRequest) { + return { request in + try await signAppSyncRequest(request, + region: region) + } + } + + private static var signer = { + return AWSSigV4Signer() + }() + + static func signAppSyncRequest(_ urlRequest: URLRequest, + region: Swift.String, + signingName: Swift.String = "appsync", + date: Date = Date()) async throws -> URLRequest { + CommonRuntimeKit.initialize() + + // Convert URLRequest to SDK's HTTPRequest + guard let requestBuilder = try createAppSyncSdkHttpRequestBuilder( + urlRequest: urlRequest) else { + return urlRequest + } + + // Retrieve the credentials from credentials provider + let credentials: AWSCredentialIdentity + let authSession = try await Amplify.Auth.fetchAuthSession() + if let awsCredentialsProvider = authSession as? AuthAWSCredentialsProvider { + let awsCredentials = try awsCredentialsProvider.getAWSCredentials().get() + credentials = try awsCredentials.toAWSSDKCredentials() + } else { + let error = AuthError.unknown("Auth session does not include AWS credentials information") + throw error + } + + // Prepare signing + let flags = SigningFlags(useDoubleURIEncode: true, + shouldNormalizeURIPath: true, + omitSessionToken: false) + let signedBodyHeader: AWSSignedBodyHeader = .none + let signedBodyValue: AWSSignedBodyValue = .empty + let signingConfig = AWSSigningConfig(credentials: credentials, + signedBodyHeader: signedBodyHeader, + signedBodyValue: signedBodyValue, + flags: flags, + date: date, + service: signingName, + region: region, + signatureType: .requestHeaders, + signingAlgorithm: .sigv4) + + // Sign request + guard let httpRequest = await signer.sigV4SignedRequest( + requestBuilder: requestBuilder, + signingConfig: signingConfig + ) else { + return urlRequest + } + + // Update original request with new headers + return setHeaders(from: httpRequest, to: urlRequest) + } + + static func setHeaders(from sdkRequest: SmithyHTTPAPI.HTTPRequest, to urlRequest: URLRequest) -> URLRequest { + var urlRequest = urlRequest + for header in sdkRequest.headers.headers { + urlRequest.setValue(header.value.joined(separator: ","), forHTTPHeaderField: header.name) + } + return urlRequest + } + + static func createAppSyncSdkHttpRequestBuilder(urlRequest: URLRequest) throws -> HTTPRequestBuilder? { + + guard let url = urlRequest.url, + let host = url.host else { + return nil + } + + let headers = urlRequest.allHTTPHeaderFields ?? [:] + let httpMethod = (urlRequest.httpMethod?.uppercased()) + .flatMap(HTTPMethodType.init(rawValue:)) ?? .get + + let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems? + .map { URIQueryItem(name: $0.name, value: $0.value)} ?? [] + + let requestBuilder = HTTPRequestBuilder() + .withHost(host) + .withPath(url.path) + .withQueryItems(queryItems) + .withMethod(httpMethod) + .withPort(443) + .withProtocol(.https) + .withHeaders(.init(headers)) + .withBody(.data(urlRequest.httpBody)) + + return requestBuilder + } +} + +extension AWSPluginsCore.AWSCredentials { + + func toAWSSDKCredentials() throws -> AWSCredentialIdentity { + if let tempCredentials = self as? AWSTemporaryCredentials { + return AWSCredentialIdentity( + accessKey: tempCredentials.accessKeyId, + secret: tempCredentials.secretAccessKey, + expiration: tempCredentials.expiration, + sessionToken: tempCredentials.sessionToken + ) + } else { + return AWSCredentialIdentity( + accessKey: accessKeyId, + secret: secretAccessKey, + expiration: nil + ) + } + } +} +``` + +Then, create an `IAMAuthorizer` with this helper class. + +```swift +let authorizer = IAMAuthorizer( + signRequest: AppSyncEventsSigner.createAppSyncSigner(region: "region") +) +``` + + + + + + + ## Connect to an Event API without an existing Amplify backend Before you begin, you will need: @@ -198,7 +502,7 @@ Before you begin, you will need: - An Event API created via the AWS Console - Take note of: HTTP endpoint, region, API Key - + Thats it! Skip to [Client Library Usage Guide](#client-library-usage-guide). @@ -372,7 +676,7 @@ backend.addOutput({ ``` - + ```ts title="amplify/backend.ts" import { defineBackend } from '@aws-amplify/backend'; import { auth } from './auth/resource'; @@ -525,9 +829,11 @@ export default function App() { ``` - + ## Client Library Usage Guide + + ### Create the Events class You can find your endpoint in the AWS AppSync Events console. It should start with `https` and end with `/event`. @@ -785,8 +1091,247 @@ val webSocketClient: EventsWebSocketClient // Your configured EventsWebSocketCli // set flushEvents to false to immediately disconnect, discarding any pending posts to the WebSocket webSocketClient.disconnect(flushEvents = true) // or false to immediately disconnect ``` + + + +### Create the Events class + +You can find your endpoint in the AWS AppSync Events console. It should start with `https` and end with `/event`. + +```swift +let eventsEndpoint = "https://abcdefghijklmnopqrstuvwxyz.appsync-api.us-east-1.amazonaws.com/event" +let events = Events(endpointURL: eventsEndpoint) +``` + +### Using the REST Client + +An `EventsRestClient` can be created to publish event(s) over REST. It accepts a publish authorizer that will be used by default for any publish calls within the client. + +#### Creating the REST Client + +```swift +let events = Events(endpointURL: eventsEndpoint) +let restClient = events.createRestClient( + publishAuthorizer: APIKeyAuthorizer(apiKey: "apiKey") +) +``` + +Additionally, you can pass custom options to the Rest Client. Current capabilities include passing a custom `URLSessionConfiguration` object, a prepend `URLRequestInterceptor` and enabling client library logs. +See [Collecting Client Library Logs](#collecting-client-library-logs) and `RestOptions` class for more details. + +```swift +let restClient = events.createRestClient( + publishAuthorizer: apiKeyAuthorizer, + options: .init( + urlSessionConfiguration: urlSessionConfiguration, // your instance of `URLSessionConfiguration` + logger: AppSyncEventsLogger(), // your implementation of `EventsLogger` + interceptor: AppSyncEventsURLRequestInterceptor() // your implementation of `URLRequestInterceptor` + ) +) +``` + +#### Publish a single event + +```swift +let defaultChannel = "default/channel" +let event = JSONValue(stringLiteral: "123") +do { + let result = try await restClient.publish( + channelName: defaultChannel, + event: event + ) + print("Publish success with result:\n status: \(result.status) \n, successful events: \(result.successfulEvents) \n, failed events: \(result.failedEvents)") +} catch { + print("Publish failure with error: \(error)") +} +``` + +#### Publish multiple events + +You can publish up to 5 events at a time. + +```swift +let defaultChannel = "default/channel" +let eventsList = [ + JSONValue(stringLiteral: "123"), + JSONValue(booleanLiteral: true), + JSONValue(floatLiteral: 1.25), + JSONValue(integerLiteral: 37), + JSONValue(dictionaryLiteral: ("key", "value")) +] + +do { + let result = try await restClient.publish( + channelName: defaultChannel, + events: eventsList + ) + print("Publish success with result:\n status: \(result.status) \n, successful events: \(result.successfulEvents) \n, failed events: \(result.failedEvents)") +} catch { + print("Publish failure with error: \(error)") +} +``` + +#### Publish with a different authorizer + +```swift +let defaultChannel = "default/channel" +let event = JSONValue(stringLiteral: "123") +let apiKeyAuthorizer = APIKeyAuthorizer(apiKey: "apiKey") +do { + let result = try await restClient.publish( + channelName: defaultChannel, + event: event, + authorizer: apiKeyAuthorizer + ) + print("Publish success with result:\n status: \(result.status) \n, successful events: \(result.successfulEvents) \n, failed events: \(result.failedEvents)") +} catch { + print("Publish failure with error: \(error)") +} +``` + +### Using the WebSocket Client + +An `EventsWebSocketClient` can be created to publish and subscribe to channels. The WebSocket connection is managed by the library and connects on the first subscribe or publish operation. Once connected, the WebSocket will remain open. You should explicitly disconnect the client when you no longer need to subscribe or publish to channels. + +#### Creating the WebSocket Client + +```swift +let apiKeyAuthorizer = APIKeyAuthorizer(apiKey: "apiKey") +let webSocketClient = events.createWebSocketClient( + connectAuthorizer: apiKeyAuthorizer, + publishAuthorizer: apiKeyAuthorizer, + subscribeAuthorizer: apiKeyAuthorizer +) +``` + +Additionally, you can pass custom options to the WebSocket Client. Current capabilities include passing a custom `URLSessionConfiguration` object, a prepend `URLRequestInterceptor` and enabling client library logs. +See [Collecting Client Library Logs](#collecting-client-library-logs) and `WebSocketOptions` class for more details. + +```swift +let apiKeyAuthorizer = APIKeyAuthorizer(apiKey: "apiKey") +let webSocketClient = events.createWebSocketClient( + connectAuthorizer: apiKeyAuthorizer, + publishAuthorizer: apiKeyAuthorizer, + subscribeAuthorizer: apiKeyAuthorizer, + options: .init( + urlSessionConfiguration: urlSessionConfiguration, // your instance of `URLSessionConfiguration` + logger: AppSyncEventsLogger(), // your implementation of `EventsLogger` + interceptor: AppSyncEventsURLRequestInterceptor() // your implementation of `URLRequestInterceptor` + ) +) +``` + +#### Publish a Single Event + +```swift +let defaultChannel = "default/channel" +let event = JSONValue(stringLiteral: "123") +do { + let result = try await websocketClient.publish( + channelName: defaultChannel, + event: event + ) + print("Publish success with result:\n status: \(result.status) \n, successful events: \(result.successfulEvents) \n, failed events: \(result.failedEvents)") +} catch { + print("Publish failure with error: \(error)") +} +``` + +#### Publish multiple Events + +You can publish up to 5 events at a time. + +```swift +let defaultChannel = "default/channel" +let eventsList = [ + JSONValue(stringLiteral: "123"), + JSONValue(booleanLiteral: true), + JSONValue(floatLiteral: 1.25), + JSONValue(integerLiteral: 37), + JSONValue(dictionaryLiteral: ("key", "value")) +] + +do { + let result = try await websocketClient.publish( + channelName: defaultChannel, + events: eventsList + ) + print("Publish success with result:\n status: \(result.status) \n, successful events: \(result.successfulEvents) \n, failed events: \(result.failedEvents)") +} catch { + print("Publish failure with error: \(error)") +} +``` + +#### Publish with a different authorizer + +```swift +let defaultChannel = "default/channel" +let event = JSONValue(stringLiteral: "123") +let apiKeyAuthorizer = APIKeyAuthorizer(apiKey: "apiKey") +do { + let result = try await websocketClient.publish( + channelName: defaultChannel, + event: event, + authorizer: apiKeyAuthorizer + ) + print("Publish success with result:\n status: \(result.status) \n, successful events: \(result.successfulEvents) \n, failed events: \(result.failedEvents)") +} catch { + print("Publish failure with error: \(error)") +} +``` + +#### Subscribing to a channel + +When subscribing to a channel, you can subscribe to a specific namespace/channel (e.g. `default/channel`), or you can specify a wildcard (`*`) at the end of a channel path to receive events published to all channels that match (e.g. `default/*`). + + +```swift +let defaultChannel = "default/channel" +let subscription = try websocketClient.subscribe(channelName: defaultChannel) +let task = Task { + for try await message in subscription { + print("Subscription received message: \(message))" + } +} +``` +To unsubscribe from the channel, you can cancel the enclosing task for the `AsyncThrowingStream`. + +```swift +task.cancel() +``` + +#### Subscribing to a channel with a different authorizer +```swift +let defaultChannel = "default/channel" +let apiKeyAuthorizer = APIKeyAuthorizer(apiKey: "apiKey") +let subscription = try websocketClient.subscribe( + channelName: defaultChannel, + authorizer: apiKeyAuthorizer +) +let task = Task { + for try await message in subscription { + print("Subscription received message: \(message))" + } +} +``` + +#### Disconnecting the WebSocket + +When you are done using the WebSocket and do not intend to call publish/subscribe on the client, you should disconnect the WebSocket. This will unsubscribe all channels. + +```swift +// set flushEvents to true if you want to wait for any pending publish operations to post to the WebSocket +// set flushEvents to false to immediately disconnect, discarding any pending posts to the WebSocket +try await webSocketClient.disconnect(flushEvents: true) // or false to immediately disconnect +``` + + + ### Collecting Client Library Logs + + + In the Rest Client and WebSocket Client examples, we demonstrated logging to a custom logger. Here is an example of a custom logger that writes logs to Android's Logcat. You are free to implement your own `Logger` type. @@ -839,4 +1384,84 @@ class AndroidLogger( } } ``` + + + + +In the Rest Client and WebSocket Client examples, we demonstrated logging to a custom logger. Here is an example of a custom logger that writes logs to Xcode console. You are free to implement your own `EventsLogger` type. + +```swift +import os +import Foundation +import AWSAppSyncEvents + +public final class AppSyncEventsLogger: EventsLogger { + static let lock: NSLocking = NSLock() + + static var _logLevel = LogLevel.error + + public init() { } + + public var logLevel: LogLevel { + get { + AppSyncEventsLogger.lock.lock() + defer { + AppSyncEventsLogger.lock.unlock() + } + + return AppSyncEventsLogger._logLevel + } + set { + AppSyncEventsLogger.lock.lock() + defer { + AppSyncEventsLogger.lock.unlock() + } + + AppSyncEventsLogger._logLevel = newValue + } + } + + public func error(_ log: @autoclosure () -> String) { + os_log("%@", type: .error, log()) + } + + public func error(_ error: @autoclosure () -> Error) { + os_log("%@", type: .error, error().localizedDescription) + } + + public func warn(_ log: @autoclosure () -> String) { + guard logLevel.rawValue >= LogLevel.warn.rawValue else { + return + } + + os_log("%@", type: .info, log()) + } + + public func info(_ log: @autoclosure () -> String) { + guard logLevel.rawValue >= LogLevel.info.rawValue else { + return + } + + os_log("%@", type: .info, log()) + } + + public func debug(_ log: @autoclosure () -> String) { + guard logLevel.rawValue >= LogLevel.debug.rawValue else { + return + } + + os_log("%@", type: .debug, log()) + } + + public func verbose(_ log: @autoclosure () -> String) { + guard logLevel.rawValue >= LogLevel.verbose.rawValue else { + return + } + + os_log("%@", type: .debug, log()) + } +} + +``` +