Доброго времени суток! С вами Анна Жаркова, ведущий разработчик компании 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/
Добавить комментарий