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

от автора

Доброго времени суток! С вами Анна Жаркова, ведущий разработчик компании Usetech, и мы продолжаем нашу серию статей, посвященных работе с технологией GraphQL при разработке мобильных приложений.

В прошлой части мы говорили о подготовке облачного GraphQL бекенда на Hasura. В этой статье мы перейдем собственно к подключению 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))      }  }

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

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

В следующей части мы поговорим, как сделать аналогичный клиент на iOS.

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


ссылка на оригинал статьи https://habr.com/ru/company/usetech/blog/645789/