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))
}
}
Подключаем код, проверяем, что все работает.