Skip to content
Open
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
1 change: 1 addition & 0 deletions appsync/aws-appsync/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/build
51 changes: 51 additions & 0 deletions appsync/aws-appsync/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/*
* Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://aws.amazon.com/apache2.0
*
* or in the "license" file accompanying this file. This file is distributed
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
* express or implied. See the License for the specific language governing
* permissions and limitations under the License.
*/

import java.util.Properties

plugins {
alias(libs.plugins.amplify.android.library)
alias(libs.plugins.amplify.publishing)
}

fun readVersion() = Properties().run {
file("../version.properties").inputStream().use { load(it) }
get("VERSION_NAME").toString()
}

project.setProperty("VERSION_NAME", readVersion())

android {
namespace = "com.amazonaws.appsync"
}

dependencies {
api(project(":core"))
implementation(project(":aws-api"))
implementation(project(":aws-api-appsync"))

implementation(libs.okhttp)
implementation(libs.gson)
implementation(libs.kotlin.coroutines)
api(project(":foundation"))
implementation(project(":foundation-bridge"))

testImplementation(libs.test.junit)
testImplementation(libs.test.mockk)
testImplementation(libs.test.kotlin.coroutines)
testImplementation(libs.test.kotest.assertions)
testImplementation(libs.test.mockwebserver)
testImplementation(libs.test.turbine)
}
5 changes: 5 additions & 0 deletions appsync/aws-appsync/gradle.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
POM_GROUP=com.amazonaws
POM_ARTIFACT_ID=aws-appsync
POM_NAME=AWS AppSync Client for Android
POM_DESCRIPTION=Standalone AppSync GraphQL Client for Android
POM_PACKAGING=aar
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
/*
* Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://aws.amazon.com/apache2.0
*
* or in the "license" file accompanying this file. This file is distributed
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
* express or implied. See the License for the specific language governing
* permissions and limitations under the License.
*/
package com.amazonaws.appsync

import com.amazonaws.appsync.internal.GraphQLHttpClient
import com.amazonaws.appsync.internal.GraphQLWebSocketClient
import com.amplifyframework.annotations.ExperimentalAmplifyApi
import com.amplifyframework.api.ApiException
import com.amplifyframework.api.graphql.GraphQLRequest
import com.amplifyframework.api.graphql.GraphQLResponse
import com.amplifyframework.foundation.result.Result
import com.amplifyframework.foundation.result.resultCatching
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharedFlow
import okhttp3.OkHttpClient

/**
* 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.
*
* ```kotlin
* val client = AmplifyAppSyncClient(
* AmplifyAppSyncClient.Configuration {
* endpoint = "https://xxx.appsync-api.us-east-1.amazonaws.com/graphql"
* authorization = AppSyncAuthorization.Single(
* AppSyncClientAuthorizer.ApiKey("da2-xxx")
* )
* }
* )
*
* when (val result = client.query(ModelQuery.get(Todo::class.java, "id-123"))) {
* is Result.Success -> use(result.data)
* is Result.Failure -> handleError(result.error)
* }
* ```
*/
@ExperimentalAmplifyApi
class AmplifyAppSyncClient(val configuration: Configuration) {

private val httpClient = GraphQLHttpClient(configuration)
private val webSocketClient = GraphQLWebSocketClient(configuration)

/**
* Per-client connection state flow.
* Emits [ConnectionState] changes for the shared WebSocket connection.
*/
val events: SharedFlow<ConnectionState>
get() = webSocketClient.connectionState

/**
* Execute a GraphQL query.
*
* @param request The GraphQL request. Use model helpers or construct manually.
* @return [Result.Success] with the typed GraphQL response, or [Result.Failure] with an [ApiException].
*/
suspend fun <T> query(request: GraphQLRequest<T>): Result<GraphQLResponse<T>, Throwable> =
resultCatching { httpClient.execute(request) }

/**
* Execute a GraphQL mutation.
*
* @param request The GraphQL request. Use model helpers or construct manually.
* @return [Result.Success] with the typed GraphQL response, or [Result.Failure] with an [ApiException].
*/
suspend fun <T> mutate(request: GraphQLRequest<T>): Result<GraphQLResponse<T>, Throwable> =
resultCatching { httpClient.execute(request) }

/**
* Subscribe to a GraphQL subscription. Returns a [Flow] of [SubscriptionEvent].
*
* The WebSocket connection is lazy (established on first subscribe) and shared across
* all subscriptions on this client. Cancelling the collecting coroutine sends an
* unsubscribe message and releases the subscription.
*
* @param request The GraphQL subscription request. Use model helpers or construct manually.
* @return A cold [Flow] of [SubscriptionEvent].
*/
fun <T> subscribe(request: GraphQLRequest<T>): Flow<SubscriptionEvent<T>> =
webSocketClient.subscribe(request)

/**
* Close the client. Terminates all active subscriptions and releases resources.
* The client cannot be reused after closing.
*/
fun close() {
webSocketClient.close()
httpClient.close()
}

// ── Configuration ───────────────────────────────────────────────────

/**
* Configuration for [AmplifyAppSyncClient].
*
* Use the builder DSL:
* ```kotlin
* AmplifyAppSyncClient.Configuration {
* endpoint = "https://xxx.appsync-api.us-east-1.amazonaws.com/graphql"
* authorization = AppSyncAuthorization.Single(
* AppSyncClientAuthorizer.ApiKey("da2-xxx")
* )
* }
* ```
*/
data class Configuration internal constructor(
/** The AppSync GraphQL endpoint URL. */
val endpoint: String,
/** Auth configuration for the client. */
val authorization: AppSyncAuthorization,
/** AWS region. Inferred from the endpoint URL or set explicitly. */
val region: String,
/** Optional configurator for the OkHttp client used for HTTP requests. */
val httpClientConfigurator: ((OkHttpClient.Builder) -> Unit)? = null,
/** Optional configurator for the OkHttp client used for WebSocket connections. */
val webSocketClientConfigurator: ((OkHttpClient.Builder) -> Unit)? = null
) {
/**
* Builder for [Configuration]. Required fields: [endpoint] and [authorization].
*/
class Builder internal constructor() {
/** The AppSync GraphQL endpoint URL. Required. */
lateinit var endpoint: String

/** Auth configuration. Required. */
lateinit var authorization: AppSyncAuthorization

/** AWS region. Defaults to inferred from the endpoint URL. */
var region: String? = null

/** Optional configurator for the HTTP OkHttp client. */
var httpClientConfigurator: ((OkHttpClient.Builder) -> Unit)? = null

/** Optional configurator for the WebSocket OkHttp client. */
var webSocketClientConfigurator: ((OkHttpClient.Builder) -> Unit)? = null

internal fun build(): Configuration {
require(::endpoint.isInitialized) { "endpoint is required" }
require(::authorization.isInitialized) { "authorization is required" }
val resolvedRegion = region ?: inferRegion(endpoint)
requireNotNull(resolvedRegion) {
"region is required. Either set it explicitly or use a standard AppSync endpoint URL."
}
return Configuration(
endpoint = endpoint,
authorization = authorization,
region = resolvedRegion,
httpClientConfigurator = httpClientConfigurator,
webSocketClientConfigurator = webSocketClientConfigurator
)
}
}

companion object {
/**
* Create a [Configuration] using the builder DSL.
*/
operator fun invoke(block: Builder.() -> Unit): Configuration =
Builder().apply(block).build()

/**
* Infer the AWS region from an AppSync endpoint URL.
* Expected format: `https://{id}.appsync-api.{region}.amazonaws.com/graphql`
*/
internal fun inferRegion(endpoint: String): String? {
val regex = Regex("""\.appsync-api\.([a-z0-9-]+)\.amazonaws\.com""")
return regex.find(endpoint)?.groupValues?.get(1)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/*
* Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://aws.amazon.com/apache2.0
*
* or in the "license" file accompanying this file. This file is distributed
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
* express or implied. See the License for the specific language governing
* permissions and limitations under the License.
*/
package com.amazonaws.appsync

import com.amplifyframework.annotations.ExperimentalAmplifyApi

/**
* The authorization modes supported by AWS AppSync.
*/
@ExperimentalAmplifyApi
enum class AppSyncAuthMode {
/** API Key authorization. */
API_KEY,

/** Amazon Cognito User Pools authorization. */
USER_POOLS,

/** OpenID Connect authorization. */
OIDC,

/** AWS IAM authorization. */
IAM,

/** AWS Lambda custom authorization. */
LAMBDA
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/*
* Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://aws.amazon.com/apache2.0
*
* or in the "license" file accompanying this file. This file is distributed
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
* express or implied. See the License for the specific language governing
* permissions and limitations under the License.
*/
package com.amazonaws.appsync

import com.amplifyframework.annotations.ExperimentalAmplifyApi

/**
* 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).
*/
@ExperimentalAmplifyApi
sealed class AppSyncAuthorization {

/**
* Single authorizer used for all requests.
* @param authorizer The authorizer to use.
*/
data class Single(
val authorizer: AppSyncClientAuthorizer
) : AppSyncAuthorization()

/**
* 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.
*
* @param defaultAuthMode The auth mode to use when no per-request override or model rule applies.
* @param authorizers The list of authorizers. Each authorizer's [AppSyncClientAuthorizer.authMode]
* determines which auth mode it handles. Duplicate auth modes are not allowed.
*/
data class Multi(
val defaultAuthMode: AppSyncAuthMode,
val authorizers: List<AppSyncClientAuthorizer>
) : AppSyncAuthorization() {
init {
val modes = authorizers.map { it.authMode }
require(modes.distinct().size == modes.size) {
"Duplicate auth modes in authorizers list: ${modes.groupBy { it }.filter { it.value.size > 1 }.keys}"
}
require(authorizers.any { it.authMode == defaultAuthMode }) {
"No authorizer provided for the default auth mode: $defaultAuthMode"
}
}
}

/**
* Resolves the authorizer for a given [AppSyncAuthMode].
* @return The matching authorizer, or null if not found.
*/
internal fun authorizerFor(mode: AppSyncAuthMode): AppSyncClientAuthorizer? = when (this) {
is Single -> authorizer.takeIf { it.authMode == mode }
is Multi -> authorizers.firstOrNull { it.authMode == mode }
}

/**
* Returns the default authorizer.
*/
internal val defaultAuthorizer: AppSyncClientAuthorizer
get() = when (this) {
is Single -> authorizer
is Multi -> authorizers.first { it.authMode == this.defaultAuthMode }
}

/**
* Returns the default auth mode.
*/
internal fun resolveDefaultAuthMode(): AppSyncAuthMode = when (this) {
is Single -> authorizer.authMode
is Multi -> defaultAuthMode
}
}
Loading
Loading