Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
//
// Copyright Amazon.com Inc. or its affiliates.
// All Rights Reserved.
//
// SPDX-License-Identifier: Apache-2.0
//

import Amplify
import Foundation

/// Standalone, instantiable AppSync GraphQL client. Supports queries, mutations, and
/// subscriptions with typed auth and per-client connection state.
///
/// Not a singleton — create multiple instances for multi-tenant / multi-API scenarios.
///
/// ```swift
/// let client = try AmplifyAppSyncClient(
/// configuration: .init(
/// endpoint: "https://xxx.appsync-api.us-east-1.amazonaws.com/graphql",
/// authorization: .single(.apiKey("da2-xxx"))
/// )
/// )
///
/// let response = try await client.query(request)
/// ```
public final class AmplifyAppSyncClient: Sendable {

/// The configuration this client was created with.
public let configuration: Configuration

/// Creates a new AppSync client.
///
/// - Parameter configuration: The client configuration including endpoint and authorization.
/// - Throws: If the configuration is invalid (e.g., region cannot be inferred).
public init(configuration: Configuration) throws {
self.configuration = configuration
// TODO: Initialize internal HTTP and WebSocket transports
}

// MARK: - Query

/// Execute a GraphQL query.
///
/// - Parameter request: The GraphQL request.
/// - Returns: The typed GraphQL response.
/// - Throws: `AppSyncError` or `AppSyncAuthError` on failure.
public func query<T: Decodable & Sendable>(
_ request: GraphQLRequest<T>
) async throws -> GraphQLResponse<T> {
fatalError("Not yet implemented")
}

// MARK: - Mutation

/// Execute a GraphQL mutation.
///
/// - Parameter request: The GraphQL request.
/// - Returns: The typed GraphQL response.
/// - Throws: `AppSyncError` or `AppSyncAuthError` on failure.
public func mutate<T: Decodable & Sendable>(
_ request: GraphQLRequest<T>
) async throws -> GraphQLResponse<T> {
fatalError("Not yet implemented")
}

// MARK: - Subscription

/// Subscribe to a GraphQL subscription.
///
/// The WebSocket connection is lazy (established on first subscribe) and shared across
/// all subscriptions on this client. Cancelling the task terminates the subscription.
///
/// - Parameter request: The GraphQL subscription request.
/// - Returns: An async stream of subscription events.
public func subscribe<T: Decodable & Sendable>(
_ request: GraphQLRequest<T>
) -> AsyncThrowingStream<SubscriptionEvent<T>, Error> {
fatalError("Not yet implemented")
}

// MARK: - Connection State

/// Per-client WebSocket connection state.
/// Emits `ConnectionState` changes for the shared WebSocket connection.
public var connectionState: AsyncStream<ConnectionState> {
fatalError("Not yet implemented")
}

// MARK: - Lifecycle

/// Close the client. Terminates all active subscriptions and releases resources.
/// The client cannot be reused after closing.
public func close() {
// TODO: Close HTTP and WebSocket transports
}

// MARK: - Configuration

/// Configuration for `AmplifyAppSyncClient`.
public struct Configuration: @unchecked Sendable {
/// The AppSync GraphQL endpoint URL.
public let endpoint: URL

/// Auth configuration for the client.
public let authorization: AppSyncAuthorization

/// AWS region. Inferred from the endpoint URL if not provided.
public let region: String

/// Optional configurator for the URLSession used for HTTP requests.
public let urlSessionConfiguration: URLSessionConfiguration?

/// Creates a new configuration.
///
/// - Parameters:
/// - endpoint: The AppSync GraphQL endpoint URL string.
/// - authorization: Auth configuration for the client.
/// - region: AWS region. If nil, inferred from the endpoint URL.
/// - urlSessionConfiguration: Optional URLSession configuration for HTTP requests.
/// - Throws: If the endpoint is not a valid URL or region cannot be inferred.
public init(
endpoint: String,
authorization: AppSyncAuthorization,
region: String? = nil,
urlSessionConfiguration: URLSessionConfiguration? = nil
) throws {
guard let url = URL(string: endpoint) else {
throw ConfigurationError.invalidEndpoint(endpoint)
}
let resolvedRegion = region ?? Self.inferRegion(from: endpoint)
guard let resolvedRegion else {
throw ConfigurationError.regionRequired
}
self.endpoint = url
self.authorization = authorization
self.region = resolvedRegion
self.urlSessionConfiguration = urlSessionConfiguration
}

/// Infer the AWS region from an AppSync endpoint URL.
/// Expected format: `https://{id}.appsync-api.{region}.amazonaws.com/graphql`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just FYI, china regions are slightly different example appsync-api.cn-north-1.amazonaws.com.cn

static func inferRegion(from endpoint: String) -> String? {
let pattern = #"\.appsync-api\.([a-z0-9-]+)\.amazonaws\.com"#
guard let regex = try? NSRegularExpression(pattern: pattern),
let match = regex.firstMatch(
in: endpoint,
range: NSRange(endpoint.startIndex..., in: endpoint)
),
let range = Range(match.range(at: 1), in: endpoint) else {
return nil
}
return String(endpoint[range])
}
}
}

// MARK: - Configuration Errors

public extension AmplifyAppSyncClient {
/// Errors thrown during configuration validation.
enum ConfigurationError: Error, Sendable {
/// The endpoint string is not a valid URL.
case invalidEndpoint(String)
/// Region could not be inferred and was not provided.
case regionRequired
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
//
// Copyright Amazon.com Inc. or its affiliates.
// All Rights Reserved.
//
// SPDX-License-Identifier: Apache-2.0
//

import Foundation

/// The authorization modes supported by AWS AppSync.
public enum AppSyncAuthMode: Sendable {
/// API Key authorization.
case apiKey
/// Amazon Cognito User Pools authorization.
case userPools
/// OpenID Connect authorization.
case oidc
/// AWS IAM authorization (SigV4 signing).
case iam
/// AWS Lambda custom authorization.
case lambda
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
//
// Copyright Amazon.com Inc. or its affiliates.
// All Rights Reserved.
//
// SPDX-License-Identifier: Apache-2.0
//

import Foundation

/// Wraps the authorizer(s) that the client uses.
///
/// Supports both single-auth (one authorizer for all requests) and multi-auth
/// (multiple authorizers, selected based on model `@auth` rules or per-request overrides).
public enum AppSyncAuthorization: Sendable {

/// Single authorizer used for all requests.
case single(AppSyncAuthorizer)

/// Multiple authorizers. The client selects the appropriate one based on model
/// `@auth` rules or per-request auth mode overrides. Falls back to `defaultAuthMode`
/// when no rule matches.
///
/// - Parameters:
/// - defaultAuthMode: The auth mode to use when no per-request override or model rule applies.
/// - authorizers: The list of authorizers. Duplicate auth modes are not allowed.
case multi(defaultAuthMode: AppSyncAuthMode, authorizers: [AppSyncAuthorizer])
}

extension AppSyncAuthorization {

/// Resolves the authorizer for a given auth mode.
/// - Returns: The matching authorizer, or nil if not found.
func authorizer(for mode: AppSyncAuthMode) -> AppSyncAuthorizer? {
switch self {
case .single(let authorizer):
return authorizer.authMode == mode ? authorizer : nil
case .multi(_, let authorizers):
return authorizers.first { $0.authMode == mode }
}
}

/// The default authorizer.
var defaultAuthorizer: AppSyncAuthorizer {
switch self {
case .single(let authorizer):
return authorizer
case .multi(let defaultAuthMode, let authorizers):
guard let authorizer = authorizers.first(where: { $0.authMode == defaultAuthMode }) else {
preconditionFailure(
"No authorizer provided for the default auth mode: \(defaultAuthMode). " +
"Ensure the authorizers list contains an entry matching the defaultAuthMode."
)
}
return authorizer
}
}

/// The default auth mode.
var defaultAuthMode: AppSyncAuthMode {
switch self {
case .single(let authorizer):
return authorizer.authMode
case .multi(let defaultAuthMode, _):
return defaultAuthMode
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
//
// Copyright Amazon.com Inc. or its affiliates.
// All Rights Reserved.
//
// SPDX-License-Identifier: Apache-2.0
//

import AWSClientRuntime
import Foundation
import SmithyIdentity

/// An authorizer that provides credentials for a specific AppSync auth mode.
///
/// Each case encodes its auth mode and holds the provider needed to produce
/// authorization credentials for that mode.
public enum AppSyncAuthorizer: @unchecked Sendable {

/// API Key authorization.
/// - Parameter fetchApiKey: Async function that provides the API key.
case apiKey(_ fetchApiKey: @Sendable () async throws -> String)

/// Amazon Cognito User Pools authorization.
/// - Parameter fetchToken: Async function that returns a valid access/ID token.
case userPools(_ fetchToken: @Sendable () async throws -> String)

/// OpenID Connect authorization.
/// - Parameter fetchToken: Async function that returns a valid OIDC token.
case oidc(_ fetchToken: @Sendable () async throws -> String)

/// AWS Lambda custom authorization.
/// - Parameter fetchToken: Async function that returns a valid authorization token.
case lambda(_ fetchToken: @Sendable () async throws -> String)

/// IAM (SigV4) authorization.
/// - Parameter credentialIdentityResolver: Provides IAM credentials for SigV4 signing.
case iam(_ credentialIdentityResolver: any AWSCredentialIdentityResolver)

/// The auth mode this authorizer provides.
public var authMode: AppSyncAuthMode {
switch self {
case .apiKey: return .apiKey
case .userPools: return .userPools
case .oidc: return .oidc
case .lambda: return .lambda
case .iam: return .iam
}
}
}

// Convenience factory for static API key
public extension AppSyncAuthorizer {
/// Creates an API Key authorizer with a static key value.
static func apiKey(_ apiKey: String) -> AppSyncAuthorizer {
.apiKey { apiKey }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
//
// Copyright Amazon.com Inc. or its affiliates.
// All Rights Reserved.
//
// SPDX-License-Identifier: Apache-2.0
//

import Foundation

/// The connection state of the client's shared WebSocket.
public enum ConnectionState: Sendable {
/// A WebSocket connection is being established.
case connecting
/// The WebSocket connection is established and ready.
case connected
/// No active WebSocket connection.
/// - Parameter reason: A description of why the connection was lost, or nil for clean shutdown.
case disconnected(reason: String? = nil)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
//
// Copyright Amazon.com Inc. or its affiliates.
// All Rights Reserved.
//
// SPDX-License-Identifier: Apache-2.0
//

import Amplify
import Foundation

/// Events emitted by a GraphQL subscription stream.
///
/// Lifecycle: the stream emits `.connecting` → `.connected` → `.data(...)` repeatedly,
/// then either completes normally (user cancel, server complete, client close) or
/// throws an error (network, auth, timeout, etc.).
public enum SubscriptionEvent<T: Decodable & Sendable>: Sendable {
/// A data message received from the subscription.
case data(GraphQLResponse<T>)
/// The subscription is being established (WebSocket connecting + registration in progress).
case connecting
/// The subscription is established and receiving data.
case connected
}
16 changes: 16 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,17 @@ let firehoseTargets: [Target] = [
)
]

let appSyncClientTargets: [Target] = [
.target(
name: "AmplifyAppSyncClient",
dependencies: [
.target(name: "Amplify"),
.product(name: "AWSClientRuntime", package: "aws-sdk-swift")
],
path: "AmplifyClients/AmplifyAppSyncClient/Sources"
),
]

let pushNotificationsTargets: [Target] = [
.target(
name: "AWSPinpointPushNotificationsPlugin",
Expand Down Expand Up @@ -592,6 +603,7 @@ targets.append(contentsOf: analyticsTargets)
targets.append(contentsOf: recordCacheTargets)
targets.append(contentsOf: kinesisTargets)
targets.append(contentsOf: firehoseTargets)
targets.append(contentsOf: appSyncClientTargets)
targets.append(contentsOf: pushNotificationsTargets)
targets.append(contentsOf: internalPinpointTargets)
targets.append(contentsOf: predictionsTargets)
Expand Down Expand Up @@ -659,6 +671,10 @@ let package = Package(
name: "AmplifyFirehoseClient",
targets: ["AmplifyFirehoseClient"]
),
.library(
name: "AmplifyAppSyncClient",
targets: ["AmplifyAppSyncClient"]
),
.library(
name: "AmplifyFoundation",
targets: ["AmplifyFoundation"]
Expand Down
Loading