Количество просмотров140
1 февраля 2022

GraphQL в мобильной разработке. Пишем клиент для iOS

В предыдущих частях мы говорили о подготовке облачного GraphQL бекенда на Hasura и подключении GraphQL API к Android клиенту. Теперь настал черед iOS мобильного приложения.

Для работы нам понадобится библиотека Apollo GraphQL для iOS:

www.apollographql.com/docs/ios

github.com/apollographql/apollo-ios

Наше приложение абсолютно аналогичное Android и включает в себя такие же по функционалу экраны:

— вход

— регистрация

— лента постов

— экран создания и редактирования поста

— экран с информацией о текущем пользователе.

Начнем с установки Apollo GraphQL. Данная библиотека доступна для установки через SPM с github github.com/apollographql/apollo-ios

Достаточно будет выбрать Apollo и ApolloWebSocket библиотеки:

Последняя актуальная версия пакетов 0.49.1:

Также нам понадобится выкачать схему. Для начала поставим через npm apollo-codegen:

npm i apollo-codegen

Теперь запустим выгрузку, указав путь к API, путь для итогового файла и дополнительные заголовки:

apollo-codegen download-schema "<host>.hasura.app/v1/graphql" 
--output schema.json --header "x-hasura-admin-secret: <key>"

Полученный schema.json добавим на верхний уровень нашего проекта:

Теперь нам нужно добавить конфигурацию. Идем в настройки нашего проекта на вкладку Build Phase и добавляем скрипт для исполнения через New Run Script Phase:

Назовем эту фазу, например, CLI и перетащим на 2ю строку, чтобы она шла сразу после зависимостей.

Заменим текстовую заглушку следующим контентом:

# Go to the build root and search up the chain to find the Derived Data Path where the source packages are checked out.
DERIVED_DATA_CANDIDATE="${BUILD_ROOT}"

while ! [ -d "${DERIVED_DATA_CANDIDATE}/SourcePackages" ]; do
  if [ "${DERIVED_DATA_CANDIDATE}" = / ]; then
    echo >&2 "error: Unable to locate SourcePackages directory from BUILD_ROOT: '${BUILD_ROOT}'"
    exit 1
  fi

  DERIVED_DATA_CANDIDATE="$(dirname "${DERIVED_DATA_CANDIDATE}")"
done

# Grab a reference to the directory where scripts are checked out
SCRIPT_PATH="${DERIVED_DATA_CANDIDATE}/SourcePackages/checkouts/apollo-ios/scripts"

if [ -z "${SCRIPT_PATH}" ]; then
    echo >&2 "error: Couldn't find the CLI script in your checked out SPM packages; make sure to add the framework to your project."
    exit 1
fi

cd "${SRCROOT}/${TARGET_NAME}"
"${SCRIPT_PATH}"/run-bundled-codegen.sh codegen:generate 
--target=swift --includes=./**/*.graphql --localSchemaFile="schema.json" API.swift

В скрипте указываем путь к схеме и файлу API, который будет генерироваться на ее основе.

Попробуем сбилдить наш проект. Первая сборка выдаст нам ошибку компиляции. Это потому, что мы не добавили скрипты запросов graphql для генерации нашего API.

Добавим файл с расширением .graphql на тот же уровень, где у нас лежит схема. После компиляции мы получим файл API.swift, который нужно подключить на тот же уровень, где у нас лежат запросы.

Рассмотрим код наших query и mutation:

query PostsQuery {
    posts {
            post_id, post_text, user_id, user_name, likes, date, image_link
    }
}

mutation AddPostMutation($postId: uuid, $text: String, $image: String, $user: String, $userId: uuid, $date: date) {
    insert_posts_one(object: {date: $date, image_link: $image, post_id: $postId, post_text: $text, user_id: $userId, user_name: $user}) {
       ... Post
    }
}

mutation DeletePost($postId: uuid!) {
    delete_posts_by_pk(post_id: $postId){
        post_id
    }
    delete_comments(where: {post_id: {_eq: $postId}}) {
        returning {
            comment_id
        }
    }
}

query Users {
    users {
       user_email, user_id, user_name
    }
}

query GetPostQuery($postId: uuid) {
    posts(where: {post_id: {_eq: $postId}}) {
           post_id, post_text, user_id, user_name, likes, date, image_link
    }
    likes(where: {post_id: {_eq: $postId}}){
            post_id, user_id
    }
    comments(where: {post_id: {_eq: $postId}}){
            comment_id, comment_text, user_id, post_id, user_name
    }
}

mutation CreateUserMutation($name: String, $id: uuid, $email: String, $password: String) {
    insert_users_one(object: {user_email: $email, user_id: $id, user_name: $name, password: $password}) {
        ... User
    }
}

query GetUser($email: String, $password: String) {
    users(where: {password: {_eq: $password}, user_email: {_eq: $email}}) {
           user_email, user_id, user_name
    }
}

query Likes($postId: uuid) {
    likes(where: {post_id: {_eq: $postId}}){
           post_id, user_id
    }
}

query Comments($commentId: uuid) {
    comments(where: {post_id: {_eq: $commentId}}) {
            comment_id, comment_text, user_id, post_id, user_name
    }
}

mutation  ChangeLikeMutation($postId: uuid, $likes: String) {
    update_posts(where: {post_id: {_eq: $postId}} _set: {likes: $likes}) {
        __typename
    }
}

mutation  ChangePostMutation($postId: uuid!, $postText: String, $imageLink: String) {
    update_posts(where: {post_id: {_eq: $postId}} _set: {post_text: $postText, image_link: $imageLink}) {
        __typename
    }
}

mutation CreateComment($postId: uuid, $commentText: String, $id: uuid, $userId: uuid, $userName: String) {
    insert_comments_one(object: {post_id: $postId, comment_text: $commentText, comment_id: $id, user_id: $userId, user_name: $userName }) {
        ... Comment
    }
}


fragment User on users {
    user_email, user_id, user_name, likes
}

fragment Comment on comments {
    comment_id, comment_text, user_id, post_id, user_name
}

fragment Post on posts {
    post_id, post_text, user_id, user_name, likes, date, image_link
}

fragment LikeForPost on likes {
    post_id, user_id
}

В принципе наши запросы аналогичны тем, что мы использовали для Android приложения. За исключением того, что для Query мы не используем фрагменты. Только для Mutation.
Попробуем сделать тестовый query с фрагментом в ответе:

query GetUserFr($email: String, $password: String) {
    users(where: {password: {_eq: $password}, user_email: {_eq: $email}}) {
                   ... User
    }
}

Но при компиляции такого запроса мы получим ошибку:

Так же, как в android приложении, у каждого Query и Mutation свой тип из комбинации вложенных классов. Тип, используемый для фрагмента, мы можем безопасно использовать только для Mutation. Фрагмент оперирует не вложенным типом, поэтому для всех Mutation, которые его используют в graphql, это будет считаться одним и тем же типом.

Это автогенерируемый код, поэтому наши исправления здесь бесполезны.
Будем использовать в коде Query явное указание нужных нам полей:

query GetUser($email: String, $password: String) {
    users(where: {password: {_eq: $password}, user_email: {_eq: $email}}) {
           user_email, user_id, user_name
    }
}

Теперь все компилируется.

В отличие от Android все наши типы и поля для работы с GraphQL с маппингом запишутся в один единственный файл:


// Примерное содержание API.swift

import Apollo
import Foundation

public final class PostsQueryQuery: GraphQLQuery {
  /// The raw GraphQL definition of this operation.
  public let operationDefinition: String =
    """
    query PostsQuery {
      posts {
        __typename
        post_id
        post_text
        user_id
        user_name
        likes
        date
        image_link
      }
    }
    """

  public let operationName: String = "PostsQuery"

  public init() {
  }

  public struct Data: GraphQLSelectionSet {
    public static let possibleTypes: [String] = ["query_root"]

    public static var selections: [GraphQLSelection] {
      return [
        GraphQLField("posts", type: .nonNull(.list(.nonNull(.object(Post.selections))))),
      ]
    }

    public private(set) var resultMap: ResultMap

    public init(unsafeResultMap: ResultMap) {
      self.resultMap = unsafeResultMap
    }

    public init(posts: [Post]) {
      self.init(unsafeResultMap: ["__typename": "query_root", "posts": posts.map { (value: Post) -> ResultMap in value.resultMap }])
    }

    /// fetch data from the table: "posts"
    public var posts: [Post] {
      get {
        return (resultMap["posts"] as! [ResultMap]).map { (value: ResultMap) -> Post in Post(unsafeResultMap: value) }
      }
      set {
        resultMap.updateValue(newValue.map { (value: Post) -> ResultMap in value.resultMap }, forKey: "posts")
      }
    }

    public struct Post: GraphQLSelectionSet {
      public static let possibleTypes: [String] = ["posts"]

      public static var selections: [GraphQLSelection] {
        return [
          GraphQLField("__typename", type: .nonNull(.scalar(String.self))),
          GraphQLField("post_id", type: .nonNull(.scalar(String.self))),
          GraphQLField("post_text", type: .scalar(String.self)),
          GraphQLField("user_id", type: .scalar(String.self)),
          GraphQLField("user_name", type: .scalar(String.self)),
          GraphQLField("likes", type: .scalar(String.self)),
          GraphQLField("date", type: .scalar(String.self)),
          GraphQLField("image_link", type: .scalar(String.self)),
        ]
      }

      public private(set) var resultMap: ResultMap

      public init(unsafeResultMap: ResultMap) {
        self.resultMap = unsafeResultMap
      }

      public init(postId: String, postText: String? = nil, userId: String? = nil, userName: String? = nil, likes: String? = nil, date: String? = nil, imageLink: String? = nil) {
        self.init(unsafeResultMap: ["__typename": "posts", "post_id": postId, "post_text": postText, "user_id": userId, "user_name": userName, "likes": likes, "date": date, "image_link": imageLink])
      }

Обратите внимание, что при именовании Query и Mutation соответствующие постфиксы добавляются автоматически.

Теперь займемся маппингом полученных типов в наши используемые структуры данных. Для каждого из типов добавим инициализаторы, куда будем передавать параметры типов Query. Для всех Mutation, использующий определенный фрагмент, можно просто передавать один тип.

struct PostItem : Codable, Equatable  {
//. . .
    init(data: Post) {
        self.uuid = data.postId
        self.postText = data.postText ?? ""
        self.id = data.postId
        self.imageLink = data.imageLink ?? ""
        self.userId = data.userId ?? ""
        self.userName = data.userName ?? ""
        self.likeItems = data.likes?.split(separator: ",").map{LikeItem(userId: String($0), postId: data.postId)} ?? [LikeItem]()
        self.dateString = data.date
    }
    
    init(data: PostsQueryQuery.Data.Post) {
        self.postText = data.postText ?? ""
        self.id = data.postId
        self.uuid = data.postId
        self.imageLink = data.imageLink ?? ""
        self.userId = data.userId ?? ""
        self.userName = data.userName ?? ""
        self.likeItems = data.likes?.split(separator: ",").map{LikeItem(userId: String($0), postId: data.postId)} ?? [LikeItem]()
        self.dateString = data.date
    }
    
    init(data: GetPostQueryQuery.Data.Post) {
        self.postText = data.postText ?? ""
        self.id = data.postId
        self.uuid = data.postId
        self.imageLink = data.imageLink ?? ""
        self.userId = data.userId ?? ""
        self.userName = data.userName ?? ""
        self.likeItems = data.likes?.split(separator: ",").map{LikeItem(userId: String($0), postId: data.postId)} ?? [LikeItem]()
        self.dateString = data.date
    }
}

struct UserData : Codable {
  //. . . 
    init(user: User) {
        self.uid = user.userId
        self.name = user.userName
        self.email = user.userEmail
    }
    
    init(user: GetUserQuery.Data.User) {
        self.uid = user.userId
        self.name = user.userName
        self.email = user.userEmail
    }
}
    
struct CommentItem : Codable {
//. . .
    
   init(comment: Comment) {
        self.userId = comment.userId
        self.postId = comment.postId
        self.userName = comment.userName ?? ""
        self.text = comment.commentText
        self.uuid = comment.commentId
    }
    
    init(comment: CommentsQuery.Data.Comment) {
         self.userId = comment.userId
         self.postId = comment.postId
         self.userName = comment.userName ?? ""
         self.text = comment.commentText
         self.uuid = comment.commentId
     }
    
}

Можно переходить к созданию адаптера для наших типов запросов.

class QueryAdapter {
    static let shared = QueryAdapter()
    func loginUserQuery(email: String, password: String)-> GetUserQuery {
        return GetUserQuery(email: email, password: password)
    }
    
    func loginUserQuery(userData: UserData)-> GetUserQuery {
        return GetUserQuery(email: userData.email, password: userData.password)
    }
    
    func createUser(userData: UserData)->CreateUserMutationMutation {
        return CreateUserMutationMutation(name: userData.name,id: UUID().uuidString, email: userData.email, password: userData.password)
    }
    
    
    func createPost(postItem: PostItem)->AddPostMutationMutation {
        return AddPostMutationMutation(
            postId: UUID().uuidString,
            text: postItem.postText,
            image: postItem.imageLink , user: postItem.userName,
            userId: postItem.userId,
            date: "\(Date())")
    }
    
    func changeLike(postItem: PostItem)->ChangeLikeMutationMutation {
        let likes = postItem.likeItems.map{$0.userId}.joined(separator: ",")
        return  ChangeLikeMutationMutation(postId: postItem.uuid, likes: likes)
    }
    
    func changePost(postItem: PostItem)->ChangePostMutationMutation {
        return ChangePostMutationMutation(postId: postItem.uuid, postText: postItem.postText,imageLink: postItem.imageLink)
    }
    func deletePost(postItem: PostItem)-> DeletePostMutation {
        return DeletePostMutation(postId: postItem.uuid)
    }
    
    func deletePostBy(postId: String)-> DeletePostMutation {
        return DeletePostMutation(postId: postId)
    }
    
    func createComment(commentItem: CommentItem)->CreateCommentMutation {
        return CreateCommentMutation(postId: commentItem.postId, commentText: commentItem.text, id: UUID().uuidString, userId: commentItem.userId,userName: commentItem.userName)
    }
}

Теперь с помощью расширения типов добавим удобный вызов данных методов адаптера:

extension PostItem {
    func createPost()->AddPostMutationMutation {
        return QueryAdapter.shared.createPost(postItem: self)
    }
    
    func changeLike()->ChangeLikeMutationMutation {
        return QueryAdapter.shared.changeLike(postItem: self)
    }
    
    func changePost()->ChangePostMutationMutation {
        return QueryAdapter.shared.changePost(postItem: self)
    }
    func deletePost()-> DeletePostMutation {
        return QueryAdapter.shared.deletePost(postItem: self)
    }
    
    func deletePostById()-> DeletePostMutation {
        return QueryAdapter.shared.deletePostBy(postId: self.uuid ?? "")
    }
}


extension UserData {
    func createUser(userData: UserData)->CreateUserMutationMutation {
        return QueryAdapter.shared.createUser(userData: self)
    }
}

extension CommentItem {
    func createComment()->CreateCommentMutation {
        return QueryAdapter.shared.createComment(commentItem: self)
    }
}

Кажется, мы кое-что забыли. А именно сетевой клиент для запросов.
Согласно документации, для обращений к API без каких-либо заголовок и настроек нам было бы достаточно такого кода:

import Foundation
import Apollo

class Network {
  static let shared = Network() 
    
  private(set) lazy var apollo = ApolloClient(url: URL(string: "http://host/graphql")!)
}

Но у нас обязательно должен быть заголовок с ключом доступа для Hasura.

Для осуществления запросов с заголовками авторизации наш клиент будет иметь такой вид:

struct NetworkInterceptorProvider: InterceptorProvider {
    
    // These properties will remain the same throughout the life of the `InterceptorProvider`, even though they
    // will be handed to different interceptors.
    private let store: ApolloStore
    private let client: URLSessionClient
    
    init(store: ApolloStore,
         client: URLSessionClient) {
        self.store = store
        self.client = client
    }
    
    func interceptors<Operation: GraphQLOperation>(for operation: Operation) -> [ApolloInterceptor] {
        return [
            MaxRetryInterceptor(),
            CacheReadInterceptor(store: self.store),
            UserManagementInterceptor(),
            RequestLoggingInterceptor(),
            NetworkFetchInterceptor(client: self.client),
            ResponseLoggingInterceptor(),
            ResponseCodeInterceptor(),
            JSONResponseParsingInterceptor(cacheKeyForObject: self.store.cacheKeyForObject),
            AutomaticPersistedQueryInterceptor(),
            CacheWriteInterceptor(store: self.store)
        ]
    }
}

class HasuraClient {
  static let shared = HasuraClient()
  
  private(set) lazy var apollo: ApolloClient = {
      // The cache is necessary to set up the store, which we're going to hand to the provider
      let cache = InMemoryNormalizedCache()
      let store = ApolloStore(cache: cache)
      
      let client = URLSessionClient()
      let provider = NetworkInterceptorProvider(store: store, client: client)
      let url = URL(string: "https://host.hasura.app/v1/graphql")!
    let headers =  ["x-hasura-admin-secret": "your key"]

      let requestChainTransport = RequestChainNetworkTransport(interceptorProvider: provider,
                                                               endpointURL: url, additionalHeaders: headers)
                                                               

      // Remember to give the store you already created to the client so it
      // doesn't create one on its own
      return ApolloClient(networkTransport: requestChainTransport,
                          store: store)
  }()
}

Да, нам потребуется по умолчанию очень много интерсептеров, без которых наш код работать не будет. Это сложнее, чем в Android, поэтому оптимальным будет скопировать код интерсептеров из документации, немного адаптировав под нашу задачу:

import Apollo

class UserManagementInterceptor: ApolloInterceptor {
    
    enum UserError: Error {
        case noUserLoggedIn
    }
    
    private let headers = ["x-hasura-admin-secret": "your key"]
    
    /// Helper function to add the token then move on to the next step
    private func addTokenAndProceed<Operation: GraphQLOperation>(
        to request: HTTPRequest<Operation>,
        chain: RequestChain,
        response: HTTPResponse<Operation>?,
        completion: @escaping (Result<GraphQLResult<Operation.Data>, Error>) -> Void) {
            for header in headers {
                request.addHeader(name: header.key, value: header.value)
            }
        chain.proceedAsync(request: request,
                           response: response,
                           completion: completion)
    }
    
    func interceptAsync<Operation: GraphQLOperation>(
        chain: RequestChain,
        request: HTTPRequest<Operation>,
        response: HTTPResponse<Operation>?,
        completion: @escaping (Result<GraphQLResult<Operation.Data>, Error>) -> Void) {
       
            self.addTokenAndProceed(
                                    to: request,
                                    chain: chain,
                                    response: response,
                                    completion: completion)
    }
}

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

class ResponseLoggingInterceptor: ApolloInterceptor {
    
    enum ResponseLoggingError: Error {
        case notYetReceived
    }
    
    func interceptAsync<Operation: GraphQLOperation>(
        chain: RequestChain,
        request: HTTPRequest<Operation>,
        response: HTTPResponse<Operation>?,
        completion: @escaping (Result<GraphQLResult<Operation.Data>, Error>) -> Void) {
        
        defer {
            // Even if we can't log, we still want to keep going.
            chain.proceedAsync(request: request,
                               response: response,
                               completion: completion)
        }
        
        guard let receivedResponse = response else {
            chain.handleErrorAsync(ResponseLoggingError.notYetReceived,
                                   request: request,
                                   response: response,
                                   completion: completion)
            return
        }
      
    }
}

Подключаем их в нужное место и переходим к работе над запросами.
Напоминаем, что UI и остальная логика у нас готова.


class AuthHelper {
    static let shared = AuthHelper()
    private let apollo = HasuraClient.shared.apollo
    private weak var userStorage = DI.dataContainer.userStorage
    
    var currentUser: UserData? = nil
    
    //MARK: check authorization
    func checkAuth()->Bool {
        return currentUser != nil || userStorage?.getUser() != nil
    }
    
    func isAuthorized()->Bool {
        //Load session and check with saved
        return currentUser != nil || userStorage?.getUser() != nil
    }
    
    //MARK: Login
    func login(email: String, password: String, completion: @escaping(Result<UserData,Error>)->Void) {
        apollo.fetch(query:  QueryAdapter.shared.loginUserQuery(email: email, password: password)) { (result:Result<GraphQLResult<GetUserQuery.Data>,Error>) in
            switch result {
            case .failure(let error):
                print(error)
            case .success(let userData):
                if let user = userData.data?.users.first {
                    let u = UserData(user: user)
                    self.userStorage?.saveUser(data: u)
                    completion(.success(u))
                   
                }
            }
        }
    }
    
    //MARK: registation
    func register(name: String, email: String, password: String, completion: @escaping(Result<UserData,Error>)->Void) {
        let query = QueryAdapter.shared.createUser(userData: UserData(uid: UUID().uuidString, name: name, email: email, password: password))
        apollo.perform(mutation: query){
            (result: Result<GraphQLResult<CreateUserMutationMutation.Data>,Error>) in
            switch result {
            case .failure(let error):
                print(error)
                completion(.failure( error))
            case .success(let data):
                if let user = data.data?.insertUsersOne?.fragments.user {
                    self.currentUser = UserData(user: user)
                    self.userStorage?.saveUser(data: self.currentUser!)
                    completion(.success(self.currentUser!))
                }
            }
        }
    }
}

Это код, который нам нужен для входа и регистрации.
Мы обращаемся к нашему apollo ApolooClient и с помощью команды fetch для query или perform для Mutation обращаемся к API, передавая в качестве параметра соответствующий тип запроса или изменения, полученный из нашего адаптера.
В качестве результата нам приходит Result<T,Error>, где T – это GraphQLResult<K.Data> с данными Data класса, вложенного в наш Mutation или Query. Внутри Data будет либо фрагмент для Mutation, либо набор полей нашего Query.

Теперь пропишем наши запросы на работу с постами, лайками и комментариями:

class PostService {
    private let apollo = HasuraClient.shared.apollo
    static let shared = PostService()
    private weak var userStorage = DI.dataContainer.userStorage
    var currentPosts: [PostItem] = [PostItem]()
    
    //MARK: post
    func publishPost(item: PostItem, completion: @escaping(Result<Bool, Error>)->Void) {
        guard let user = userStorage?.getUser() else {
            return
        }
        var postItem = item
        postItem.userId = user.uid
        postItem.userName = user.name
        apollo.perform(mutation: QueryAdapter.shared.createPost(postItem: postItem)) {
            (result: Result<GraphQLResult<AddPostMutationMutation.Data>,Error>) in
            switch result {
            case .failure(let error):
                completion(.failure(error))
            case .success(_):
                completion(.success(true))
            }
        }
    }

    func updatePost(item: PostItem, completion: @escaping(Result<Bool, Error>)->Void) {
        guard let user = userStorage?.getUser(), item.userId == user.uid else {
            return
        }
        
        let mutation = QueryAdapter.shared.changePost(postItem: item)
        apollo.perform(mutation: mutation) {
            (result: Result<GraphQLResult<ChangePostMutationMutation.Data>,Error>) in
            switch result {
            case .failure(let error):
                completion(.failure(error))
            case .success(_):
                completion(.success(true))
            }
        }
    }

    func deletePost(postId: String,completion: @escaping(Result<Bool, Error>)->Void) {
        let mutation = QueryAdapter.shared.deletePostBy(postId: postId)
        apollo.perform(mutation: mutation) {
            (result: Result<GraphQLResult<DeletePostMutation.Data>,Error>) in
            switch result {
            case .failure(let error):
                completion(.failure(error))
            case .success(_):
                completion(.success(true))
            }
        }
    }
    
    //MARK: posts
    func loadPosts(completion: @escaping([PostItem])->Void) {
        apollo.fetch(query: PostsQueryQuery()){ [weak self]
            (result:Result<GraphQLResult<PostsQueryQuery.Data>,Error>) in
            guard let self = self else {return}
            switch result {
            case .failure(let error):
               print(error)
            case .success(let data):
                let posts =   data.data?.posts.map{PostItem(data: $0)} ?? [PostItem]()
                completion(self.checkLiked(posts: posts))
            }
        }
    }

    private func checkLiked(posts: [PostItem])->[PostItem] {
        var tempPosts = posts
        guard let userId = userStorage?.getUser()?.uid.lowercased() else {
            return posts
        }
        for i in 0..<tempPosts.count {
            let postLikes  = tempPosts[i].likeItems
            tempPosts[i].isLiked = (postLikes .filter{$0.userId.lowercased() == userId}).count > 0
        }
        return tempPosts
    }

    
    
    func changeLike(postItem: PostItem, completion: @escaping(Result<[PostItem],Error>)->Void) {
        guard let userId  = userStorage?.getUser()?.uid else {
            return
        }
        var likeItems = postItem.likeItems
            if let found = likeItems.filter({$0.userId == userId}).first {
                likeItems.remove(item: found)
            } else {
            likeItems.append(LikeItem(userId: userId, postId: postItem.uuid))
        }
        let mutation = ChangeLikeMutationMutation(postId: postItem.uuid.lowercased(), likes: likeItems.map{$0.userId}.joined(separator: ","))
        apollo.perform(mutation: mutation) {
            (result: Result< GraphQLResult<ChangeLikeMutationMutation.Data>,Error>) in
            switch result {
            case .failure(let error):
                completion(.failure(error))
            case .success(_):
                self.loadPosts { items in
                    completion(.success(items))
                }
            }
        }
    }
    
    //MARK: comments
    func publishComment(item: CommentItem, completion: @escaping(Result<Bool, Error>)->Void) {
        var comment = item
        guard let user = userStorage?.getUser() else {
            return
        }
        comment.userId = user.uid
        comment.userName = user.name
        let mutation = item.createComment()
        apollo.perform(mutation: mutation) {
            (result: Result<GraphQLResult<CreateCommentMutation.Data>,Error>) in
            switch result {
            case .failure(let error):
                completion(.failure(error))
            case .success(_):
                completion(.success(true))
                
            }
        }
    }
    
    func loadComments(postId: String, completion: @escaping(Result<[CommentItem], Error>)->Void) {
        let query = CommentsQuery(commentId: postId)
        apollo.fetch(query: query) {
            (result: Result<GraphQLResult<CommentsQuery.Data>,Error>) in
            switch result {
            case .failure(let error):
                print(error)
            case .success(let data):
                let comments = data.data?.comments.map{
                    CommentItem(comment: $0)
                } ?? [CommentItem]()
                completion(.success(comments))
            }
        }
    }
}

Получаем готовое приложение:
github.com/anioutkazharkova/graphql_ios_postoram

Подведем итог.

Итак, за 3 статьи мы с вами рассмотрели, как можно сделать небольшое и несложное мобильное приложение с собственным бекендом на GraphQL Hasura. Некоторые моменты работы с данной технологией весьма спорны. Если отвлечься от особенностей реализаций решений Apollo и Hasura, на мой взгляд, самый проблемный момент – это вынужденное и весьма избыточное дублирование типов из-за вложенности в Query или Mutation. В остальном, что выбрать Rest или GraphQL – уже дело вкуса и предпочтений.

Bonus.
Мы с вами использовали Apollo для совершения запросов к нашему API GraphQL У решения есть свои недостатки. Для iOS даже нет еще версии 1.0, и версионность не особенно стабильна.
Мы можем это делать с помощью обычных сетевых запросов. Для этого нам потребуется отправить наш запрос в качестве json body POST. Cделаем специальную структуру Payload для корректного кодирование body.

Например, чтобы получить список постов:

 let query = """
query Posts {
  posts {
       user_name
      user_id
      post_text
      post_id
      post_date
      likes
      image_link
  }
}
"""


class NetworkClient {
    lazy var urlSession: URLSession? = {
        return URLSession(configuration: URLSessionConfiguration.default)
    }()
    
    var urlSessionDataTask: URLSessionDataTask? = nil
    
    struct Payload: Encodable {
        let query: String
    }
    
    private let headers = ["x-hasura-admin-secret": "your key"]
    
    func doQuery() {
        guard let url = URL(string: "https://host.hasura.app/v1/graphql") else {
            return
        }
        var urlRequest = URLRequest(url: url)
        for header in headers {
            urlRequest.addValue(header.value, forHTTPHeaderField: header.key)
        }
        urlRequest.httpMethod = "POST"
        urlRequest.httpBody = try? JSONEncoder().encode(Payload(query: query))
        let task = self.urlSession?.dataTask(with: urlRequest, completionHandler: { data, response, error in
            if let data = data {
                let json = String(data: data, encoding: .utf8)
                print(json) //Результат получили сюда
            }
        })
        task?.resume()
    }
}

Результат нам вернет json c корневым элементом data:

"{
"data": {
"posts": [
{
"user_name": "...",
"user_id": "...",
"post_text": "...",
"post_id": "...",
//...
}
]
}
}"

Можно написать собственный парсер и использовать его без Apollo.
Дерзайте)

www.apollographql.com/docs/ios
github.com/apollographql/apollo-ios
github.com/anioutkazharkova/graphql_ios_postoram