Using Apollo with AppSync directly on iOS

For the past few days I have been trying to get the apollo-ios subscriptions to work with AppSync with no luck. I am able to fetch the schema, generate the API.swift file, and run querys but when I try to setup the WebSocketTransport no matter what I pass I am getting

{
    errorType = UnsupportedOperation;
    message = "unknown not supported through the realtime channel";
}

I am setting up the Apollo Client like this:

class Network {
    static let shared = Network()
    private lazy var cache = InMemoryNormalizedCache()
    private lazy var store = ApolloStore(cache: cache)

    private lazy var webSocketTransport: WebSocketTransport = {

        let authDict = [
            "x-api-key": "API_KEY",
            "host": "<API_URL>.appsync-api.ap-northeast-1.amazonaws.com"
        ]

        let headerData: Data = try! JSONSerialization.data(withJSONObject: authDict, options: JSONSerialization.WritingOptions.prettyPrinted)
        let headerBase64 = headerData.base64EncodedString()

        let payloadData = try! JSONSerialization.data(withJSONObject: [:], options: JSONSerialization.WritingOptions.prettyPrinted)
        let payloadBase64 = payloadData.base64EncodedString()

        let authPayload: [String : JSONEncodable] = [
            "header": headerBase64,
            "payload": payloadBase64
        ]

        let url = URL(string: "wss://<API_URL>.appsync-realtime-api.ap-northeast-1.amazonaws.com/graphql?header=\(headerBase64)&payload=\(payloadBase64)")!
        let request = URLRequest(url: url)

        return WebSocketTransport(request: request, sendOperationIdentifiers: false, reconnectionInterval: 5)
    }()

    /// An HTTP transport to use for queries and mutations
    private lazy var normalTransport: RequestChainNetworkTransport = {
        let url = URL(string: "https://<API_URL>.appsync-api.ap-northeast-1.amazonaws.com/graphql")!
        return RequestChainNetworkTransport(interceptorProvider: LegacyInterceptorProvider(store: store), endpointURL: url, additionalHeaders: ["X-Api-Key": "API_KEY"])
    }()

    /// A split network transport to allow the use of both of the above
    /// transports through a single `NetworkTransport` instance.
    private lazy var splitNetworkTransport = SplitNetworkTransport(
        uploadingNetworkTransport: self.normalTransport,
        webSocketNetworkTransport: self.webSocketTransport
    )

    private(set) lazy var apollo: ApolloClient = {
        return ApolloClient(networkTransport: splitNetworkTransport, store: store)
    }()

and when setting up a subscription I did:

class ViewController: UIViewController {
    var subscription: Cancellable?

    override func viewDidLoad() {
        super.viewDidLoad()
        subscription = Network.shared.apollo.subscribe(subscription: SubscribeCommentsSubscription()) { result in
            switch result {
            case .success(let graphQLResult):
              print("Success! Result: \(graphQLResult)")
            case .failure(let error):
              print("Failure! Error: \(error)")
            }
        }
    }

    deinit {
      // Make sure the subscription is cancelled, if it exists, when this object is deallocated.
      self.subscription?.cancel()
    }
}

Just to share a snippet from API.swift

    """
    subscription subscribeComments {
      subscribeToEventComments(eventId: "EVENT_ID") {
        __typename
        eventId
        commentId
        content
      }
    }
    """

A similar setup is working on Android so I am at a loss for what is not working correctly…

In Addition to the above I tried creating my own body for the Subscription Request to no avail…

class AppSyncRequestBodyCreator: RequestBodyCreator {
    func requestBody<Operation>(for operation: Operation, sendOperationIdentifiers: Bool, sendQueryDocument: Bool, autoPersistQuery: Bool) -> GraphQLMap where Operation : GraphQLOperation {
        var body: GraphQLMap = [:
//          "variables": operation.variables
//          "operationName": operation.operationName,
        ]

        if sendOperationIdentifiers {
          guard let operationIdentifier = operation.operationIdentifier else {
            preconditionFailure("To send operation identifiers, Apollo types must be generated with operationIdentifiers")
          }

            body["id"] = UUID().uuidString
        }

        if sendQueryDocument {
            let data = try! JSONSerialization.data(withJSONObject: ["query": operation.queryDocument,
                                                                    "variables": [:]], options: .prettyPrinted)
            let jsonString = String(data: data, encoding: .utf8)
            body["data"] = jsonString
        }

        let authDict = [
            "x-api-key": "API_KEY",
            "host": "API_URL.appsync-api.ap-northeast-1.amazonaws.com",
        ]
        body["extension"] = ["authorization": authDict]

        if autoPersistQuery {
          guard let operationIdentifier = operation.operationIdentifier else {
            preconditionFailure("To enable `autoPersistQueries`, Apollo types must be generated with operationIdentifiers")
          }

          body["extensions"] = [
            "persistedQuery" : ["sha256Hash": operationIdentifier, "version": 1]
          ]
        }

        return body
    }
}

I do not have a solution but am experiencing the same problem on Mac with Node.js (v16) - my code is described in [SOLVED] Using `client.subscribe` does not work? (to AppSync, from Node) When I add logs to the ws implementation, I see:

ws.msg {"type":"connection_ack","payload":{"connectionTimeoutMs":300000}}
ws.msg {"type":"ka"}
ws.msg {"type":"error","payload":{"errors":[{"errorType":"UnsupportedOperation","message":"unknown not supported through the realtime channel"}]}}

I suppose I got to the “connection init ack” as described in the docs. The error then I guess comes from AppSync. However the Python client - also referenced from my post - works just fine. There the messages are:

### opened ###
>> {"type": "connection_init"}
### message ###
<< {"type":"connection_ack","payload":{"connectionTimeoutMs":300000}}
>> {"id": "04acda54-438d-4558-8472-211b76df0f73", "payload": {"data": "{\"query\": \"subscription JhTestSubscription {onOrderAndRequestOwnerConsent(id: \\\"281c6d08-146b-4b05-aeb9-ef12c5ed1cc5\\\") {id,orderId,state}}\", \"variables\": {}}", "extensions": {"authorization": {"host": "<secret>.appsync-api.eu-west-1.amazonaws.com", "x-api-key": "da2-<secret>"}}}, "type": "start"}

The changes described here Document how to use client with AppSync · Issue #224 · apollographql/apollo-feature-requests · GitHub solved the problem for me. You would need to do something similar in the iOS code, I imagine. No idea why a similar setup works on Android.

(Edited by admin @AnthonyMDev to fix broken link)

Yeah I overlooked a huge step in connecting Apollo to AppSync. I ended up subclassing RequestBodyCreator and fitting it to the AppSync docs.

var body: GraphQLMap = [:]
let data = try! JSONSerialization.data(withJSONObject: ["query": operation.queryDocument,
                                                                    "variables": [:]], options: .prettyPrinted)
let jsonString = String(data: data, encoding: .utf8)
body["data"] = jsonString
body["extensions"] = ["authorization": "{authorization info}"]

but to get rid of the strange error I forked Apollo and edited like so
https://github.com/monolithic-adam/apollo-ios/commit/2936816801a7ee2ccb68af4ae4f52981cb29b186

It had nothing to do with Apollo at all except for the start_ack not being implemented :sweat_smile:

I’m having a similar problem in my IOS app.
I’m sorry but i cant really piece together how your finished solution would look like.

Do you have some complete code on how you manage to solve the problem?

Like how you manage the connection and create the subscription?

I just came across this post and solved this issue so I will share some code for others that run into it. Took me a while of trial and error to piece it together. I am integrating with AppSync here via api-key auth. In the snippet below you can see the additional header I add.

To get it to work, create the transport / client as normal, but what is required is to setup a custom request body creator as mentioned above. You have to pass it to any transport you create. In my case this is for a AppSync websocket endpoint.

        // Create the websocket client
        let webSocketClient = WebSocket(request: request, protocol: .graphql_ws)
        
        // This custom body wrapper is required for AppSync's protocol
        let requestBody = AppSyncRequestBodyCreator()
        
        requestBody.authorization = authHeaders
        
        let webSocketTransport = WebSocketTransport(websocket: webSocketClient, requestBodyCreator: requestBody)
        
        let normalTransport = RequestChainNetworkTransport(interceptorProvider: DefaultInterceptorProvider(store: store), endpointURL: normalUrl, additionalHeaders: ["x-api-key" : parsedResponse.data.config.appSyncKey])
        
        // Using a split transport allows us to handle both standard HTTP queries and websockets through the same client
        let splitTransport = SplitNetworkTransport(uploadingNetworkTransport: normalTransport, webSocketNetworkTransport: webSocketTransport)
        self.client = ApolloClient(networkTransport: splitTransport, store: self.store)

The code for the RequestBodyCreator:

/**
 Creates an AWS AppSync-compatible RequestBody for subscriptions.
 Credit to: https://community.apollographql.com/t/using-apollo-with-appsync-directly-on-ios/324/4
 */
public class AppSyncRequestBodyCreator : RequestBodyCreator {
    
    // Don't forget to to set these! This is required for the initial handshake.
    var authorization: [String : String]!
    
    public func requestBody<Operation>(for operation: Operation, sendOperationIdentifiers: Bool, sendQueryDocument: Bool, autoPersistQuery: Bool) -> GraphQLMap where Operation : GraphQLOperation {
        
        var body: GraphQLMap = [
            "variables": operation.variables,
            "operationName": operation.operationName,
        ]
        
        if sendOperationIdentifiers {
            guard let operationIdentifier = operation.operationIdentifier else {
                preconditionFailure("To send operation identifiers, Apollo types must be generated with operationIdentifiers")
            }
            
            body["id"] = operationIdentifier
        }
        
        if sendQueryDocument {
            body["query"] = operation.queryDocument
        }
        
        // The data portion of the body needs to have the query and variables as well.
        guard let data = try? JSONSerialization.data(withJSONObject: ["query": operation.queryDocument,
                                                                      "variables": operation.variables ?? [:]], options: .prettyPrinted) else {
            fatalError("Somehow the query and variables aren't valid JSON!")
        }
        let jsonString = String(data: data, encoding: .utf8)
        body["data"] = jsonString
        
        if autoPersistQuery {
            guard let operationIdentifier = operation.operationIdentifier else {
                preconditionFailure("To enable `autoPersistQueries`, Apollo types must be generated with operationIdentifiers")
            }
            
            body["extensions"] = [
                "persistedQuery" : ["sha256Hash": operationIdentifier, "version": 1],
                "authorization": authorization
            ]
        } else {
            body["extensions"] = ["authorization": authorization]
        }
        
        return body
    }
}
1 Like

I am integrating with AppSync with API-key auth too. Following the steps resolved the log error like "unknown not supported through the real-time channel"or "Both, the header, and the payload query string parameters are missing".
But my subscription still did not work.

My experiment is below.
A: iOS client implemented by Apollo SDK.
B: iOS client implemented by Apollo SDK.
C: iOS client implemented by AppSync SDK.
D: a server that is implemented by AppSync.

The experimental results are here.
A: perform a mutation.
B: did not receive any subscription.
C: did receive the expected subscription.
D: Data in the Appsync console is edited as expected.

Do you have any thoughts or suggestions about the B not working?

My Network class is below.

let host = ""
let normalEndPoint = "https://{HOST}/graphql"
let realtimeEndPoint = "wss://{HOST}/graphql"
let authKey = "x-api-key"
let authValue = ""


class NetworkInterceptorProvider: DefaultInterceptorProvider {
    override func interceptors<Operation: GraphQLOperation>(for operation: Operation) -> [ApolloInterceptor] {
        var interceptors = super.interceptors(for: operation)
        interceptors.insert(TokenAddingInterceptor(), at: 0)
        return interceptors
    }
}

class TokenAddingInterceptor: ApolloInterceptor {
    func interceptAsync<Operation: GraphQLOperation>(
        chain: RequestChain,
        request: HTTPRequest<Operation>,
        response: HTTPResponse<Operation>?,
        completion: @escaping (Result<GraphQLResult<Operation.Data>, Error>) -> Void) {
            request.addHeader(name: authKey, value: authValue)
            chain.proceedAsync(request: request,
                               response: response,
                               completion: completion)
        
    }
}

// MARK: - Singleton Wrapper

class Network {
  static let shared = Network()

  /// A web socket transport to use for subscriptions
  // This web socket will have to provide the connecting payload which
  // initializes the connection as an authorized channel.
  private lazy var webSocketTransport: WebSocketTransport = {
      let authDict = [
        "x-api-key": authValue,
        "host": host
      ]
      
      let headerData: Data = try! JSONSerialization.data(withJSONObject: authDict, options: JSONSerialization.WritingOptions.prettyPrinted)
      let headerBase64 = headerData.base64EncodedString()
      
      let payloadData = try! JSONSerialization.data(withJSONObject: [:], options: JSONSerialization.WritingOptions.prettyPrinted)
      let payloadBase64 = payloadData.base64EncodedString()
      
      let authPayload: [String : JSONEncodable] = [
        "header": headerBase64,
        "payload": payloadBase64
      ]
      
      let url = URL(string: realtimeEndPoint + "?header=\(headerBase64)&payload=\(payloadBase64)")!
      let request = URLRequest(url: url)
      let webSocketClient = WebSocket(request: request, protocol: .graphql_ws)
      let requestBody = AppSyncRequestBodyCreator([authKey: authValue])
      let webSocketTransport = WebSocketTransport(websocket: webSocketClient, requestBodyCreator: requestBody)
      return webSocketTransport
  }()
  
  /// An HTTP transport to use for queries and mutations.
  private lazy var normalTransport: RequestChainNetworkTransport = {
      let url = URL(string: normalEndPoint)!
      let transport = RequestChainNetworkTransport(interceptorProvider: DefaultInterceptorProvider(store: store), endpointURL: url, additionalHeaders: [authKey: authValue])
      return transport
  }()

  /// A split network transport to allow the use of both of the above
  /// transports through a single `NetworkTransport` instance.
  private lazy var splitNetworkTransport = SplitNetworkTransport(
    uploadingNetworkTransport: self.normalTransport,
    webSocketNetworkTransport: self.webSocketTransport
  )

  /// Create a client using the `SplitNetworkTransport`.
  private(set) lazy var apollo = ApolloClient(networkTransport: self.splitNetworkTransport, store: self.store)

  /// A common store to use for `normalTransport` and `client`.
    private lazy var store:ApolloStore = {
        return ApolloStore()
    }()
}

/**
 Creates an AWS AppSync-compatible RequestBody for subscriptions.
 Credit to: https://community.apollographql.com/t/using-apollo-with-appsync-directly-on-ios/324/4
 */
public class AppSyncRequestBodyCreator : RequestBodyCreator {
    
    init(_ authorization:[String:String]) {
        self.authorization = authorization
    }
    
    // Don't forget to set these! This is required for the initial handshake.
    var authorization: [String : String]
    
    public func requestBody<Operation>(for operation: Operation, sendOperationIdentifiers: Bool, sendQueryDocument: Bool, autoPersistQuery: Bool) -> GraphQLMap where Operation : GraphQLOperation {
        
        var body: GraphQLMap = [
            "variables": operation.variables,
            "operationName": operation.operationName,
        ]
        
        if sendOperationIdentifiers {
            guard let operationIdentifier = operation.operationIdentifier else {
                preconditionFailure("To send operation identifiers, Apollo types must be generated with operationIdentifiers")
            }
            
            body["id"] = operationIdentifier
        }
        
        if sendQueryDocument {
            body["query"] = operation.queryDocument
        }
        
        // The data portion of the body needs to have the query and variables as well.
        guard let data = try? JSONSerialization.data(withJSONObject: ["query": operation.queryDocument,
                                                                      "variables": operation.variables ?? [:]], options: .prettyPrinted) else {
            fatalError("Somehow the query and variables aren't valid JSON!")
        }
        let jsonString = String(data: data, encoding: .utf8)
        body["data"] = jsonString
        
        if autoPersistQuery {
            guard let operationIdentifier = operation.operationIdentifier else {
                preconditionFailure("To enable `autoPersistQueries`, Apollo types must be generated with operationIdentifiers")
            }
            
            body["extensions"] = [
                "persistedQuery" : ["sha256Hash": operationIdentifier, "version": 1],
                "authorization": authorization
            ]
        } else {
            body["extensions"] = ["authorization": authorization]
        }
        
        return body
    }
}

I’d have to see the full example to understand why, but at a quick glance, your networking code looks ok. I do notice you are using lazy variables everywhere, which makes things really confusing. If this is your networking class, all of those variable declarations don’t need to happen lazily since they are needed at initialization. I’d clean that up into a more linear sequence of execution and I’d make sure you aren’t losing a reference to something somewhere. Make sure for your subscription you are doing it like [monolithic-adam] did on the first post on this thread and storing a reference to it at the class level, such that it can be canceled later. If that isn’t it, I’d look at your CloudWatch logs in AWS and see if your client is making the connection and whether your subscription is working from your endpoint.

Thanks for your reply. I’ve found the issue that is my requestBodyCreator’s payload’s format is not right for the Appsync server.
The Appsync server’s expected payload is below.

{
        "data":
        {
            "query": "query stirng",
            "variables": "variables"
        },
        "extensions":
        {
            "authorization":
            {
                "host": "host",
                "x-api-key": "apikey"
            }
        }
    }

After fixing that everything has been expected.

those variable declarations don’t need to happen lazily

I agree with these, and these codes just simply copy and edit from up, I’ll re-edit them.