Количество просмотров202
18 января 2022

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

В этой статье мы перейдем к подключению GraphQL и API к нашему приложению. И начнем мы с Android клиента.

github.com/apollographql/apollo-android
www.apollographql.com/docs/android

Наше приложение состоит из нескольких экранов:

  • вход
  • регистрация
  • лента постов
  • экран создания и редактирования поста
  • экран с информацией о текущем пользователе.

Экраны и сопутствующий код уже у нас есть, осталось подключить API.
Изображения мы храним в Firebase Storage, поэтому не затрагиваем этот вопрос.

Для работы нам понадобится добавить библиотеки apollo для клиента GraphQL под android, которые мы добавим в наш build.gradle:

implementation(«com.apollographql.apollo:apollo-runtime:2.5.9»)
implementation(«com.apollographql.apollo:apollo-coroutines-support:2.5.9»)

Обязательно поставьте библиотеку для работы с корутинами, чтобы облегчить себе задачу для работы с запросами.

Если вы еще не выкачали схему, то обязательно сделайте это сейчас. Можно это сделать так, как было описано в предыдущей части:

npm i apollo-codegen


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

Либо используйте gradle:

./gradlew downloadApolloSchema \
  --endpoint="<host>.hasura.app/v1/graphql" \
  --schema="./app/src/main/graphql/com/ex2/hasura/gql/schema.json" \
  --header="x-hasura-admin-secret: <key>"

Также нам потребуется добавить специальный конфиг по пути main/graphql/com/ex2/hasura/gql c именем .graphqlconfig:

{
  "name": "Expenses Schema",
  "schemaPath": "schema.json",
  "extensions": {
    "endpoints": {
      "Default GraphQL Endpoint": {
        "url": "<host>/v1/graphql",
        "headers": {
          "user-agent": "JS GraphQL",
          "x-hasura-admin-secret" : "<key>"
        },
        "introspect": false
      }
    }
  }
}

В конфиге указываем путь к нашему API и ключ заголовка авторизации.

В этот же каталог положим нашу schema.json.

Все наши запросы, которые мы подготовили в предыдущей части, скопируем в файл с расширением .graphql:


query PostsQuery {
    posts {
        ... Post
    }
}

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
    }
}

query GetPostQuery($postId: uuid) {
    posts(where: {post_id: {_eq: $postId}}) {
        ... Post
    }
    likes(where: {post_id: {_eq: $postId}}){
        ... LikeForPost
    }
    comments(where: {post_id: {_eq: $postId}}){
        ... Comment
    }
}

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 User($email: String, $password: String) {
    users(where: {password: {_eq: $password}, user_email: {_eq: $email}}) {
       ... User
    }
}

query Likes($postId: uuid) {
    likes(where: {post_id: {_eq: $postId}}){
        ... LikeForPost
    }
}

query Comments($commentId: uuid) {
    comments(where: {post_id: {_eq: $commentId}}) {
        ... Comment
    }
}

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
}

Обратите внимание, что у nullable полей мы используем! для указания опциональности. Также мы добавили фрагменты в выдачу query и mutation.

После запуска build для типов фрагментов у нас сгенерируются специальные маппинги примерно следующего содержания:

//Пример класса фрагмента для маппинга комментариев
@Suppress("NAME_SHADOWING", "UNUSED_ANONYMOUS_PARAMETER", "LocalVariableName",
    "RemoveExplicitTypeArguments", "NestedLambdaShadowedImplicitParameter")
data class Comment(
  val __typename: String = "comments",
  val comment_id: Any,
  val comment_text: String,
  val user_id: Any,
  val post_id: Any,
  val user_name: String?
) : GraphqlFragment {
  override fun marshaller(): ResponseFieldMarshaller = ResponseFieldMarshaller.invoke { writer ->
    writer.writeString(RESPONSE_FIELDS[0], this@Comment.__typename)
    writer.writeCustom(RESPONSE_FIELDS[1] as ResponseField.CustomTypeField, this@Comment.comment_id)
    writer.writeString(RESPONSE_FIELDS[2], this@Comment.comment_text)
    writer.writeCustom(RESPONSE_FIELDS[3] as ResponseField.CustomTypeField, this@Comment.user_id)
    writer.writeCustom(RESPONSE_FIELDS[4] as ResponseField.CustomTypeField, this@Comment.post_id)
    writer.writeString(RESPONSE_FIELDS[5], this@Comment.user_name)
  }

  companion object {
    private val RESPONSE_FIELDS: Array<ResponseField> = arrayOf(
        ResponseField.forString("__typename", "__typename", null, false, null),
        ResponseField.forCustomType("comment_id", "comment_id", null, false, CustomType.UUID, null),
        ResponseField.forString("comment_text", "comment_text", null, false, null),
        ResponseField.forCustomType("user_id", "user_id", null, false, CustomType.UUID, null),
        ResponseField.forCustomType("post_id", "post_id", null, false, CustomType.UUID, null),
        ResponseField.forString("user_name", "user_name", null, true, null)
        )

    val FRAGMENT_DEFINITION: String = """
        |fragment Comment on comments {
        |  __typename
        |  comment_id
        |  comment_text
        |  user_id
        |  post_id
        |  user_name
        |}
        """.trimMargin()

    operator fun invoke(reader: ResponseReader): Comment = reader.run {
      val __typename = readString(RESPONSE_FIELDS[0])!!
      val comment_id = readCustomType<Any>(RESPONSE_FIELDS[1] as ResponseField.CustomTypeField)!!
      val comment_text = readString(RESPONSE_FIELDS[2])!!
      val user_id = readCustomType<Any>(RESPONSE_FIELDS[3] as ResponseField.CustomTypeField)!!
      val post_id = readCustomType<Any>(RESPONSE_FIELDS[4] as ResponseField.CustomTypeField)!!
      val user_name = readString(RESPONSE_FIELDS[5])
      Comment(
        __typename = __typename,
        comment_id = comment_id,
        comment_text = comment_text,
        user_id = user_id,
        post_id = post_id,
        user_name = user_name
      )
    }

    @Suppress("FunctionName")
    fun Mapper(): ResponseFieldMapper<Comment> = ResponseFieldMapper { invoke(it) }
  }
}

Также сгенерируется специальный enum для сопоставления типов:

enum class CustomType : ScalarType {
  ID {
    override fun typeName(): String = "ID"

    override fun className(): String = "kotlin.String"
  },

  _UUID {
    override fun typeName(): String = "_uuid"

    override fun className(): String = "kotlin.Any"
  },

  DATE {
    override fun typeName(): String = "date"

    override fun className(): String = "kotlin.Any"
  },

  UUID {
    override fun typeName(): String = "uuid"

    override fun className(): String = "kotlin.Any"
  }
}

Для каждого из запросов сгенерируется свой файл с мапингом полей для запроса и ответа. Каждый наш запрос преобразовался в свой тип данных с вложенными классами:

@Suppress("NAME_SHADOWING", "UNUSED_ANONYMOUS_PARAMETER", "LocalVariableName",
    "RemoveExplicitTypeArguments", "NestedLambdaShadowedImplicitParameter")
class PostsQuery : Query<PostsQuery.Data, PostsQuery.Data, Operation.Variables> {
  override fun operationId(): String = OPERATION_ID
  override fun queryDocument(): String = QUERY_DOCUMENT
  override fun wrapData(data: Data?): Data? = data
  override fun variables(): Operation.Variables = Operation.EMPTY_VARIABLES
  override fun name(): OperationName = OPERATION_NAME
  override fun responseFieldMapper(): ResponseFieldMapper<Data> = ResponseFieldMapper.invoke {
    Data(it)
  }

  @Throws(IOException::class)
  override fun parse(source: BufferedSource, scalarTypeAdapters: ScalarTypeAdapters): Response<Data>
      = SimpleOperationResponseParser.parse(source, this, scalarTypeAdapters)

  @Throws(IOException::class)
  override fun parse(byteString: ByteString, scalarTypeAdapters: ScalarTypeAdapters): Response<Data>
      = parse(Buffer().write(byteString), scalarTypeAdapters)

  @Throws(IOException::class)
  override fun parse(source: BufferedSource): Response<Data> = parse(source, DEFAULT)

  @Throws(IOException::class)
  override fun parse(byteString: ByteString): Response<Data> = parse(byteString, DEFAULT)

  override fun composeRequestBody(scalarTypeAdapters: ScalarTypeAdapters): ByteString =
      OperationRequestBodyComposer.compose(
    operation = this,
    autoPersistQueries = false,
    withQueryDocument = true,
    scalarTypeAdapters = scalarTypeAdapters
  )

  override fun composeRequestBody(): ByteString = OperationRequestBodyComposer.compose(
    operation = this,
    autoPersistQueries = false,
    withQueryDocument = true,
    scalarTypeAdapters = DEFAULT
  )


//….
}

Теперь перейдем к основному – созданию сетевого клиента. Будем использовать ApolloClient. В качестве механизма работы с сетью он использует okHttpClient.

Также нам потребуется создать свой Interceptor для авторизации, через который будем передавать заголовок. Обратите внимание, не все версии клиента позволяют настроить авторизацию таким образом.

class HerasuClient {
     val apolloClient: ApolloClient by lazy {
        setupApollo()
    }
    companion object {
        val instance = HerasuClient()
    }

    private class AuthorizationInterceptor(): Interceptor {
        override fun intercept(chain: Interceptor.Chain): Response {
            val request = chain.request().newBuilder()
                .addHeader("x-hasura-admin-secret", "<key>")
                .build()

            return chain.proceed(request)
        }
    }


    private fun setupApollo(): ApolloClient {
        return ApolloClient.builder()
            .serverUrl("<host>.app/v1/graphql")
            .okHttpClient(OkHttpClient.Builder()
                .addInterceptor(AuthorizationInterceptor())
                .build()
            )
            .build()
    }
}

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

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

class QueryAdapter {
    companion object {
        val instance = QueryAdapter()
    }

    fun loginUserQuery(email: String, password: String): UserQuery {
        return UserQuery(Input.optional(email), Input.optional(password))
    }

    fun loginUserQuery(userData: UserData): UserQuery {
        return UserQuery(Input.optional(userData.email), Input.optional(userData.password))
    }

    fun createUser(userData: UserData):CreateUserMutation {
       return CreateUserMutation(name = Input.optional(userData.name), id = Input.optional(UUID.randomUUID()), email = Input.optional(userData.email), password = Input.optional(userData.password))
    }

    fun createPost(postItem: PostItem):AddPostMutation {
        return AddPostMutation(
            postId = Input.optional(UUID.fromString(postItem.uuid)),
            text = Input.optional(postItem.postText),
            user = Input.optional(postItem.userName),
            userId = Input.optional(UUID.fromString(postItem.userId)),
            date = Input.optional(Date()),
            image = Input.optional(postItem.imageLink.orEmpty())
        )
    }

    fun changeLike(postItem: PostItem):ChangeLikeMutation {
        val likes = postItem.mapLikes()
        return  ChangeLikeMutation(postId = Input.optional(UUID.fromString(postItem.uuid)), likes = Input.optional(likes))
    }

    fun changePost(postItem: PostItem):ChangePostMutation {
        return ChangePostMutation(postId = Input.optional(UUID.fromString(postItem.uuid)), postText = Input.optional(postItem.postText),imageLink = Input.optional(postItem.imageLink))
    }

    fun deletePost(postItem: PostItem):DeletePostMutation {
        return DeletePostMutation(Input.optional(postItem.uuid))
    }

    fun createComment(commentItem: CommentItem):CreateCommentMutation {
        return CreateCommentMutation(postId = Input.optional(UUID.fromString(commentItem.postId)), userId = Input.optional(UUID.fromString(commentItem.userId)),commentText = Input.optional(commentItem.text), id = Input.optional(UUID.randomUUID()),userName = Input.optional(commentItem.userName))
    }

}

Теперь создадим обратное преобразование. Все наши запросы содержат в качестве вложенных типов фрагменты, которые мы указали. Несмотря на то, что это, как фрагмент, одна и та же структура данных, из-за вложенности это разные типы.

Поэтому для каждой нашей модели данных создадим свои расширения:

fun PostItem.mapLikes():String {
    val likeString = likeItems.map { it.userId }
    return likeString.joinToString(", ")
}

fun PostsQuery.Post.toPost():PostItem{
    val post = this.fragments.post
    var postItem = PostItem()
    postItem.uuid = (post.post_id as? String).orEmpty()
    postItem.userName = post.user_name.orEmpty()
    postItem.userId = (post.user_id as? String).orEmpty()
    postItem.date = (post.date as? Date)?.format("HH:mm dd.MM.yyyy").orEmpty()
    postItem.postText = post.post_text.orEmpty()
    if (post.likes != null) {
        postItem.likeItems = (post.likes as? String)?.split(",")?.orEmpty()?.map {
            val like = LikeItem()
            like.userId = it
            like.postId = post.post_id.toString()
            like
        } as ArrayList<LikeItem>
    } else {
        postItem.likeItems = arrayListOf()
    }
    postItem.imageLink = post.image_link.orEmpty()
    return postItem
}

fun GetPostQuery.Post.toPost():PostItem{
    val post = this.fragments.post
    var postItem = PostItem()
    postItem.uuid = (post.post_id as? String).orEmpty()
    postItem.userName = post.user_name.orEmpty()
    postItem.userId = (post.user_id as? String).orEmpty()
    postItem.date = (post.date as? Date)?.format("HH:mm dd.MM.yyyy").orEmpty()
    postItem.postText = post.post_text.orEmpty()
    if (post.likes != null) {
        postItem.likeItems = (post.likes as? String)?.split(",")?.orEmpty()?.map {
            val like = LikeItem()
            like.userId = it
            like.postId = post.post_id.toString()
            like
        }.orEmpty() as ArrayList<LikeItem>
    } else {
        postItem.likeItems = arrayListOf()
    }
    postItem.imageLink = post.image_link.orEmpty()
    return postItem
}

fun AddPostMutation.Insert_posts_one.Fragments.toPost():PostItem{
    val post = this.post
    var postItem = PostItem()
    postItem.uuid = (post.post_id as? String).orEmpty()
    postItem.userName = post.user_name.orEmpty()
    postItem.userId = (post.user_id as? String).orEmpty()
    postItem.date = (post.date as? Date)?.format("HH:mm dd.MM.yyyy").orEmpty()
    postItem.postText = post.post_text.orEmpty()
    postItem.likeItems = arrayListOf()
    postItem.imageLink = post.image_link.orEmpty()
    return postItem
}

fun UserQuery.User.toUserData():UserData {
    val user = this.fragments.user
    val uuid =  (user.user_id as? String)?.let { UUID.fromString(it) } ?: UUID.randomUUID()
    val name = user.user_name
    val email = user.user_email
    return UserData(uuid,name,email,"")
}

fun CreateUserMutation.Insert_users_one.Fragments.toUserData():UserData {
    val user = this.user
    val uuid = (user.user_id as? String)?.let { UUID.fromString(it) } ?: UUID.randomUUID()
    val name = user.user_name
    val email = user.user_email
    return UserData(uuid,name,email,"")
}

fun CommentsQuery.Comment.toComment(): CommentItem {
      val comment =  this.fragments.comment
        val commentItem = CommentItem()
        commentItem.uuid = (comment.comment_id as? String).orEmpty()
        commentItem.text = comment.comment_text
        commentItem.userId = (comment.user_id as? String).orEmpty()
       commentItem.userName = comment.user_name.orEmpty()
        commentItem.postId = (comment.post_id as? String).orEmpty()
        return commentItem
}

fun CreateCommentMutation.Insert_comments_one.toComment(): CommentItem {
    val comment =  this.fragments.comment
    val commentItem = CommentItem()
    commentItem.uuid = (comment.comment_id as? String).orEmpty()
    commentItem.text = comment.comment_text
    commentItem.userId = (comment.user_id as? String).orEmpty()
    commentItem.userName = comment.user_name.orEmpty()
    commentItem.postId = (comment.post_id as? String).orEmpty()
    return commentItem
}

Теперь мы можем заняться запросами. Для вызова query мы используем специальную команду apolloClient.query, в качестве входного параметра передаем наш тип Query. Используем await для превращения вызова в suspend. Внутри нашего ответа мы получаем общую структуру data, внутри которой будут те параметры, которые мы прописали в ожидаемых еще на уровне GraphQL скрипта запросов. Аналогично с mutation.

suspend  fun loginUser(email: String, password: String): Result<UserData> {
   val response = apolloClient.query(QueryAdapter.instance.loginUserQuery(email, password)).await()
    response.data?.users?.firstOrNull()?.let {
        currentUser = it.toUserData()
    }
    val error = response.errors?.firstOrNull()?.message
    if (currentUser != null) {
        return Result.Success(currentUser!!)
    } else {
        return Result.Error(Exception(error))
    }
}

suspend fun createUser(user: UserData): Result<UserData> {
    val newUser = QueryAdapter.instance.createUser(user)
    val response =  apolloClient.mutate(newUser).await()
    val userData = response.data?.insert_users_one?.fragments?.toUserData()
    val error = response.errors?.firstOrNull()?.message
    if (userData != null) {
        return Result.Success(userData)
    } else {
        return Result.Error(Exception(error))
    }
}

Код выше мы используем для входа и регистрации. Подключаем его к приложению.

Теперь займемся методами для постов:

suspend fun loadPosts(): Result<List<PostItem>> {
     val response = apolloClient.query(PostsQuery()).await()
     val posts = response.data?.posts?.map { it.toPost() }
     val error = response.errors?.firstOrNull()?.message.orEmpty()
     if (posts != null) {
         return Result.Success(checkLiked(posts))
     } else {
         return Result.Error(java.lang.Exception(error))
     }
 }

 fun checkLiked(posts: List<PostItem>): List<PostItem> {
     val currentUser = AuthRepository.instance.currentUser
     if (currentUser != null) {
         for (item in posts) {
             item.hasLike = item.likeItems.any { it.userId == currentUser.uid.toString() }
         }
     }
     return posts
 }

 suspend fun loadPost(id: String): Result<PostItem?> {
     val postResponse =
         apolloClient.query(GetPostQuery(postId = Input.optional(UUID.fromString(id)))).await()
     val posts = postResponse.data?.posts?.map { it.toPost() }
     val error = postResponse.errors?.firstOrNull()?.message.orEmpty()
     if (posts != null) {
         return Result.Success(posts.firstOrNull())
     } else {
         return Result.Error(java.lang.Exception(error))
     }
 }


 suspend fun createPost(postItem: PostItem): Result<PostItem> {
     val currentUser = AuthRepository.instance.currentUser
     postItem.userId = currentUser?.uid.toString().orEmpty()
     postItem.userName = currentUser?.name.orEmpty()

     val response = apolloClient.mutate(QueryAdapter.instance.createPost(postItem)).await()
     val error = response.errors?.firstOrNull()?.message.orEmpty()
     val created = response.data?.insert_posts_one?.fragments?.toPost()
     if (created != null) {
         return Result.Success(created)
     } else {
         return Result.Error(java.lang.Exception(error))
     }
 }

suspend fun changeLike(postItem: PostItem):Result<Boolean> {
     val currentUser = AuthRepository.instance.currentUser
     if (currentUser != null) {
         val uuid = currentUser.uid
         val found = postItem.likeItems.filter { it.userId == uuid.toString() }
         if (!found.isNullOrEmpty()) {
             postItem.likeItems =
                 postItem.likeItems.filter { it.userId != uuid.toString() } as ArrayList<LikeItem>
         } else {
             val likeItem = LikeItem()
             likeItem.postId = postItem.uuid
             likeItem.userId = uuid.toString()
             postItem.likeItems.add(likeItem)
         }
       val response =  apolloClient.mutate(
            QueryAdapter.instance.changeLike(postItem)
         ).await()

         val error = response.errors?.firstOrNull()?.message
         if (response.data != null) {
             return Result.Success(true)
         } else {
             return Result.Success(false)
         }
     } else {
         return Result.Success(false)
     }
 }

 suspend fun editPost(postItem: PostItem):Result<PostItem> {
     val currentUser = AuthRepository.instance.currentUser
     if (postItem.userId == currentUser?.uid.toString()) {
         val response = apolloClient.mutate(QueryAdapter.instance.changePost(postItem)).await()
         val error = response.errors?.firstOrNull()?.message
         if (response.data != null) {
             return Result.Success(postItem)
         } else {
             return Result.Error(Exception(error))
         }
     } else {
         return Result.Error(Exception("wrong user"))
     }
 }

suspend fun deletePost(postItem: PostItem):Result<Boolean> {
     val currentUser = AuthRepository.instance.currentUser
     if (postItem.userId == currentUser?.uid.toString()) {
         val response = apolloClient.mutate(QueryAdapter.instance.deletePost(postItem)).await()
         val error = response.errors?.firstOrNull()?.message
         if (response.data != null) {
             return Result.Success(true)
         } else {
             return Result.Error(Exception(error))
         }

     } else {
         return Result.Error(Exception("wrong user"))
     }
 }

И комментариев:

suspend fun loadComments(postId: String): Result<List<CommentItem>> {
    val response =
        apolloClient.query(CommentsQuery(commentId = Input.optional(UUID.fromString(postId))))
            .await()
    val comments = response.data?.comments?.map { it.toComment() }
    val error = response.errors?.firstOrNull()?.message
    if (comments != null) {
        return Result.Success(comments)
    } else {
        return Result.Error(Exception(error))
    }
}

 suspend fun sendComment(commentItem: CommentItem):Result<CommentItem> {
     val currentUser = AuthRepository.instance.currentUser
     commentItem.userId = currentUser?.uid.toString().orEmpty()
     commentItem.userName = currentUser?.name.orEmpty()
    val response = apolloClient.mutate(QueryAdapter.instance.createComment(commentItem)).await()
     val comment = response.data?.insert_comments_one?.toComment()
     val error = response.errors?.firstOrNull()?.message
     if (comment != null) {
         return Result.Success(comment)
     } else {
         return Result.Error(Exception(error))
     }
 }

Подключаем код, проверяем, что все работает.

Полный код примера доступен по ссылке.