Rest Api yapısının eksik olduğu yönleri kapatmak için facebook ekibi tarafından geliştirilmiştir.
Api'dan veri çekerken sadece istediğimiz sorguya uygun veri dönmesini sağlar. Bunu tek bir endpoint kullanarak yapar.
İlişkisel verilerle çalışması çok daha kolaydır.
GraphQL dökümantasyonunu kendisi otomatik olarak yapar. Sorgu sonucunun göründüğü arayüz de otomatik olarak oluşur.
Versiyonlama yapmaya gerek yok. Eski ve yeni alanları belirtebiliyoruz.
Websoket tanımlarını yapmak daha kolay.
Apollo Server, hızlıca GraphQL sunucuları oluşturabileceğimiz, oldukça az bağımlılığı olan bir kütüphanedir.
Apollo server kullanmak için NodeJS pc de kurulu olmalı.
Bir dosya oluşturup açıyoruz. Açtığımız dosyanın içinde
npm init --yesyazıyoruz.
npm init bizim yeni bir proje oluşturmamızı sağlar. --yes npm init sonrası sorulacak tüm soruların default olarak otomatik doldurulmasını sağlar.
Apollo server için 2 tane bağlılık gerekiyor. Bunları yüklemek için terminale
npm i @apollo/server graphqlyazıyoruz.
ES6 import yapspını kullanabilmek için package.json dosyasında aşağıdaki ekleme yapılır.
{
// ...etc.
"type": "module",
"scripts": {
"start": "node index.js"
}
// other dependencies
}
index.js dosyası oluşturulur. İçine:
import { ApolloServer } from "@apollo/server";
import { startStandaloneServer } from '@apollo/server/standalone';
// Tip (type) tanımı:
const typeDefs = `#graphql
# GraphQL dizelerindeki yorumlar (bunun gibi) kare (#) simgesiyle başlar.
# Bu "Book" türü, veri kaynağımızdaki her kitap için sorgulanabilir alanları tanımlar.
type Book {
title: String
author: String
}
# "Query" türü özeldir: istemcilerin yürütebileceği tüm kullanılabilir sorguları ve her birinin dönüş türünü listeler. Bu durumda, "books" sorgusu sıfır veya daha fazla Book'tan (yukarıda tanımlanmıştır) oluşan bir dizi döndürür.
type Query {
books: [Book]
}
# data tipi ne ise ona ona uygun düzenlenmeli. Bu örnekte data tipi array olduğundan "[]" içine yazıldı.
`;
// data:
const books = [
{
title: "The Awakening",
author: "Kate Chopin",
},
{
title: "City of Glass",
author: "Paul Auster",
},
{
title: "Yabancı",
author: "Albert Camus",
},
];
// resolvers (çözücü):
const resolvers = {
Query: {
books: () => books,
},
};
// apollo server:
const server = new ApolloServer({ typeDefs, resolvers }); // iki parametre alır. 1. tip tanımları, 2. resolvers
const { url } = await startStandaloneServer(server, {
listen: { port: 4000 },
});
console.log(`🚀 Server ready at: ${url}`);
terminale
npm startyazdığımızda terminalde "🚀 Server ready at: http://localhost:4000/" çıktısını görürüz.
http://localhost:4000/ adresine gittiğimizde bizi sorgu yapmak için hazır bir arayüz ve dökümantasyon karşılar.
Bu arayüze
query ExampleQuery {
books {
title
}
}
yazarak datamızdaki kitap adlarına ulaşabiliriz.
Bu sayfada gördüğümüz arayüzü, daha sık kullanılan graphql playground arayüzü ile değiştirmek için bu yönerge takip edilebilir ancak yapımcılar bunu önermiyor.
Kodu her güncellediğimizde serveri manuel restart etmemek için nodemon kurduk. Bunun için terminale
npm i --save-dev nodemonyazdık ve package.json>scripts alanına
"dev": "nodemon index.js",ekledik. Terminale
npm run devyazarak serveri başlattığımızda her güncellemede kendini resetler.
GraphQL resolver tanımlarınız üzerinde null dönmemesini istediğiniz bir field veya tip varsa bunun için bir tanım yapabilirsiniz. "!" ile ifade edilir.
...
type Book {
title: String!
author: String
}
...
ifadesi title alanının null dönemeyeceğini ifade eder.
...
type Query {
books: [Book]!
}
...
ifadesi books sorgusunun null dönemeyeceğini ifade eder.
...
type Query {
books: [Book!]!
}
...
ifadesi books sorgusunu sonucunda gelen array içinde null eleman olamayacağını ifade eder.
Örnek kullanım:
...
type Book {
id: ID!
title: String!
author: String
score: Float
isPublished: Boolean
}
...
const books = [
{
id: 1,
title: "The Awakening",
author: "Kate Chopin",
score: 6.9,
isPublished: true
},
...
Girilen verinin tipini girerken aşağıdaki gibi tek tek girebiliriz:
const typeDefs = `#graphql
type Query {
name: String!
surname: String!
age: Int
}
`;
// resolvers (çözücü):
const resolvers = {
Query: {
name: () => "Murat",
surname: () => "Gökduman",
age: () => 29,
},
};
bunun yerine User için bir tip tanımı oluşturup bunu da geçebiliriz.
const typeDefs = `#graphql
type User {
name: String!
surname: String!
age: Int
}
type Query {
user: User
}
`;
// resolvers (çözücü):
const resolvers = {
Query: {
user: () => ({
name: "Murat",
surname: "Gökduman",
age: 29,
}),
},
};
bu yazımda User bir custom type'dır. Daha önceki örneklerdeki Book da bir custom type'dir.
Bu durumda yeni bir veri girmek istersek ancak User içine dahil etmezsek veri ayrıca tiplendirilip resorve edilebilir.
const typeDefs = `#graphql
type User {
name: String!
surname: String!
age: Int
}
type Query {
user: User
hello: String!
}
`;
// resolvers (çözücü):
const resolvers = {
Query: {
user: () => ({
name: "Murat",
surname: "Gökduman",
age: 29,
}),
hello: () => "world"
},
};
localhost:4000 içinde aşağıdaki sorgu yapıldığında
Query {
hello
user {
name
surname
}
}
Aşağıdaki cevap alınır.
{
"data": {
"hello": "world",
"user": {
"name": "Murat",
"surname": "Gökduman"
}
}
}
Custom type başka bir custom type içinde type olarak da kullanılabilir.
const typeDefs = `#graphql
type Author {
id: ID!
name: String!
score: Float
age: Int
books: [Book!]
}
type Book {
id: ID!
title: String!
author: Author!
isPublsihed: Boolean
score: Float
}
type Query {
book: [Book]
}
`;
// resolvers (çözücü):
const resolvers = {
Query: {
book: () => books,
},
};
Hazırlık olarak çalıştığımız dataları data.js dosyasına taşıdık ve index.js içine import ettik.
data.js
export const authors = [
{
id: 3,
name: "Kate Chopin",
score: 8,
books: [],
},
{
id: 2,
name: "Paul Auster",
score: 3,
books: [],
},
{
id: 1,
name: "Albert Camus",
score: 5,
books: [],
},
];
export const books = [
{
id: 1,
title: "The Awakening",
author: authors[0],
score: 6.9,
isPublished: true,
},
{
id: 2,
title: "City of Glass",
author: authors[1],
score: 7,
},
{
id: 3,
title: "Yabancı",
author: authors[2],
isPublished: false,
},
];
id den kitap sorgulama ve id den yazar sorgulama için typeQuery alanına ilgili tip tanımları yapıldı. Sonrasında bu tip tanımlarına uygun resolve lar tanımlandı.
import { ApolloServer } from "@apollo/server";
import { startStandaloneServer } from "@apollo/server/standalone";
// data:
import { books, authors } from "./data.js";
// Tip (type) tanımı:
const typeDefs = `#graphql
type Author {
id: ID!
name: String!
score: Float
age: Int
books: [Book!]
}
type Book {
id: ID!
title: String!
author: Author!
isPublsihed: Boolean
score: Float
}
type Query {
books: [Book!]
book(id: ID!): Book!
authors: [Author!]
author(id: ID!): Author!
}
`;
// resolvers (çözücü):
const resolvers = {
Query: {
books: () => books,
book: (parent, args) => {
//4 parametre alır: 1. parent: ilişkisel veri tabanı oluştururken kullanacağız. 2. prametre istemciden gelen argümanı verir.
const data = books.find((book) => book.id == args.id);
return data;
},
authors: () => authors,
author: (parent, args) => {
const data = authors.find((author) => author.id == args.id);
return data;
}
},
};
// apollo server:
const server = new ApolloServer({ typeDefs, resolvers }); // iki parametre alır. 1. tip tanımları, 2. resolvers
const { url } = await startStandaloneServer(server, {
listen: { port: 4000 },
});
console.log(`🚀 Server ready at: ${url}`);
resolver alanında alınan 2. parametre sorguda bize gönderilen parametredir. Bundan faydalanarak bir find işlemi yapıldı ve gelen veri dönüldü.
Aşağıdaki sorgu yapıldığında
query{
author (id: 1){
name
}
book(id: 3) {
title
author {
name
}
}
}
aşağıdaki sonuç alınır
{
"data": {
"author": {
"name": "Albert Camus"
},
"book": {
"title": "Yabancı",
"author": {
"name": "Albert Camus"
}
}
}
}
Sorgu sırasında birbiri ile alakalı iki veriyi birbirine bağlamak mümkün. Bunun için aranacak karşılık data alanından silinir. Daha sonra resolvers içinde ilişkisi kurulur.
import { ApolloServer } from "@apollo/server";
import { startStandaloneServer } from "@apollo/server/standalone";
// data:
import { books, authors } from "./data.js";
// Tip (type) tanımı:
const typeDefs = `#graphql
type Author {
id: ID!
name: String!
score: Float
age: Int
books: [Book!]
}
type Book {
id: ID!
title: String!
author: Author
author_id: ID!
isPublsihed: Boolean
score: Float
}
type Query {
books: [Book!]
book(id: ID!): Book!
authors: [Author!]
author(id: ID!): Author!
}
`;
// resolvers (çözücü):
const resolvers = {
Query: {
books: () => books,
book: (parent, args) => books.find((book) => book.id === args.id),
authors: () => authors,
author: (parent, args) => authors.find((author) => author.id === args.id),
},
Book: {
// Book tipi altındaki author keyi için girilen resolver.
author: (
parent, // parent sorgunun yapıldığı parent tipin değerini döner.
args
) => authors.find((author) => author.id === parent.author_id),
},
Author: {
books: (parent, args) =>
books.filter((book) => book.author_id === parent.id),
},
};
// apollo server:
const server = new ApolloServer({ typeDefs, resolvers }); // iki parametre alır. 1. tip tanımları, 2. resolvers
const { url } = await startStandaloneServer(server, {
listen: { port: 4000 },
});
console.log(`🚀 Server ready at: ${url}`);
tip alanında istenilen veri keyi veri tipi ile bağlantılanır.
resolver alanında hangi tipin altında hangi sorgu yapılırsa nasıl bir verinin çekileceği tanımlanır.
Book altında author sorgulandığında parent alanından aldığı author_id ile authors içinde data arar.
Author altında books sorgulandığında books içinde author_id değeri barent.id ile uyumlu olanları filtreler.
Author altında books sorgusuna ilk harfe göre filtreleme özelliği eklemek için aşağıdaki örneği yaptık.
import { ApolloServer } from "@apollo/server";
import { startStandaloneServer } from "@apollo/server/standalone";
// data:
import { books, authors } from "./data.js";
// Tip (type) tanımı:
const typeDefs = `#graphql
type Author {
id: ID!
name: String!
score: Float
age: Int
books(filter: String): [Book!] # filtreleme işlemi için
}
books için parametre alabilme özelliği ve alacağı parametrenin veri tipi belirtildi.
type Book {
id: ID!
title: String!
author: Author
author_id: ID!
isPublsihed: Boolean
score: Float
}
type Query {
books: [Book!]
book(id: ID!): Book!
authors: [Author!]
author(id: ID!): Author!
}
`;
// resolvers (çözücü):
const resolvers = {
Query: {
books: () => books,
book: (parent, args) => books.find((book) => book.id === args.id),
authors: () => authors,
author: (parent, args) => authors.find((author) => author.id === args.id),
},
Book: {
author: (parent, args) =>
authors.find((author) => author.id === parent.author_id),
},
Author: {
books: (parent, args) => {
let filtered = books.filter((book) => book.author_id === parent.id);
if (args.filter) { // sorguda filter var ise
filtered = filtered.filter((book) =>
book.title.toLowerCase().startsWith(args.filter.toLowerCase()) // args.filter verisine göre filtreler.
);
}
return filtered;
},
},
};
sorguda book için filter parametresi geçildiyse ilk harfe göre filtreleme yapılır.
// apollo server:
const server = new ApolloServer({ typeDefs, resolvers }); // iki parametre alır. 1. tip tanımları, 2. resolvers
const { url } = await startStandaloneServer(server, {
listen: { port: 4000 },
});
console.log(`🚀 Server ready at: ${url}`);
Mutation tanımı, GraphQL sunucuları üzerinde veri ekleme,silme veya güncelleme durumlarında kullanılır.
import { ApolloServer } from "@apollo/server";
import { startStandaloneServer } from "@apollo/server/standalone";
import { nanoid } from "nanoid"; // user id için gerekli
import { users, posts, comments } from "./data.js";
const typeDefs = `#graphql
type User {
id: ID!
fullName: String!
posts: [Post]
comments: [Comment]
}
type Post {
id: ID!
title: String!
user_id: ID!
comments: [Comment!]
user: User!
}
type Comment {
id: ID!
text: String!
post_id: ID!
post: Post!
user: User!
}
type Query {
users: [User!]!
user(id: ID!): User!
posts: [Post!]
post(id: ID!): Post!
comments: [Comment]
comment(id: ID!): Comment!
}
type Mutation {
createUser(fullName: String!): User! #createUser parametre olarak fullName keyi ile string alır. response olarak User döner.
createPost(title: String!, user_id: ID!): Post!
createComment(text: String!, post_id: ID!, user_id: ID!): Comment!
}
`;
const resolvers = {
Mutation: {
createUser: (parent, args) => {
// yeni user ekleme
const user = {
id: nanoid(),
fullName: args.fullName,
};
users.push(user);
return user;
},
createPost: (parent, args) => {
// yeni post ekleme
const post = { id: nanoid(), title: args.title, user_id: args.user_id };
posts.push(post);
return post;
},
createComment: (parent, { text, post_id, user_id }) => {
//args destruct edildi
// yeni comment ekleme
const comment = {
id: nanoid(),
text, // parametre key ve value aynı olduğundan tek kelimeyle yazılabilir.
post_id,
user_id,
};
comments.push(comment);
return comment;
},
},
Query: {
users: () => users,
user: (parent, args) => {
const user = users.find((user) => user.id === args.id);
if (!user) {
return new Error("User not found");
}
return user;
},
posts: () => posts,
post: (parent, args) => posts.find((post) => post.id === args.id),
comments: () => comments,
comment: (parent, args) =>
comments.find((comment) => comment.id === args.id),
},
User: {
posts: (parent, args) => posts.filter((post) => post.user_id === parent.id),
comments: (parent, args) =>
comments.filter((comment) => comment.user_id === parent.id),
},
Post: {
comments: (parent, args) =>
comments.filter((comment) => comment.post_id === parent.id),
user: (parent, args) => users.find((user) => user.id === parent.user_id),
},
Comment: {
post: (parent, args) => posts.find((post) => post.id === parent.post_id),
user: (parent, args) => users.find((user) => user.id === parent.user_id),
},
};
const server = new ApolloServer({ typeDefs, resolvers });
const { url } = await startStandaloneServer(server, { listen: { port: 4000 } });
console.log(`🚀 Server ready at: ${url}`);
Mutation için yazılan parametre tanımları, kdun temiz kalması için, başka bir alanda yazılabilir. Bu alan input tanımı ile başlar. Parametre içinde data ketyi ile karşılanır.
import { ApolloServer } from "@apollo/server";
import { startStandaloneServer } from "@apollo/server/standalone";
import { nanoid } from "nanoid"; // user id için gerekli
import { users, posts, comments } from "./data.js";
const typeDefs = `#graphql
type User {
id: ID!
fullName: String!
posts: [Post]
comments: [Comment]
}
input CreateUserInput { # createUser mutationu için parametre tanımları.
fullName: String!
}
type Post {
id: ID!
title: String!
user_id: ID!
comments: [Comment!]
user: User!
}
input CreatePostInput { # createPost mutationu için parametre tanımları.
title: String!
user_id: ID!
}
type Comment {
id: ID!
text: String!
post_id: ID!
post: Post!
user: User!
}
input CreateCommetInput{
text: String!
post_id: ID!
user_id: ID!
}
type Query {
users: [User!]!
user(id: ID!): User!
posts: [Post!]
post(id: ID!): Post!
comments: [Comment]
comment(id: ID!): Comment!
}
type Mutation {
createUser(data: CreateUserInput!): User! #input type tanımı data keyi ile geçilir.
createPost(data: CreatePostInput!): Post!
createComment(data: CreateCommetInput!): Comment!
}
`;
const resolvers = {
Mutation: {
createUser: (parent, args) => {
// yeni user ekleme
const user = {
id: nanoid(),
fullName: args.data.fullName, // gelen arguman data altında gelir.
};
users.push(user);
return user;
},
createPost: (parent, { data: { title, user_id } }) => {
//args.data destruct edildi
const post = {
id: nanoid(),
title,
user_id,
};
posts.push(post);
return post;
},
createComment: (parent, { data }) => {
const comment = {
id: nanoid(),
...data, // data içindeki veri obje olarak tamamen eklendi.
};
comments.push(comment);
return comment;
},
},
Query: {
users: () => users,
user: (parent, args) => {
const user = users.find((user) => user.id === args.id);
if (!user) {
return new Error("User not found");
}
return user;
},
posts: () => posts,
post: (parent, args) => posts.find((post) => post.id === args.id),
comments: () => comments,
comment: (parent, args) =>
comments.find((comment) => comment.id === args.id),
},
User: {
posts: (parent, args) => posts.filter((post) => post.user_id === parent.id),
comments: (parent, args) =>
comments.filter((comment) => comment.user_id === parent.id),
},
Post: {
comments: (parent, args) =>
comments.filter((comment) => comment.post_id === parent.id),
user: (parent, args) => users.find((user) => user.id === parent.user_id),
},
Comment: {
post: (parent, args) => posts.find((post) => post.id === parent.post_id),
user: (parent, args) => users.find((user) => user.id === parent.user_id),
},
};
const server = new ApolloServer({ typeDefs, resolvers });
const { url } = await startStandaloneServer(server, { listen: { port: 4000 } });
console.log(`🚀 Server ready at: ${url}`);
import { ApolloServer } from "@apollo/server";
import { startStandaloneServer } from "@apollo/server/standalone";
import { nanoid } from "nanoid";
import { users, posts, comments } from "./data.js";
const typeDefs = `#graphql
# User
type User {
id: ID!
fullName: String!
age: Int!
posts: [Post]
comments: [Comment]
}
input CreateUserInput {
fullName: String!
age: Int!
}
input UpdateUserInput {
fullName: String
age: Int
}
# Post
type Post {
id: ID!
title: String!
user_id: ID!
comments: [Comment!]
user: User!
}
input CreatePostInput {
title: String!
user_id: ID!
}
input UpdatePostInput {
title: String
user_id: ID
}
# Comment
type Comment {
id: ID!
text: String!
post_id: ID!
post: Post!
user: User!
}
input CreateCommetInput{
text: String!
post_id: ID!
user_id: ID!
}
input UpdateCommentInput{
text: String
post_id: ID
user_id: ID
}
type Query {
users: [User!]!
user(id: ID!): User!
posts: [Post!]
post(id: ID!): Post!
comments: [Comment]
comment(id: ID!): Comment!
}
type Mutation {
# User
createUser(data: CreateUserInput!): User!
updateUser(id: ID!, data: UpdateUserInput!): User!
# Post
createPost(data: CreatePostInput!): Post!
updatePost(id: ID!, data: UpdatePostInput!): Post
# Comment
createComment(data: CreateCommetInput!): Comment!
updateComment(id: ID!, data: UpdateCommentInput!): Comment!
}
`;
const resolvers = {
Mutation: {
// User
createUser: (parent, args) => {
const user = {
id: nanoid(),
fullName: args.data.fullName,
};
users.push(user);
return user;
},
updateUser: (parent, { id, data }) => {
const user_index = users.findIndex((user) => user.id === id);
if (user_index === -1) {
throw new Error("User not found.");
}
const update_user = (users[user_index] = {
...users[user_index], // önce mevcut tanımları al
...data, // data altından gelenlerle merge et.
});
return update_user;
},
// Post
createPost: (parent, { data: { title, user_id } }) => {
const post = {
id: nanoid(),
title,
user_id,
};
posts.push(post);
return post;
},
updatePost: (parent, { id, data }) => {
const post_index = posts.findIndex((post) => post.id === id);
if (post_index === -1) {
throw new Error("Post not found.");
}
const updated_post = (posts[post_index] = {
...posts[post_index],
...data,
});
return updated_post;
},
// Comment
createComment: (parent, { data }) => {
const comment = {
id: nanoid(),
...data,
};
comments.push(comment);
return comment;
},
updateComment: (parent, { id, data }) => {
const comment_index = comments.findIndex((comment) => comment.id === id);
if (comment_index === -1) {
throw new Error("Comment not found.");
}
const updated_comment = (comments[comment_index] = {
...comments[comment_index],
...data,
});
return updated_comment;
},
},
Query: {
users: () => users,
user: (parent, args) => {
const user = users.find((user) => user.id === args.id);
if (!user) {
return new Error("User not found");
}
return user;
},
posts: () => posts,
post: (parent, args) => posts.find((post) => post.id === args.id),
comments: () => comments,
comment: (parent, args) =>
comments.find((comment) => comment.id === args.id),
},
User: {
posts: (parent, args) => posts.filter((post) => post.user_id === parent.id),
comments: (parent, args) =>
comments.filter((comment) => comment.user_id === parent.id),
},
Post: {
comments: (parent, args) =>
comments.filter((comment) => comment.post_id === parent.id),
user: (parent, args) => users.find((user) => user.id === parent.user_id),
},
Comment: {
post: (parent, args) => posts.find((post) => post.id === parent.post_id),
user: (parent, args) => users.find((user) => user.id === parent.user_id),
},
};
const server = new ApolloServer({ typeDefs, resolvers });
const { url } = await startStandaloneServer(server, { listen: { port: 4000 } });
console.log(`🚀 Server ready at: ${url}`);
import { ApolloServer } from "@apollo/server";
import { startStandaloneServer } from "@apollo/server/standalone";
import { nanoid } from "nanoid";
import { users, posts, comments } from "./data.js";
const typeDefs = `#graphql
# User
type User {
id: ID!
fullName: String!
age: Int!
posts: [Post]
comments: [Comment]
}
input CreateUserInput {
fullName: String!
age: Int!
}
input UpdateUserInput {
fullName: String
age: Int
}
# Post
type Post {
id: ID!
title: String!
user_id: ID!
comments: [Comment!]
user: User!
}
input CreatePostInput {
title: String!
user_id: ID!
}
input UpdatePostInput {
title: String
user_id: ID
}
# Comment
type Comment {
id: ID!
text: String!
post_id: ID!
post: Post!
user: User!
}
input CreateCommetInput{
text: String!
post_id: ID!
user_id: ID!
}
input UpdateCommentInput{
text: String
post_id: ID
user_id: ID
}
type Query {
users: [User!]!
user(id: ID!): User!
posts: [Post!]
post(id: ID!): Post!
comments: [Comment]
comment(id: ID!): Comment!
}
type Mutation {
# User
createUser(data: CreateUserInput!): User!
updateUser(id: ID!, data: UpdateUserInput!): User!
deleteUser(id: ID!): User!
# Post
createPost(data: CreatePostInput!): Post!
updatePost(id: ID!, data: UpdatePostInput!): Post!
deletePost(id: ID!): Post!
# Comment
createComment(data: CreateCommetInput!): Comment!
updateComment(id: ID!, data: UpdateCommentInput!): Comment!
deleteComment(id: ID!): Comment!
}
`;
const resolvers = {
Mutation: {
// User
createUser: (parent, args) => {
const user = {
id: nanoid(),
fullName: args.data.fullName,
};
users.push(user);
return user;
},
updateUser: (parent, { id, data }) => {
const user_index = users.findIndex((user) => user.id === id);
if (user_index === -1) {
throw new Error("User not found.");
}
const update_user = (users[user_index] = {
...users[user_index],
...data,
});
return update_user;
},
deleteUser: (parent, { id }) => {
// silme işlemi için: 1. filter metodunda id si parent.id olmayanları filtreleyip users'a atayabiliriz. 2. indexini bulup slice ile çıkartabiliriz.
const user_index = users.findIndex((user) => user.id === id); // bu idye sahip kullanıcı var mı?
if (user_index === -1) {
throw new Error("User not found.");
}
const deleted_user = users[user_index]; // silinecek olanı ayrı bir yere kaydettik
users.splice(user_index, 1); //splice iki parametre alır. Silinecek olanın indexi ve indexten itibaren kaç eleman silineceği. 3 parametre olarak da yerine eklenecek ögeyi alabilir.
return deleted_user;
},
// Post
createPost: (parent, { data: { title, user_id } }) => {
const post = {
id: nanoid(),
title,
user_id,
};
posts.push(post);
return post;
},
updatePost: (parent, { id, data }) => {
const post_index = posts.findIndex((post) => post.id === id);
if (post_index === -1) {
throw new Error("Post not found.");
}
const updated_post = (posts[post_index] = {
...posts[post_index],
...data,
});
return updated_post;
},
deletePost: (parent, { id }) => {
const post_index = posts.findIndex((post) => post.id === id);
if (post_index === -1) {
throw new Error("Post not found.");
}
const deleted_post = posts[post_index];
posts.splice(post_index, 1);
return deleted_post;
},
// Comment
createComment: (parent, { data }) => {
const comment = {
id: nanoid(),
...data,
};
comments.push(comment);
return comment;
},
updateComment: (parent, { id, data }) => {
const comment_index = comments.findIndex((comment) => comment.id === id);
if (comment_index === -1) {
throw new Error("Comment not found.");
}
const updated_comment = (comments[comment_index] = {
...comments[comment_index],
...data,
});
return updated_comment;
},
deleteComment: (parent, { id }) => {
const comment_index = comments.findIndex((comment) => comment.id === id);
if (comment_index === -1) {
throw new Error("Comment not found");
}
const deleted_comment = comments[comment_index];
comments.splice(comment_index, 1);
return deleted_comment;
},
},
Query: {
users: () => users,
user: (parent, args) => {
const user = users.find((user) => user.id === args.id);
if (!user) {
return new Error("User not found");
}
return user;
},
posts: () => posts,
post: (parent, args) => posts.find((post) => post.id === args.id),
comments: () => comments,
comment: (parent, args) =>
comments.find((comment) => comment.id === args.id),
},
User: {
posts: (parent, args) => posts.filter((post) => post.user_id === parent.id),
comments: (parent, args) =>
comments.filter((comment) => comment.user_id === parent.id),
},
Post: {
comments: (parent, args) =>
comments.filter((comment) => comment.post_id === parent.id),
user: (parent, args) => users.find((user) => user.id === parent.user_id),
},
Comment: {
post: (parent, args) => posts.find((post) => post.id === parent.post_id),
user: (parent, args) => users.find((user) => user.id === parent.user_id),
},
};
const server = new ApolloServer({ typeDefs, resolvers });
const { url } = await startStandaloneServer(server, { listen: { port: 4000 } });
console.log(`🚀 Server ready at: ${url}`);
import { ApolloServer } from "@apollo/server";
import { startStandaloneServer } from "@apollo/server/standalone";
import { nanoid } from "nanoid";
import { users, posts, comments } from "./data.js";
const typeDefs = `#graphql
# User
type User {
id: ID!
fullName: String!
age: Int!
posts: [Post]
comments: [Comment]
}
input CreateUserInput {
fullName: String!
age: Int!
}
input UpdateUserInput {
fullName: String
age: Int
}
# Post
type Post {
id: ID!
title: String!
user_id: ID!
comments: [Comment!]
user: User!
}
input CreatePostInput {
title: String!
user_id: ID!
}
input UpdatePostInput {
title: String
user_id: ID
}
# Comment
type Comment {
id: ID!
text: String!
post_id: ID!
post: Post!
user: User!
}
input CreateCommetInput{
text: String!
post_id: ID!
user_id: ID!
}
input UpdateCommentInput{
text: String
post_id: ID
user_id: ID
}
type DeleteAllOutput {
count: Int!
}
type Query {
users: [User!]!
user(id: ID!): User!
posts: [Post!]
post(id: ID!): Post!
comments: [Comment]
comment(id: ID!): Comment!
}
type Mutation {
# User
createUser(data: CreateUserInput!): User!
updateUser(id: ID!, data: UpdateUserInput!): User!
deleteUser(id: ID!): User!
deleteAllUsers: DeleteAllOutput!
# Post
createPost(data: CreatePostInput!): Post!
updatePost(id: ID!, data: UpdatePostInput!): Post!
deletePost(id: ID!): Post!
deleteAllPosts: DeleteAllOutput!
# Comment
createComment(data: CreateCommetInput!): Comment!
updateComment(id: ID!, data: UpdateCommentInput!): Comment!
deleteComment(id: ID!): Comment!
deleteAllComments: DeleteAllOutput!
}
`;
const resolvers = {
Mutation: {
// User
createUser: (parent, args) => {
const user = {
id: nanoid(),
fullName: args.data.fullName,
};
users.push(user);
return user;
},
updateUser: (parent, { id, data }) => {
const user_index = users.findIndex((user) => user.id === id);
if (user_index === -1) {
throw new Error("User not found.");
}
const update_user = (users[user_index] = {
...users[user_index],
...data,
});
return update_user;
},
deleteUser: (parent, { id }) => {
const user_index = users.findIndex((user) => user.id === id);
if (user_index === -1) {
throw new Error("User not found.");
}
const deleted_user = users[user_index];
users.splice(user_index, 1);
return deleted_user;
},
deleteAllUsers: () => {
const length = users.length;
// users = []; // users tanımlanırken const ile tanımlandığından bu method çalışmaz. Bunun çalışabilmesi içiin const ifadesi let ile değiştirilebilir veya farklı bir metod kullanılabilir.
users.splice(0, length); // 0 dan başlayıp tüm elemanları siler
return {
count: length,
};
},
// Post
createPost: (parent, { data: { title, user_id } }) => {
const post = {
id: nanoid(),
title,
user_id,
};
posts.push(post);
return post;
},
updatePost: (parent, { id, data }) => {
const post_index = posts.findIndex((post) => post.id === id);
if (post_index === -1) {
throw new Error("Post not found.");
}
const updated_post = (posts[post_index] = {
...posts[post_index],
...data,
});
return updated_post;
},
deletePost: (parent, { id }) => {
const post_index = posts.findIndex((post) => post.id === id);
if (post_index === -1) {
throw new Error("Post not found.");
}
const deleted_post = posts[post_index];
posts.splice(post_index, 1);
return deleted_post;
},
deleteAllPosts: () => {
const length = posts.length;
posts.splice(0, length);
return {
count: length,
};
},
// Comment
createComment: (parent, { data }) => {
const comment = {
id: nanoid(),
...data,
};
comments.push(comment);
return comment;
},
updateComment: (parent, { id, data }) => {
const comment_index = comments.findIndex((comment) => comment.id === id);
if (comment_index === -1) {
throw new Error("Comment not found.");
}
const updated_comment = (comments[comment_index] = {
...comments[comment_index],
...data,
});
return updated_comment;
},
deleteComment: (parent, { id }) => {
const comment_index = comments.findIndex((comment) => comment.id === id);
if (comment_index === -1) {
throw new Error("Comment not found");
}
const deleted_comment = comments[comment_index];
comments.splice(comment_index, 1);
return deleted_comment;
},
deleteAllComments: () => {
const length = comments.length;
comments.splice(0, length);
return {
count: length,
};
},
},
Query: {
users: () => users,
user: (parent, args) => {
const user = users.find((user) => user.id === args.id);
if (!user) {
return new Error("User not found");
}
return user;
},
posts: () => posts,
post: (parent, args) => posts.find((post) => post.id === args.id),
comments: () => comments,
comment: (parent, args) =>
comments.find((comment) => comment.id === args.id),
},
User: {
posts: (parent, args) => posts.filter((post) => post.user_id === parent.id),
comments: (parent, args) =>
comments.filter((comment) => comment.user_id === parent.id),
},
Post: {
comments: (parent, args) =>
comments.filter((comment) => comment.post_id === parent.id),
user: (parent, args) => users.find((user) => user.id === parent.user_id),
},
Comment: {
post: (parent, args) => posts.find((post) => post.id === parent.post_id),
user: (parent, args) => users.find((user) => user.id === parent.user_id),
},
};
const server = new ApolloServer({ typeDefs, resolvers });
const { url } = await startStandaloneServer(server, { listen: { port: 4000 } });
console.log(`🚀 Server ready at: ${url}`);
Subscription tanımı gerçekleşen olaylardan(ekleme,silme,güncelleme vb.) gerçek zamanlı olarak haberdar olabileceğimiz WebSocket tabanlı bir yapıdır.
Apollo Server'ın 3. versiyonu ile birlikte Subscription yapısı core üzerinden kaldırıldı ancak ek kütüphaneler ekleyerek bunu yeniden aktifleştirebilirsiniz. Bu işlem sırasında birden fazla kütüphanenin kurulması gerekiyor ve kod biraz daha karmaşık görünüyor. Bundan kurtulmak için graphql yoga adında bir kütüphaneden faydalanacağız.
Terminale
npm i graphql-yogayazıyoruz
Dersin videosu güncel değil. Biz kendi yolumuzu bulacağız. Bunu uyguladık.
Bu da kullanılabilir. Aynı methodun farklı yazılmışı.
import { createYoga, createSchema, createPubSub } from "graphql-yoga"; //graphql-yoga içinden gerekenler import edildi.
import { createServer } from "node:http";
import { nanoid } from "nanoid";
import { users, posts, comments } from "./data.js";
const pubSub = createPubSub(); // Yayın için gereken middleware
const yoga = createYoga({
// Şemayı sarmala
schema: createSchema({
typeDefs: `#graphql
# User
type User {
id: ID!
fullName: String!
age: Int!
posts: [Post]
comments: [Comment]
}
input CreateUserInput {
fullName: String!
age: Int!
}
input UpdateUserInput {
fullName: String
age: Int
}
# Post
type Post {
id: ID!
title: String!
user_id: ID!
comments: [Comment!]
user: User!
}
input CreatePostInput {
title: String!
user_id: ID!
}
input UpdatePostInput {
title: String
user_id: ID
}
# Comment
type Comment {
id: ID!
text: String!
post_id: ID!
post: Post!
user: User!
}
input CreateCommetInput{
text: String!
post_id: ID!
user_id: ID!
}
input UpdateCommentInput{
text: String
post_id: ID
user_id: ID
}
type DeleteAllOutput {
count: Int!
}
type Query {
users: [User!]!
user(id: ID!): User!
posts: [Post!]
post(id: ID!): Post!
comments: [Comment]
comment(id: ID!): Comment!
}
type Mutation {
# User
createUser(data: CreateUserInput!): User!
updateUser(id: ID!, data: UpdateUserInput!): User!
deleteUser(id: ID!): User!
deleteAllUsers: DeleteAllOutput!
# Post
createPost(data: CreatePostInput!): Post!
updatePost(id: ID!, data: UpdatePostInput!): Post!
deletePost(id: ID!): Post!
deleteAllPosts: DeleteAllOutput!
# Comment
createComment(data: CreateCommetInput!): Comment!
updateComment(id: ID!, data: UpdateCommentInput!): Comment!
deleteComment(id: ID!): Comment!
deleteAllComments: DeleteAllOutput!
}
type Subscription {
# count: Int! # Örnek için
userCreated: User!
}
`,
resolvers: {
Subscription: {
// count: { // Örnek
// subscribe: () => { // Her saniye değeri 1 arttırıp iletir
// let count = 0;
// setInterval(() => {
// count++;
// pubSub.publish("count", {count});
// }, 1000);
// return pubSub.subscribe("count")
// }
// }
userCreated: {
subscribe: () => pubSub.subscribe("userCreated"), // kanala abone olduk.
},
},
Mutation: {
// User
createUser: (parent, args) => {
const user = {
id: nanoid(),
fullName: args.data.fullName,
age: args.data.age
};
users.push(user);
pubSub.publish("userCreated", {"userCreated": user}); // yayın yapıldı.
return user;
},
updateUser: (parent, { id, data }) => {
const user_index = users.findIndex((user) => user.id === id);
if (user_index === -1) {
throw new Error("User not found.");
}
const update_user = (users[user_index] = {
...users[user_index],
...data,
});
return update_user;
},
deleteUser: (parent, { id }) => {
const user_index = users.findIndex((user) => user.id === id);
if (user_index === -1) {
throw new Error("User not found.");
}
const deleted_user = users[user_index];
users.splice(user_index, 1);
return deleted_user;
},
deleteAllUsers: () => {
const length = users.length;
users.splice(0, length);
return {
count: length,
};
},
// Post
createPost: (parent, { data: { title, user_id } }) => {
const post = {
id: nanoid(),
title,
user_id,
};
posts.push(post);
return post;
},
updatePost: (parent, { id, data }) => {
const post_index = posts.findIndex((post) => post.id === id);
if (post_index === -1) {
throw new Error("Post not found.");
}
const updated_post = (posts[post_index] = {
...posts[post_index],
...data,
});
return updated_post;
},
deletePost: (parent, { id }) => {
const post_index = posts.findIndex((post) => post.id === id);
if (post_index === -1) {
throw new Error("Post not found.");
}
const deleted_post = posts[post_index];
posts.splice(post_index, 1);
return deleted_post;
},
deleteAllPosts: () => {
const length = posts.length;
posts.splice(0, length);
return {
count: length,
};
},
// Comment
createComment: (parent, { data }) => {
const comment = {
id: nanoid(),
...data,
};
comments.push(comment);
return comment;
},
updateComment: (parent, { id, data }) => {
const comment_index = comments.findIndex(
(comment) => comment.id === id
);
if (comment_index === -1) {
throw new Error("Comment not found.");
}
const updated_comment = (comments[comment_index] = {
...comments[comment_index],
...data,
});
return updated_comment;
},
deleteComment: (parent, { id }) => {
const comment_index = comments.findIndex(
(comment) => comment.id === id
);
if (comment_index === -1) {
throw new Error("Comment not found");
}
const deleted_comment = comments[comment_index];
comments.splice(comment_index, 1);
return deleted_comment;
},
deleteAllComments: () => {
const length = comments.length;
comments.splice(0, length);
return {
count: length,
};
},
},
Query: {
users: () => users,
user: (parent, args) => {
const user = users.find((user) => user.id === args.id);
if (!user) {
return new Error("User not found");
}
return user;
},
posts: () => posts,
post: (parent, args) => posts.find((post) => post.id === args.id),
comments: () => comments,
comment: (parent, args) =>
comments.find((comment) => comment.id === args.id),
},
User: {
posts: (parent, args) =>
posts.filter((post) => post.user_id === parent.id),
comments: (parent, args) =>
comments.filter((comment) => comment.user_id === parent.id),
},
Post: {
comments: (parent, args) =>
comments.filter((comment) => comment.post_id === parent.id),
user: (parent, args) =>
users.find((user) => user.id === parent.user_id),
},
Comment: {
post: (parent, args) =>
posts.find((post) => post.id === parent.post_id),
user: (parent, args) =>
users.find((user) => user.id === parent.user_id),
},
},
}),
});
const server = createServer(yoga); // server kur
server.listen(4000, () => {
console.info("Server is running on http://localhost:4000/graphql");
});
Yukarıdaki işlemleri diğer user Crud işlemlerine uyarladık.
import { createYoga, createSchema, createPubSub } from "graphql-yoga";
import { createServer } from "node:http";
import { nanoid } from "nanoid";
import { users, posts, comments } from "./data.js";
const pubSub = createPubSub();
const yoga = createYoga({
// Şemayı sarmala
schema: createSchema({
typeDefs: `#graphql
# User
type User {
id: ID!
fullName: String!
age: Int!
posts: [Post]
comments: [Comment]
}
input CreateUserInput {
fullName: String!
age: Int!
}
input UpdateUserInput {
fullName: String
age: Int
}
# Post
type Post {
id: ID!
title: String!
user_id: ID!
comments: [Comment!]
user: User!
}
input CreatePostInput {
title: String!
user_id: ID!
}
input UpdatePostInput {
title: String
user_id: ID
}
# Comment
type Comment {
id: ID!
text: String!
post_id: ID!
post: Post!
user: User!
}
input CreateCommetInput{
text: String!
post_id: ID!
user_id: ID!
}
input UpdateCommentInput{
text: String
post_id: ID
user_id: ID
}
type DeleteAllOutput {
count: Int!
}
type Query {
users: [User!]!
user(id: ID!): User!
posts: [Post!]
post(id: ID!): Post!
comments: [Comment]
comment(id: ID!): Comment!
}
type Mutation {
# User
createUser(data: CreateUserInput!): User!
updateUser(id: ID!, data: UpdateUserInput!): User!
deleteUser(id: ID!): User!
deleteAllUsers: DeleteAllOutput!
# Post
createPost(data: CreatePostInput!): Post!
updatePost(id: ID!, data: UpdatePostInput!): Post!
deletePost(id: ID!): Post!
deleteAllPosts: DeleteAllOutput!
# Comment
createComment(data: CreateCommetInput!): Comment!
updateComment(id: ID!, data: UpdateCommentInput!): Comment!
deleteComment(id: ID!): Comment!
deleteAllComments: DeleteAllOutput!
}
type Subscription {
# count: Int! # Örnek için
# User
userCreated: User!
userUpdated: User!
userDeleted: User!
}
`,
resolvers: {
Subscription: {
userCreated: {
subscribe: () => pubSub.subscribe("userCreated"),
},
userUpdated: {
subscribe: () => pubSub.subscribe("userUpdated"), // yayına abone olundu
},
userDeleted: {
subscribe: () => pubSub.subscribe("userDeleted"), // yayına abone olundu
},
},
Mutation: {
// User
createUser: (parent, args) => {
const user = {
id: nanoid(),
fullName: args.data.fullName,
age: args.data.age,
};
users.push(user);
pubSub.publish("userCreated", { userCreated: user });
return user;
},
updateUser: (parent, { id, data }) => {
const user_index = users.findIndex((user) => user.id === id);
if (user_index === -1) {
throw new Error("User not found.");
}
const update_user = (users[user_index] = {
...users[user_index],
...data,
});
pubSub.publish("userUpdated", { userUpdated: update_user, }); //yayın yapıldı
return update_user;
},
deleteUser: (parent, { id }) => {
const user_index = users.findIndex((user) => user.id === id);
if (user_index === -1) {
throw new Error("User not found.");
}
const deleted_user = users[user_index];
users.splice(user_index, 1);
pubSub.publish("userDeleted", { userDeleted: deleted_user }); // yayın yapıldı
return deleted_user;
},
deleteAllUsers: () => {
const length = users.length;
users.splice(0, length);
return {
count: length,
};
},
// Post
createPost: (parent, { data: { title, user_id } }) => {
const post = {
id: nanoid(),
title,
user_id,
};
posts.push(post);
return post;
},
updatePost: (parent, { id, data }) => {
const post_index = posts.findIndex((post) => post.id === id);
if (post_index === -1) {
throw new Error("Post not found.");
}
const updated_post = (posts[post_index] = {
...posts[post_index],
...data,
});
return updated_post;
},
deletePost: (parent, { id }) => {
const post_index = posts.findIndex((post) => post.id === id);
if (post_index === -1) {
throw new Error("Post not found.");
}
const deleted_post = posts[post_index];
posts.splice(post_index, 1);
return deleted_post;
},
deleteAllPosts: () => {
const length = posts.length;
posts.splice(0, length);
return {
count: length,
};
},
// Comment
createComment: (parent, { data }) => {
const comment = {
id: nanoid(),
...data,
};
comments.push(comment);
return comment;
},
updateComment: (parent, { id, data }) => {
const comment_index = comments.findIndex(
(comment) => comment.id === id
);
if (comment_index === -1) {
throw new Error("Comment not found.");
}
const updated_comment = (comments[comment_index] = {
...comments[comment_index],
...data,
});
return updated_comment;
},
deleteComment: (parent, { id }) => {
const comment_index = comments.findIndex(
(comment) => comment.id === id
);
if (comment_index === -1) {
throw new Error("Comment not found");
}
const deleted_comment = comments[comment_index];
comments.splice(comment_index, 1);
return deleted_comment;
},
deleteAllComments: () => {
const length = comments.length;
comments.splice(0, length);
return {
count: length,
};
},
},
Query: {
users: () => users,
user: (parent, args) => {
const user = users.find((user) => user.id === args.id);
if (!user) {
return new Error("User not found");
}
return user;
},
posts: () => posts,
post: (parent, args) => posts.find((post) => post.id === args.id),
comments: () => comments,
comment: (parent, args) =>
comments.find((comment) => comment.id === args.id),
},
User: {
posts: (parent, args) =>
posts.filter((post) => post.user_id === parent.id),
comments: (parent, args) =>
comments.filter((comment) => comment.user_id === parent.id),
},
Post: {
comments: (parent, args) =>
comments.filter((comment) => comment.post_id === parent.id),
user: (parent, args) =>
users.find((user) => user.id === parent.user_id),
},
Comment: {
post: (parent, args) =>
posts.find((post) => post.id === parent.post_id),
user: (parent, args) =>
users.find((user) => user.id === parent.user_id),
},
},
}),
});
const server = createServer(yoga); // server kur
server.listen(4000, () => {
console.info("Server is running on http://localhost:4000/graphql");
});
Yukarıdaki işlemi post ve comment için tekrarlıyoruz.
import { createYoga, createSchema, createPubSub } from "graphql-yoga";
import { createServer } from "node:http";
import { nanoid } from "nanoid";
import { users, posts, comments } from "./data.js";
const pubSub = createPubSub();
const yoga = createYoga({
// Şemayı sarmala
schema: createSchema({
typeDefs: `#graphql
# User
type User {
id: ID!
fullName: String!
age: Int!
posts: [Post]
comments: [Comment]
}
input CreateUserInput {
fullName: String!
age: Int!
}
input UpdateUserInput {
fullName: String
age: Int
}
# Post
type Post {
id: ID!
title: String!
user_id: ID!
comments: [Comment!]
user: User!
}
input CreatePostInput {
title: String!
user_id: ID!
}
input UpdatePostInput {
title: String
user_id: ID
}
# Comment
type Comment {
id: ID!
text: String!
post_id: ID!
post: Post!
user: User!
}
input CreateCommetInput{
text: String!
post_id: ID!
user_id: ID!
}
input UpdateCommentInput{
text: String
post_id: ID
user_id: ID
}
type DeleteAllOutput {
count: Int!
}
type Query {
users: [User!]!
user(id: ID!): User!
posts: [Post!]
post(id: ID!): Post!
comments: [Comment]
comment(id: ID!): Comment!
}
type Mutation {
# User
createUser(data: CreateUserInput!): User!
updateUser(id: ID!, data: UpdateUserInput!): User!
deleteUser(id: ID!): User!
deleteAllUsers: DeleteAllOutput!
# Post
createPost(data: CreatePostInput!): Post!
updatePost(id: ID!, data: UpdatePostInput!): Post!
deletePost(id: ID!): Post!
deleteAllPosts: DeleteAllOutput!
# Comment
createComment(data: CreateCommetInput!): Comment!
updateComment(id: ID!, data: UpdateCommentInput!): Comment!
deleteComment(id: ID!): Comment!
deleteAllComments: DeleteAllOutput!
}
type Subscription {
# count: Int! # Örnek için
# User
userCreated: User!
userUpdated: User!
userDeleted: User!
# Post
postCreated: Post!
postUpdated: Post!
postDeleted: Post!
postsCount: Int!
# Comment
commentCreated: Comment!
commentUpdated: Comment!
commentDeleted: Comment!
}
`,
resolvers: {
Subscription: {
// User
userCreated: {
subscribe: () => pubSub.subscribe("userCreated"),
},
userUpdated: {
subscribe: () => pubSub.subscribe("userUpdated"),
},
userDeleted: {
subscribe: () => pubSub.subscribe("userDeleted"),
},
// Post
postCreated: {
subscribe: () => pubSub.subscribe("postCreated"),
},
postUpdated: {
subscribe: () => pubSub.subscribe("postUpdated"),
},
postDeleted: {
subscribe: () => pubSub.subscribe("postDeleted"),
},
postsCount: {
subscribe: () => {
setTimeout(() => {
pubSub.publish("postsCount", { postsCount: posts.length })
}); // publish işlemi subscribe işleminden sonra olmalı. Bu nedenle geçikme koyduk
return pubSub.subscribe("postsCount");
},
},
// Comment
commentCreated: {
subscribe: () => pubSub.subscribe("commentCreated"),
},
commentUpdated: {
subscribe: () => pubSub.subscribe("commentUpdated"),
},
commentDeleted: {
subscribe: () => pubSub.subscribe("commentDeleted"),
},
},
Mutation: {
// User
createUser: (parent, args) => {
const user = {
id: nanoid(),
fullName: args.data.fullName,
age: args.data.age,
};
users.push(user);
pubSub.publish("userCreated", { userCreated: user });
return user;
},
updateUser: (parent, { id, data }) => {
const user_index = users.findIndex((user) => user.id === id);
if (user_index === -1) {
throw new Error("User not found.");
}
const update_user = (users[user_index] = {
...users[user_index],
...data,
});
pubSub.publish("userUpdated", { userUpdated: update_user }); //yayın yapıldı
return update_user;
},
deleteUser: (parent, { id }) => {
const user_index = users.findIndex((user) => user.id === id);
if (user_index === -1) {
throw new Error("User not found.");
}
const deleted_user = users[user_index];
users.splice(user_index, 1);
pubSub.publish("userDeleted", { userDeleted: deleted_user }); // yayın yapıldı
return deleted_user;
},
deleteAllUsers: () => {
const length = users.length;
users.splice(0, length);
return {
count: length,
};
},
// Post
createPost: (parent, { data: { title, user_id } }) => {
const post = {
id: nanoid(),
title,
user_id,
};
posts.push(post);
pubSub.publish("postCreated", { postCreated: post });
pubSub.publish("postsCount", { postsCount: posts.length });
return post;
},
updatePost: (parent, { id, data }) => {
const post_index = posts.findIndex((post) => post.id === id);
if (post_index === -1) {
throw new Error("Post not found.");
}
const updated_post = (posts[post_index] = {
...posts[post_index],
...data,
});
pubSub.publish("postUpdated", { postUpdated: updated_post });
return updated_post;
},
deletePost: (parent, { id }) => {
const post_index = posts.findIndex((post) => post.id === id);
if (post_index === -1) {
throw new Error("Post not found.");
}
const deleted_post = posts[post_index];
posts.splice(post_index, 1);
pubSub.publish("postDeleted", { postDeleted: deleted_post });
pubSub.publish("postsCount", { postsCount: posts.length });
return deleted_post;
},
deleteAllPosts: () => {
const length = posts.length;
posts.splice(0, length);
pubSub.publish("postsCount", { postsCount: posts.length });
return {
count: length,
};
},
// Comment
createComment: (parent, { data }) => {
const comment = {
id: nanoid(),
...data,
};
comments.push(comment);
pubSub.publish("commentCreated", { commentCreated: comment });
return comment;
},
updateComment: (parent, { id, data }) => {
const comment_index = comments.findIndex(
(comment) => comment.id === id
);
if (comment_index === -1) {
throw new Error("Comment not found.");
}
const updated_comment = (comments[comment_index] = {
...comments[comment_index],
...data,
});
pubSub.publish("commentUpdated", { commentUpdated: updated_comment });
return updated_comment;
},
deleteComment: (parent, { id }) => {
const comment_index = comments.findIndex(
(comment) => comment.id === id
);
if (comment_index === -1) {
throw new Error("Comment not found");
}
const deleted_comment = comments[comment_index];
comments.splice(comment_index, 1);
pubSub.publish("commentDeleted", { commentDeleted: deleted_comment });
return deleted_comment;
},
deleteAllComments: () => {
const length = comments.length;
comments.splice(0, length);
return {
count: length,
};
},
},
Query: {
users: () => users,
user: (parent, args) => {
const user = users.find((user) => user.id === args.id);
if (!user) {
return new Error("User not found");
}
return user;
},
posts: () => posts,
post: (parent, args) => posts.find((post) => post.id === args.id),
comments: () => comments,
comment: (parent, args) =>
comments.find((comment) => comment.id === args.id),
},
User: {
posts: (parent, args) =>
posts.filter((post) => post.user_id === parent.id),
comments: (parent, args) =>
comments.filter((comment) => comment.user_id === parent.id),
},
Post: {
comments: (parent, args) =>
comments.filter((comment) => comment.post_id === parent.id),
user: (parent, args) =>
users.find((user) => user.id === parent.user_id),
},
Comment: {
post: (parent, args) =>
posts.find((post) => post.id === parent.post_id),
user: (parent, args) =>
users.find((user) => user.id === parent.user_id),
},
},
}),
});
const server = createServer(yoga); // server kur
server.listen(4000, () => {
console.info("Server is running on http://localhost:4000/graphql");
});
Subscription sırasında paraametre ile filtreleme yapacağız.
biz v4 kullanıyoruz. hocanın methodu bizde işe yaramıyor. Bu nedenle dökümantasyondan buradan ve buradan faydalanarak kendi kodumuzu yazıyoruz.
import {
createYoga,
createSchema,
createPubSub,
filter,
pipe,
map,
} from "graphql-yoga"; //filter ve pipe subscription filtreleme için import edildi.
import { createServer } from "node:http";
import { nanoid } from "nanoid";
import { users, posts, comments } from "./data.js";
const pubSub = createPubSub();
const yoga = createYoga({
// Şemayı sarmala
schema: createSchema({
typeDefs: `#graphql
# User
type User {
id: ID!
fullName: String!
age: Int!
posts: [Post]
comments: [Comment]
}
input CreateUserInput {
fullName: String!
age: Int!
}
input UpdateUserInput {
fullName: String
age: Int
}
# Post
type Post {
id: ID!
title: String!
user_id: ID!
comments: [Comment!]
user: User!
}
input CreatePostInput {
title: String!
user_id: ID!
}
input UpdatePostInput {
title: String
user_id: ID
}
# Comment
type Comment {
id: ID!
text: String!
post_id: ID!
post: Post!
user: User!
}
input CreateCommetInput{
text: String!
post_id: ID!
user_id: ID!
}
input UpdateCommentInput{
text: String
post_id: ID
user_id: ID
}
type DeleteAllOutput {
count: Int!
}
type Query {
users: [User!]!
user(id: ID!): User!
posts: [Post!]
post(id: ID!): Post!
comments: [Comment]
comment(id: ID!): Comment!
}
type Mutation {
# User
createUser(data: CreateUserInput!): User!
updateUser(id: ID!, data: UpdateUserInput!): User!
deleteUser(id: ID!): User!
deleteAllUsers: DeleteAllOutput!
# Post
createPost(data: CreatePostInput!): Post!
updatePost(id: ID!, data: UpdatePostInput!): Post!
deletePost(id: ID!): Post!
deleteAllPosts: DeleteAllOutput!
# Comment
createComment(data: CreateCommetInput!): Comment!
updateComment(id: ID!, data: UpdateCommentInput!): Comment!
deleteComment(id: ID!): Comment!
deleteAllComments: DeleteAllOutput!
}
type Subscription {
# count: Int! # Örnek için
# User
userCreated: User!
userUpdated: User!
userDeleted: User!
# Post
postCreated(user_id: ID): Post! #postCreated parametre alacak şekilde düzenlendi.
postUpdated: Post!
postDeleted: Post!
postsCount: Int!
# Comment
commentCreated(post_id: ID): Comment!
commentUpdated: Comment!
commentDeleted: Comment!
}
`,
resolvers: {
Subscription: {
// User
userCreated: {
subscribe: () => pubSub.subscribe("userCreated"),
},
userUpdated: {
subscribe: () => pubSub.subscribe("userUpdated"),
},
userDeleted: {
subscribe: () => pubSub.subscribe("userDeleted"),
},
// Post
postCreated: {
subscribe: (parent, args) => {
return pipe(
// pipe 2 parametre alır.
pubSub.subscribe("postCreated"), // 1. parametre yayına abone olmak için kullanılan fonksiyon.
filter((value) => // 2 parametre filter. içi true dönerse değeri yakalar. yoksa es geçer.
args.user_id // args.user_id varsa
? value.postCreated.user_id === args.user_id // bu kıyaslama yapılır.
: true // args.user_id yoksa parametre geçilmemiştir. true döner.
)
);
},
},
postUpdated: {
subscribe: () => pubSub.subscribe("postUpdated"),
},
postDeleted: {
subscribe: () => pubSub.subscribe("postDeleted"),
},
postsCount: {
subscribe: () => {
setTimeout(() => {
pubSub.publish("postsCount", { postsCount: posts.length });
});
return pubSub.subscribe("postsCount");
},
},
// Comment
commentCreated: {
subscribe: (parent, args) => {
return pipe( // Yukarıdaki örneğin tekrarı.
pubSub.subscribe("commentCreated"),
filter((value) =>
args.post_id
? value.commentCreated.post_id === args.post_id
: true
)
);
},
},
commentUpdated: {
subscribe: () => pubSub.subscribe("commentUpdated"),
},
commentDeleted: {
subscribe: () => pubSub.subscribe("commentDeleted"),
},
},
Mutation: {
// User
createUser: (parent, args) => {
const user = {
id: nanoid(),
fullName: args.data.fullName,
age: args.data.age,
};
users.push(user);
pubSub.publish("userCreated", { userCreated: user });
return user;
},
updateUser: (parent, { id, data }) => {
const user_index = users.findIndex((user) => user.id === id);
if (user_index === -1) {
throw new Error("User not found.");
}
const update_user = (users[user_index] = {
...users[user_index],
...data,
});
pubSub.publish("userUpdated", { userUpdated: update_user });
return update_user;
},
deleteUser: (parent, { id }) => {
const user_index = users.findIndex((user) => user.id === id);
if (user_index === -1) {
throw new Error("User not found.");
}
const deleted_user = users[user_index];
users.splice(user_index, 1);
pubSub.publish("userDeleted", { userDeleted: deleted_user });
return deleted_user;
},
deleteAllUsers: () => {
const length = users.length;
users.splice(0, length);
return {
count: length,
};
},
// Post
createPost: (parent, { data: { title, user_id } }) => {
const post = {
id: nanoid(),
title,
user_id,
};
posts.push(post);
pubSub.publish("postCreated", { postCreated: post });
pubSub.publish("postsCount", { postsCount: posts.length });
return post;
},
updatePost: (parent, { id, data }) => {
const post_index = posts.findIndex((post) => post.id === id);
if (post_index === -1) {
throw new Error("Post not found.");
}
const updated_post = (posts[post_index] = {
...posts[post_index],
...data,
});
pubSub.publish("postUpdated", { postUpdated: updated_post });
return updated_post;
},
deletePost: (parent, { id }) => {
const post_index = posts.findIndex((post) => post.id === id);
if (post_index === -1) {
throw new Error("Post not found.");
}
const deleted_post = posts[post_index];
posts.splice(post_index, 1);
pubSub.publish("postDeleted", { postDeleted: deleted_post });
pubSub.publish("postsCount", { postsCount: posts.length });
return deleted_post;
},
deleteAllPosts: () => {
const length = posts.length;
posts.splice(0, length);
pubSub.publish("postsCount", { postsCount: posts.length });
return {
count: length,
};
},
// Comment
createComment: (parent, { data }) => {
const comment = {
id: nanoid(),
...data,
};
comments.push(comment);
pubSub.publish("commentCreated", { commentCreated: comment });
return comment;
},
updateComment: (parent, { id, data }) => {
const comment_index = comments.findIndex(
(comment) => comment.id === id
);
if (comment_index === -1) {
throw new Error("Comment not found.");
}
const updated_comment = (comments[comment_index] = {
...comments[comment_index],
...data,
});
pubSub.publish("commentUpdated", { commentUpdated: updated_comment });
return updated_comment;
},
deleteComment: (parent, { id }) => {
const comment_index = comments.findIndex(
(comment) => comment.id === id
);
if (comment_index === -1) {
throw new Error("Comment not found");
}
const deleted_comment = comments[comment_index];
comments.splice(comment_index, 1);
pubSub.publish("commentDeleted", { commentDeleted: deleted_comment });
return deleted_comment;
},
deleteAllComments: () => {
const length = comments.length;
comments.splice(0, length);
return {
count: length,
};
},
},
Query: {
users: () => users,
user: (parent, args) => {
const user = users.find((user) => user.id === args.id);
if (!user) {
return new Error("User not found");
}
return user;
},
posts: () => posts,
post: (parent, args) => posts.find((post) => post.id === args.id),
comments: () => comments,
comment: (parent, args) =>
comments.find((comment) => comment.id === args.id),
},
User: {
posts: (parent, args) =>
posts.filter((post) => post.user_id === parent.id),
comments: (parent, args) =>
comments.filter((comment) => comment.user_id === parent.id),
},
Post: {
comments: (parent, args) =>
comments.filter((comment) => comment.post_id === parent.id),
user: (parent, args) =>
users.find((user) => user.id === parent.user_id),
},
Comment: {
post: (parent, args) =>
posts.find((post) => post.id === parent.post_id),
user: (parent, args) =>
users.find((user) => user.id === parent.user_id),
},
},
}),
});
const server = createServer(yoga); // server kur
server.listen(4000, () => {
console.info("Server is running on http://localhost:4000/graphql");
});
Birden fazla sunucu kullanılması gereken durumlarda pubSub işlemlerinin sunucuda olması diğer sunucularda yapılan işlemleri okuyamamasına neden olur. Bu nedenle pubSub işlemleri sunucu dışında redis üzerinde tutulabilir.
Redis: bir veri yapısı sunucusudur. Açık kaynak, bellek kullanımlı, anahtar-değer deposudur. Redis "Uzak Sözlük Sunucusu" (İngilizce: "REmote DIctionary Server") anlamına gelmektedir. Çeşitli kaynaklara göre en çok kullanılan anahtar-değer veritabanıdır.
Bunun için graphql-redis-subscriptions kullanacağız. buradaki örneği baz alıyoruz.
terminale:
npm i graphql-redis-subscriptionsve
npm i ioredisyazıyoruz.
Hoca redis için heroku kullanmış. heruko artık free değil. Biz render kullanacağız.
new > redis > create redis
projemizin kök dizinine pubsub.js dosyası oluşturulur. İçine buradaki örneği yapıştırıyoruz. İlgili alanları da oluşturduğumuz redis serverına göre dolduruyoruz. External redis url
rediss://username:password@host:portkalıbına göre oluşturulmuştur. Buradaki veriler örneğe uygun olarak ayrıştırılır.
Kullanılan teknolojilerde çakışma var. Son sürümler çalışmıyor. Daha sonra tekrar denenecek.
birden fazla metod denendi. 5. saatin sonunda konu daha sonra irdelenmek üzere bırakıldı. Kullanılan iki pakette çakışma mevcut.
Kaynak kodumuzda görünmesini istemediğimiz bilgiler için .env adında bir dosya oluşturuyoruz. Bunun içinde
PASS="password1234"gibi tanımımızı yapıyoruz. Bu bilgileri kullanmak için terminale
npm i dotenvyazarak ilgili paketi kuruyoruz.
Bu bilgiyi kullanmak istediğimiz dosyada dotenv paketini import edip çalıştırıyoruz.
import dotenv from "dotenv"
dotenv.config()
Artık .env içindeki veriyi kullanabiliriz.
...
password: process.env.PASS
...
Bu ksımda index.js içindeki kodun daha düzenli ve okunaklı görünmesi için bazı kısımları başka dosyalara yükleyip import ettik. index.js ve data.js src klasörüne taşındı.
Son durumda:
index.js:
import { createYoga, createSchema, createPubSub } from "graphql-yoga";
import { createServer } from "node:http";
import * as fs from "fs";
import * as path from "path";
import { fileURLToPath } from "url";
import db from "./data.js"; // veriler import edildi
import resolvers from "./graphql/resolvers/index.js"; // resolvers import edildi
const pubSub = createPubSub();
const yoga = createYoga({
// Şemayı sarmala
schema: createSchema({
typeDefs: fs.readFileSync( // datanın olduğu dosyanın okunması
path.join(
path.dirname(fileURLToPath(import.meta.url)), // data yolunun ifade edilmesi
"graphql/schema.graphql"
),
"utf-8"
),
resolvers,
}),
context: {
pubSub,
db, // database context içinde geçildi.
},
});
const server = createServer(yoga); // server kur
server.listen(4000, () => {
console.info("Server is running on http://localhost:4000/graphql");
});
typeDefs için graphql/schema.qraphql dosyası oluşturuldu.
# User
type User {
id: ID!
fullName: String!
age: Int!
posts: [Post]
comments: [Comment]
}
input CreateUserInput {
fullName: String!
age: Int!
}
input UpdateUserInput {
fullName: String
age: Int
}
# Post
type Post {
id: ID!
title: String!
user_id: ID!
comments: [Comment!]
user: User!
}
input CreatePostInput {
title: String!
user_id: ID!
}
input UpdatePostInput {
title: String
user_id: ID
}
# Comment
type Comment {
id: ID!
text: String!
post_id: ID!
post: Post!
user: User!
}
input CreateCommetInput {
text: String!
post_id: ID!
user_id: ID!
}
input UpdateCommentInput {
text: String
post_id: ID
user_id: ID
}
type DeleteAllOutput {
count: Int!
}
type Query {
users: [User!]!
user(id: ID!): User!
posts: [Post!]
post(id: ID!): Post!
comments: [Comment]
comment(id: ID!): Comment!
}
type Mutation {
# User
createUser(data: CreateUserInput!): User!
updateUser(id: ID!, data: UpdateUserInput!): User!
deleteUser(id: ID!): User!
deleteAllUsers: DeleteAllOutput!
# Post
createPost(data: CreatePostInput!): Post!
updatePost(id: ID!, data: UpdatePostInput!): Post!
deletePost(id: ID!): Post!
deleteAllPosts: DeleteAllOutput!
# Comment
createComment(data: CreateCommetInput!): Comment!
updateComment(id: ID!, data: UpdateCommentInput!): Comment!
deleteComment(id: ID!): Comment!
deleteAllComments: DeleteAllOutput!
}
type Subscription {
# count: Int! # Örnek için
# User
userCreated: User!
userUpdated: User!
userDeleted: User!
# Post
postCreated(user_id: ID): Post! #postCreated parametre alacak şekilde düzenlendi.
postUpdated: Post!
postDeleted: Post!
postsCount: Int!
# Comment
commentCreated(post_id: ID): Comment!
commentUpdated: Comment!
commentDeleted: Comment!
}
data.js db adıyla import edildi ve pubSub ile birlikte context içinde geçildi. Bu sayede her resolver için ulaşılabilir kılındı.
src/graphql/resolvers dosyasında tüm resolver işlemleri ayrı ayrı tanımlandı. Tanımlarda data.js den gelen veri context.db üzerinden alındı. pubSub işlemleri contex.pubSub a göre düzenlendi. resolvers/index.js içine import edilip oradan topluca export edildi.
örnek resolvers için:
src/graphql/resolvers/Mutation.js
import { nanoid } from "nanoid";
const Mutation = {
// User
createUser: (_, args, { pubSub, db }) => {
const user = {
id: nanoid(),
fullName: args.data.fullName,
age: args.data.age,
};
db.users.push(user);
pubSub.publish("userCreated", { userCreated: user });
return user;
},
updateUser: (_, { id, data }, { pubSub, db }) => {
const user_index = db.users.findIndex((user) => user.id === id);
if (user_index === -1) {
throw new Error("User not found.");
}
const update_user = (db.users[user_index] = {
...db.users[user_index],
...data,
});
pubSub.publish("userUpdated", { userUpdated: update_user });
return update_user;
},
deleteUser: (_, __, { pubSub, db }) => {
const user_index = db.users.findIndex((user) => user.id === id);
if (user_index === -1) {
throw new Error("User not found.");
}
const deleted_user = db.users[user_index];
db.users.splice(user_index, 1);
pubSub.publish("userDeleted", { userDeleted: deleted_user });
return deleted_user;
},
deleteAllUsers: (_, __, { db }) => {
const length = db.users.length;
db.users.splice(0, length);
return {
count: length,
};
},
// Post
createPost: (_, { data: { title, user_id } }, { pubSub, db }) => {
const post = {
id: nanoid(),
title,
user_id,
};
db.posts.push(post);
pubSub.publish("postCreated", { postCreated: post });
pubSub.publish("postsCount", { postsCount: db.posts.length });
return post;
},
updatePost: (_, { id, data }, { pubSub, db }) => {
const post_index = db.posts.findIndex((post) => post.id === id);
if (post_index === -1) {
throw new Error("Post not found.");
}
const updated_post = (db.posts[post_index] = {
...db.posts[post_index],
...data,
});
pubSub.publish("postUpdated", { postUpdated: updated_post });
return updated_post;
},
deletePost: (_, { id }, { pubSub, db }) => {
const post_index = db.posts.findIndex((post) => post.id === id);
if (post_index === -1) {
throw new Error("Post not found.");
}
const deleted_post = db.posts[post_index];
db.posts.splice(post_index, 1);
pubSub.publish("postDeleted", { postDeleted: deleted_post });
pubSub.publish("postsCount", { postsCount: posts.length });
return deleted_post;
},
deleteAllPosts: (_, __, { pubSub, db }) => {
const length = db.posts.length;
db.posts.splice(0, length);
pubSub.publish("postsCount", { postsCount: db.posts.length });
return {
count: length,
};
},
// Comment
createComment: (_, { data }, { pubSub, db }) => {
const comment = {
id: nanoid(),
...data,
};
db.comments.push(comment);
pubSub.publish("commentCreated", { commentCreated: comment });
return comment;
},
updateComment: (_, { id, data }, { pubSub, db }) => {
const comment_index = db.comments.findIndex((comment) => comment.id === id);
if (comment_index === -1) {
throw new Error("Comment not found.");
}
const updated_comment = (db.comments[comment_index] = {
...db.comments[comment_index],
...data,
});
pubSub.publish("commentUpdated", { commentUpdated: updated_comment });
return updated_comment;
},
deleteComment: (_, { id }, { pubSub, db }) => {
const comment_index = db.comments.findIndex((comment) => comment.id === id);
if (comment_index === -1) {
throw new Error("Comment not found");
}
const deleted_comment = db.comments[comment_index];
db.comments.splice(comment_index, 1);
pubSub.publish("commentDeleted", { commentDeleted: deleted_comment });
return deleted_comment;
},
deleteAllComments: (_, __, { db }) => {
const length = db.comments.length;
db.comments.splice(0, length);
return {
count: length,
};
},
};
export default Mutation;
index.js:
import Comment from "./Comment.js"
import Mutation from "./Mutation.js"
import Post from "./Post.js"
import Query from "./Query.js"
import Subscription from "./Subscription.js"
import User from "./User.js"
export default {
Mutation,
Comment,
Post,
Query,
Subscription,
User
}
Bu kısımda tip tanımlarımızı otomatik merge edeceğiz. doc
resolver için de dökümanda merge uygulaması var ancak es6 için uygulanamıyor. Babeli kurduktan sonraki kısımda resolver merge işlemini tekrar yaptık 😄
terminale
npm i @graphql-tools/load-files @graphql-tools/mergeyazıp gereken paketleri yüklüyoruz.
dökümantasyondaki __dir ifadesi ES6 da çalışmıyor. path.join(__dirname) yerine path.dirname(fileURLToPath(import.meta.url)) ifadesini kullanıyoruz. Bunun çalışması için ise
import * as path from "path";
import { fileURLToPath } from "url";
import işlemlerinin yapılması gerekiyor.
tip tanımlarını src/graphql/type-defs klasörünün içine yerleştirdik ve her bir veri tipi için ilgili tip tanımını kendine ait bir dosyaya taşıdık.
Örnek olarak User.graphql
type Query {
users: [User!]!
user(id: ID!): User!
}
type Mutation {
createUser(data: CreateUserInput!): User!
updateUser(id: ID!, data: UpdateUserInput!): User!
deleteUser(id: ID!): User!
deleteAllUsers: DeleteAllOutput!
}
type Subscription {
userCreated: User!
userUpdated: User!
userDeleted: User!
}
type User {
id: ID!
fullName: String!
age: Int!
posts: [Post]
comments: [Comment]
}
input CreateUserInput {
fullName: String!
age: Int!
}
input UpdateUserInput {
fullName: String
age: Int
}
Bu tanımları birleştirmesi (merge) içib src/type-defs/index.js içine:
import * as path from "path";
import { fileURLToPath } from "url";
import { loadFilesSync } from '@graphql-tools/load-files'
import { mergeTypeDefs } from '@graphql-tools/merge'
const typesArray = loadFilesSync(path.dirname(fileURLToPath(import.meta.url)), { extensions: ['graphql'] })
export default mergeTypeDefs(typesArray)
src index.js içinde import edilir ve kullanılır.
import { createYoga, createSchema, createPubSub } from "graphql-yoga";
import { createServer } from "node:http";
import db from "./data.js";
import resolvers from "./graphql/resolvers/index.js";
import typeDefs from "./graphql/type-defs/index.js";
const pubSub = createPubSub();
const yoga = createYoga({
schema: createSchema({
typeDefs,
resolvers,
}),
context: {
pubSub,
db,
},
});
const server = createServer(yoga); // server kur
server.listen(4000, () => {
console.info("Server is running on http://localhost:4000/graphql");
});
Build işlemini gerçekleştireceğiz. Bunun için babel kullanacağız.
Babel nedir? Babel, ECMAScript 2015+ kodunu mevcut ve eski tarayıcılarda veya ortamlarda geriye dönük olarak uyumlu bir JavaScript sürümüne dönüştürmek için kullanılan bir araçtır.
Babel sayesinde daha önce ES6 da çalışmayan söz dizimleri ile ES6 bir arada yazılabilir.
Babeljs.io içinde setup>nodemon
terminale
npm install @babel/core @babel/node --save-devve
npm install @babel/preset-env --save-devve build için:
npm install --save-dev @babel/cli
package.json dosyasında scripts>dev alanı aşağıdaki gibi güncellendi ve "type": "module" ifadesi kaldırıldı.
"scripts": {
...
"dev": "nodemon --exec babel-node ./src/index.js",
...
},
kök dizine .babelrc adında bir dosya oluşturuldu ve içine:
{
"presets": ["@babel/preset-env"]
}
eklendi.
son nanoid versiyonu ES6 dışında çalışmıyor. Bu nedenle önceki bir sürüm yoklendi.
npm uninstall nanoid
npm install nanoid@3.3.4
ES6 dışında çalışmayan kodlardan kurtulmak için graphql/type-defs/index.js düzenlendi.
import * as path from "path";
import { loadFilesSync } from '@graphql-tools/load-files'
import { mergeTypeDefs } from '@graphql-tools/merge'
// const typesArray = loadFilesSync(path.dirname(fileURLToPath(import.meta.url)), { extensions: ['graphql'] })
const typesArray = loadFilesSync(path.join(__dirname), { extensions: ['graphql'] }) // dir ifadesi babel sayesinde çalışıyor.
export default mergeTypeDefs(typesArray)
package.json script alanı düzenlendi. package.json son hali:
{
"name": "comment-challenge",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "node ./dist/index.js",
"dev": "nodemon --exec babel-node ./src/index.js",
"build": "babel ./src --out-dir dist --minified --copy-files"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@apollo/server": "^4.7.1",
"@graphql-tools/load-files": "^7.0.0",
"@graphql-tools/merge": "^9.0.0",
"@graphql-yoga/node": "^3.9.1",
"@graphql-yoga/redis-event-target": "2.0.0",
"graphql": "^16.6.0",
"graphql-redis-subscriptions": "^2.6.0",
"graphql-subscriptions": "^2.0.0",
"graphql-ws": "^5.13.1",
"graphql-yoga": "^3.9.2-rc-20230524133912-835c7e3d",
"ioredis": "5.3.2",
"nanoid": "^3.3.4",
"redis": "^4.6.7"
},
"devDependencies": {
"@babel/cli": "^7.22.5",
"@babel/core": "^7.22.5",
"@babel/node": "^7.22.5",
"@babel/preset-env": "^7.22.5",
"babel-plugin-module-resolver": "^5.0.0",
"nodemon": "^2.0.22"
}
}
"build": "babel ./src --out-dir dist --minified --copy-files" kodunda --minified build edilen tüm dosyalarda kodu tek satıra toplar. --copy-files ise o olmadan kopyalanmayan nonjs dosyaları da build içine kopyalar
build işlemi ile developer tarafındaki ürün product tarafına hazır hale getirilir. Bu işlem için terminale
npm run buildyazılır. disc adında bir klasöre build oluşturulur.
import sırasında dosya dizini bulmayı kolaylaştırmak için dosya yollarına takma ad vermek için babel-plugin-module-resolver kullanılabilir. bunun için terminale
npm install --save-dev babel-plugin-module-resolver
.babelrc içine kısayollar tanımlanır. .babelrc son hali:
{
"presets": ["@babel/preset-env"],
"plugins": [
["module-resolver", {
"root": ["./src"],
"alias": {
"@graphql": "./src/graphql",
"@resolvers": "./src/graphql/resolvers",
"@type-defs": "./src/graphql/type-defs"
}
}]
]
}
Buna göre src/index.js:
import { createYoga, createSchema, createPubSub } from "graphql-yoga";
import { createServer } from "node:http";
import db from "./data.js";
import resolvers from "@resolvers"; //
import typeDefs from "@type-defs"; //
const pubSub = createPubSub();
const yoga = createYoga({
schema: createSchema({
typeDefs,
resolvers,
}),
context: {
pubSub,
db,
},
});
const server = createServer(yoga);
server.listen(4000, () => {
console.info("Server is running on http://localhost:4000/graphql");
});
graphql/resolvers altına bir resolvers klasörü daha açılır ve index.js harici dosyalaor oraya taşınır.
graphql/resolvers/index.js aşağıdaki gibi düzenlenir.
import path from "path";
import { mergeResolvers } from "@graphql-tools/merge";
import { loadFilesSync } from "@graphql-tools/load-files";
const resolversArray = loadFilesSync(path.join(__dirname, "/resolvers"), {
extensions: ["js"],
extractExports: (fileExport) => { // bu kod index.js ile aynı dizinde resolverler olması durumunda index.js dosyasını merge dışında tutmak için yazıldı ama çalışmıyor. Bu nedenle resolverlar başka bir klasöre taşındı.
if (typeof fileExport === "function") {
return fileExport("query_root");
}
return fileExport;
},
});
export default mergeResolvers(resolversArray);
merge işleminin düzgün yapılabilmesi için export default ... ifadesi yerine export const ... kalıbı kullanılır. örnek: src/graphql/resolvers/resolvers/Mutation.js
import { nanoid } from "nanoid";
export const Mutation = {
// User
createUser: (_, args, { pubSub, db }) => {
const user = {
id: nanoid(),
fullName: args.data.fullName,
age: args.data.age,
};
db.users.push(user);
pubSub.publish("userCreated", { userCreated: user });
return user;
},
updateUser: (_, { id, data }, { pubSub, db }) => {
const user_index = db.users.findIndex((user) => user.id === id);
if (user_index === -1) {
throw new Error("User not found.");
}
const update_user = (db.users[user_index] = {
...db.users[user_index],
...data,
});
pubSub.publish("userUpdated", { userUpdated: update_user });
return update_user;
},
deleteUser: (_, __, { pubSub, db }) => {
const user_index = db.users.findIndex((user) => user.id === id);
if (user_index === -1) {
throw new Error("User not found.");
}
const deleted_user = db.users[user_index];
db.users.splice(user_index, 1);
pubSub.publish("userDeleted", { userDeleted: deleted_user });
return deleted_user;
},
deleteAllUsers: (_, __, { db }) => {
const length = db.users.length;
db.users.splice(0, length);
return {
count: length,
};
},
// Post
createPost: (_, { data: { title, user_id } }, { pubSub, db }) => {
const post = {
id: nanoid(),
title,
user_id,
};
db.posts.push(post);
pubSub.publish("postCreated", { postCreated: post });
pubSub.publish("postsCount", { postsCount: db.posts.length });
return post;
},
updatePost: (_, { id, data }, { pubSub, db }) => {
const post_index = db.posts.findIndex((post) => post.id === id);
if (post_index === -1) {
throw new Error("Post not found.");
}
const updated_post = (db.posts[post_index] = {
...db.posts[post_index],
...data,
});
pubSub.publish("postUpdated", { postUpdated: updated_post });
return updated_post;
},
deletePost: (_, { id }, { pubSub, db }) => {
const post_index = db.posts.findIndex((post) => post.id === id);
if (post_index === -1) {
throw new Error("Post not found.");
}
const deleted_post = db.posts[post_index];
db.posts.splice(post_index, 1);
pubSub.publish("postDeleted", { postDeleted: deleted_post });
pubSub.publish("postsCount", { postsCount: posts.length });
return deleted_post;
},
deleteAllPosts: (_, __, { pubSub, db }) => {
const length = db.posts.length;
db.posts.splice(0, length);
pubSub.publish("postsCount", { postsCount: db.posts.length });
return {
count: length,
};
},
// Comment
createComment: (_, { data }, { pubSub, db }) => {
const comment = {
id: nanoid(),
...data,
};
db.comments.push(comment);
pubSub.publish("commentCreated", { commentCreated: comment });
return comment;
},
updateComment: (_, { id, data }, { pubSub, db }) => {
const comment_index = db.comments.findIndex((comment) => comment.id === id);
if (comment_index === -1) {
throw new Error("Comment not found.");
}
const updated_comment = (db.comments[comment_index] = {
...db.comments[comment_index],
...data,
});
pubSub.publish("commentUpdated", { commentUpdated: updated_comment });
return updated_comment;
},
deleteComment: (_, { id }, { pubSub, db }) => {
const comment_index = db.comments.findIndex((comment) => comment.id === id);
if (comment_index === -1) {
throw new Error("Comment not found");
}
const deleted_comment = db.comments[comment_index];
db.comments.splice(comment_index, 1);
pubSub.publish("commentDeleted", { commentDeleted: deleted_comment });
return deleted_comment;
},
deleteAllComments: (_, __, { db }) => {
const length = db.comments.length;
db.comments.splice(0, length);
return {
count: length,
};
},
};
Daha önceki tüm yapımızı kök dizindeki server dosyasına taşıdık.
Kök dizinde client dosyası içine react kuruludu. Terminale
npx create-react-app .
Apollo Client, GraphQL ile uzak ve yerel verilerin yönetimini basitleştiren bir durum yönetimi kütüphanesidir. Özelliklerini zamanla öğreneceğiz.
client içine apollo client kuruldu. terminale
npm install @apollo/client graphql
apollo client'in client içine importu için bu döküman uygulandı.
client/src/index.js
import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import App from "./App";
import {
ApolloProvider
} from "@apollo/client";
import client from "./apollo";
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
<ApolloProvider client={client}>
<App />
</ApolloProvider>,
);
client/src/apollo.js
import { ApolloClient, InMemoryCache } from "@apollo/client";
const client = new ApolloClient({
uri: "http://localhost:4000/graphql", // server tarafının verdiği endpoint
cache: new InMemoryCache(),
});
export default client
chore için apollo client devtools eklentisi.
Kullanıcı tarafının görüntüsünün ayarlanmasına başlandı.
Bunun için ant design kullanacağız.
terminale
npm install antd
client/src/App.js dosyası client/src/components/App/index.js olarak göüncellendi. İçine:
import { Col, Row } from "antd";
import styles from "./styles.module.css"; // stil tanımları
import { Avatar, List, Skeleton } from "antd";
const data = [ // placeholder data
{
gender: "female",
name: {
title: "Miss",
first: "Tanise",
last: "Monteiro",
},
email: "tanise.monteiro@example.com",
picture: {
large: "https://randomuser.me/api/portraits/women/82.jpg",
medium: "https://randomuser.me/api/portraits/med/women/82.jpg",
thumbnail: "https://randomuser.me/api/portraits/thumb/women/82.jpg",
},
nat: "BR",
},
];
function App() {
return (
<div className={styles.container}>
<Row justify="center">
<Col span={14} className={styles.content}>
<List
className="demo-loadmore-list"
loading={false}
itemLayout="horizontal"
// loadMore={loadMore}
dataSource={data}
renderItem={(item) => (
<List.Item>
<Skeleton avatar title={false} loading={item.loading} active>
<List.Item.Meta
avatar={<Avatar src={item.picture.large} />}
title={<a href="https://ant.design">{item.name?.last}</a>}
description="Ant Design, a design language for background applications, is refined by Ant UED Team"
/>
</Skeleton>
</List.Item>
)}
/>
</Col>
</Row>
</div>
);
}
export default App;
Stil tanımları için client/src/components/App/styles.module.css içi aşağıdaki gibi düzenlenir.
.container{
height: 100vh;
}
.content{
background-color: #fff;
padding: 24px;
margin: 24px;
border: solid 4px bisque;
}
App.js lokasyonundaki değişiklik src/index.js içindeki import satırında güncellenir.
...
import App from "./components/App";
...
src/index.css
body {
background-color: #282c34;
}
terminale
npm i react-router-dom
hoca v5 üzerinden anlatıyor. Biz v6 kullanıyoruz. React notlarımda detay mevcut.
Router sarmalama işlemi App.js üzerinde yapılabilir. Hoca index.js üzerinden yapmayı tercih etti.
import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import App from "./components/App";
import { BrowserRouter as Router } from "react-router-dom";
import { ApolloProvider } from "@apollo/client";
import client from "./apollo";
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
<ApolloProvider client={client}>
<Router>
{/* Router sarmalaması App.js yerine burada yapıldı.*/}
<App />
</Router>
</ApolloProvider>
);
Sayfa yapısını düzenlemek için src/pages altına Home/index.js ve NewPost/index.js oluşturuldu. NewPost/index.js placeholder olarak bırakıldı. Home/index.js içine App/index.js içindeki List yapısı taşındı.
import React from "react";
import { Avatar, List, Skeleton } from "antd";
const data = [
// placeholder data
{
gender: "female",
name: {
title: "Miss",
first: "Tanise",
last: "Monteiro",
},
email: "tanise.monteiro@example.com",
picture: {
large: "https://randomuser.me/api/portraits/women/82.jpg",
medium: "https://randomuser.me/api/portraits/med/women/82.jpg",
thumbnail: "https://randomuser.me/api/portraits/thumb/women/82.jpg",
},
nat: "BR",
},
];
function Home() {
return (
<div>
<List
className="demo-loadmore-list"
loading={false}
itemLayout="horizontal"
// loadMore={loadMore}
dataSource={data}
renderItem={(item) => (
<List.Item>
<Skeleton avatar title={false} loading={item.loading} active>
<List.Item.Meta
avatar={<Avatar src={item.picture.large} />}
title={<a href="https://ant.design">{item.name?.last}</a>}
description="Ant Design, a design language for background applications, is refined by Ant UED Team"
/>
</Skeleton>
</List.Item>
)}
/>
</div>
);
}
export default Home;
src/components/App/index.js dosyasıda router yapısı kuruldu.
import { Col, Row } from "antd";
import { Routes, Route } from "react-router-dom";
import styles from "./styles.module.css";
import HeaderMenu from "./HeaderMenu";
// pages
import Home from "pages/Home"; // react absolute path ile kök dizin src olarak ayarlandı.
import NewPost from "pages/NewPost";
function App() {
return (
<div className={styles.container}>
<Row justify="center">
<Col span={14}>
<HeaderMenu />
<div className={styles.content}>
<Routes>
<Route path="/new" element={<NewPost />} />
<Route path="/" element={<Home />} />
</Routes>
</div>
</Col>
</Row>
</div>
);
}
export default App;
kök dizini src yapmak için react absolute path yapısı kurgulandı. Bunun için client/jsconfig.json dosyası oluşturuldu. içine
{
"compilerOptions": {
"baseUrl": "src"
}
}
App/index.js içinde kullanılan HeaderMenu App/HeaderMenu.js içinde oluşturuldu.
import React from "react";
import { Menu } from "antd";
import styles from "./styles.module.css";
import { Link, useLocation } from "react-router-dom";
const items = [
{
label: (
<Link to="/" className={styles.menuItem}>
Home
</Link>
),
key: "/",
},
{
label: (
<Link to="/new" className={styles.menuItem}>
New
</Link>
),
key: "/new",
},
];
function HeaderMenu() {
const location = useLocation(); // açık olan sayfanın bilgisini verir.
return (
<Menu
mode="horizontal"
items={items}
className={styles.headerMenu}
selectedKeys={location.pathname}
/>
);
}
export default HeaderMenu;
stil tanımları için componenets/App/styles.module.css içinde:
.container{
height: 100vh;
}
.content{
background-color: #fff;
padding: 24px;
/* margin: 24px; */
border: solid 4px bisque;
}
.headerMenu{
background: none;
border: none;
}
.menuItem{
font-weight: bold;
font-size: 16px;
}
src/index.css içinde
body {
background-color: #282c34;
}
.ant-menu-item:hover::after{
border-bottom-color: bisque !important
}
.ant-menu-item-selected::after{
border-bottom-color: bisque !important
}
.ant-menu-item-selected .ant-menu-title-content a {
color: bisque !important
}
.ant-menu-item:hover::after .ant-menu-title-content {
color: #f8f8f8
}
.ant-menu-horizontal > .ant-menu-item a:hover {
color: bisque;
}
.ant-menu-horizontal > .ant-menu-item a {
color: #f8f8f8;
}
Client tarafında daha iyi bir görsel yakalamak için server tarafında post datasının ve type-def tanımının içine description
, user fatasının ve type-def tanımının içine pfofile_photo
tanımları eklendi
pages/Home/queries.js dosyası oluşturuldu ve içinde sorgumuz tanımlandı.
import { gql } from "@apollo/client";
export const GET_POSTS = gql`
query getAllPosts {
posts {
id
title
description
user {
profile_photo
}
}
}
`;
pages/Home/index.js içinde useQuery ile sorgudan data alındı ve işlendi. useQuery data dışında loading ve error durumlarını da bize veriyor.
import React from "react";
import { Avatar, List } from "antd";
import { useQuery } from "@apollo/client";
import Loading from "components/Loading";
import { GET_POSTS } from "./queries";
function Home() {
const { loading, error, data } = useQuery(GET_POSTS);
if (loading) {
return <Loading />;
}
if (error) {
return <div>Error: {error.message}</div>;
}
console.log(data);
return (
<div>
<List
className="demo-loadmore-list"
loading={false}
itemLayout="horizontal"
// loadMore={loadMore}
dataSource={data.posts}
renderItem={(item) => (
<List.Item>
<List.Item.Meta
avatar={<Avatar src={item.user.profile_photo} />}
title={<a href="https://ant.design">{item.title}</a>}
description={item.description}
/>
</List.Item>
)}
/>
</div>
);
}
export default Home;
components/Loading/index.js içinde loading durumunda gösterilecek spinner tanımlandı.
import React from "react";
import { LoadingOutlined } from "@ant-design/icons";
import { Spin } from "antd";
import styles from "./styles.module.css"
function Loading() {
return (
<div className={styles.loading}>
<Spin indicator={<LoadingOutlined style={{ fontSize: 32 }} spin />} />
</div>
);
}
export default Loading;
component/Loading/styles.module.css içinde loading spinnerı için stil tanımı yapıldı.
.loading{
display: flex;
align-items: center;
justify-content: center;
}
server tarafında posts datasına ve type-defs kısmına cover
tanımları eklendi.
App/index.js içinde Routes altında yeni route oluşturuldu.
...
<Routes>
<Route path="/new" element={<NewPost />} />
<Route path="/post/:id" element={<Post />} />
<Route path="/" element={<Home />} />
</Routes>
...
Home/index.js alanında Link kompanenti ile bağlantılar oluşturuldu.
...
import { Link } from "react-router-dom";
...
function Home() {
...
return (
<div>
<List
className="demo-loadmore-list"
loading={false}
itemLayout="horizontal"
// loadMore={loadMore}
dataSource={data.posts}
renderItem={(item) => (
<List.Item>
<List.Item.Meta
avatar={<Avatar src={item.user.profile_photo} />}
title={
<Link to={`/post/${item.id}`} className={styles.listTitle}>
{item.title}
</Link>
}
description={
<Link to={`/post/${item.id}`} className={styles.listItem}>
{item.description}
</Link>
}
/>
</List.Item>
)}
/>
</div>
);
}
export default Home;
Home/styles.module.css içinde linlker için stil tanımları yapıldı.
.listItem{
color: rgba(0, 0, 0, 0.45);
font-size: 14px;
line-height: 1,5715;
}
.listItem:hover{
color: rgba(0, 0, 0, 0.6);
}
.listTitle{
margin-bottom: 4px;
color: rgba(0, 0, 0, 0.85);
font-size: 14px;
line-height: 1,5715;
}
.listTitle:hover{
color: rgba(0, 0, 0, 0.9) !important;
}
pages/Post dosyası oluşturuldu. İçinde index.js ve queries.js dosyası oluşturuldu.
queries.js içinde query tanımı eklendi.
import { gql } from "@apollo/client";
export const GET_POST = gql`
query post($id: ID!) {
post(id: $id) {
id
title
description
cover
user {
id
fullName
}
}
}
`;
index.js içinde de query import edildi ve useQuery ile variables girilerek kullanıldı.
import Loading from "components/Loading";
import { useParams } from "react-router-dom";
import { useQuery } from "@apollo/client";
import { GET_POST } from "./queries";
import { Typography, Image } from "antd";
const { Title } = Typography;
function Post() {
const { id } = useParams();
const { loading, error, data } = useQuery(GET_POST, {
variables: {
id,
},
});
if (loading) {
return <Loading />;
}
if (error) {
return <div>Error: {error.message}</div>;
}
console.log(data);
const { post } = data;
return (
<div>
<Title level={3}>{post.title}</Title>
<Image
src={post.cover}
/>
<div>{post.description}</div>
</div>
);
}
export default Post;
post datası ve type-def tanımlarına short_description
alanı oluşturuldu. Bu short_description client tarafında Home için query ile sorgulandı ve gelen veri daha önce description tanımı olan yerde kullanıldı.
Post detaylarının olduğu sayfada description alanı için stil tanımı yapıldı. Post/index.js
import styles from "./styles.module.css";
...
function Post() {
...
return (
...
<div className={styles.description}>{post.description}</div>
</div>
);
}
export default Post;
Post/styles.module.css
.description{
font-size: 1.1rem;
margin: 10px;
color: #666;
}
Genel görünümü güzelleştirmek için App/index.js için stil tanımı yapıldı.
...
function App() {
return (
...
<Row justify="center">
<Col span={14} className={styles.col}>
...
App/styles.module.css
...
.col{
margin: 10px 0 30px 0
}
Post sayfasında yorumların sayfadaki show comments
butonuna basılınca gösteileceği bir durum kurguladık.
Hoca butonu tıklandıktan sonra kaybetmek için farklı bir yöntem kullandı. Ben daha farklı yaptım.
Post/queries.js içine ihtiyacımız olan bilgileri çekmek için sorgu yazıldı.
import { gql } from "@apollo/client";
...
export const GET_POST_COMMENTS = gql`
query postComments($id: ID!) {
post(id: $id) {
comments {
id
text
user {
id
fullName
profile_photo
}
}
}
}
`;
Post/Comments.js dosyası oluşturuldu. İçinde sorgu için butonla tetiklenen useLazyQuery hooku kullanıldı.
import Loading from "components/Loading";
import { Divider, Button } from "antd";
import styles from "./styles.module.css";
import { useLazyQuery } from "@apollo/client";
import { GET_POST_COMMENTS } from "./queries";
import { Avatar, List } from "antd";
function Comments({ post_id }) {
const [
loadComments, // sorguyu başlatacak olan fonksiyon
{
called, // sorgu başladı mı?
loading, // yükleniyor mu?
data, // gelen veri
},
] = useLazyQuery(
GET_POST_COMMENTS,
{ variables: { id: post_id } } // sorgu için gönderilen değişken
);
if (called && loading) return <Loading />;
return (
<>
<Divider>Comments</Divider>
{!called && (
<div className={styles.showCommentsButton}>
<Button loading={loading} onClick={() => loadComments()}>
Show Comments
</Button>
</div>
)}
{!loading && data && (
<div>
<List
className="demo-loadmore-list"
loading={false}
itemLayout="horizontal"
// loadMore={loadMore}
dataSource={data.post.comments}
renderItem={(item) => (
<List.Item>
<List.Item.Meta
avatar={<Avatar src={item.user.profile_photo} />}
title={item.user.fullName}
description={item.text}
/>
</List.Item>
)}
/>
</div>
)}
>
);
}
export default Comments;
Comments.js içindeki butona styles.module.css içinde stil tanımı yapıldı.
...
.showCommentsButton{
text-align: center;
}
Post/index.js içindeki kompanentin en altına Comments kompanenti yerleştirildi. Bu sırada props olarak gönderilen veri Comments kompanentinde karşılandı ve sorgu için kullanıldı.
...
import Comments from "./Comments";
...
function Post() {
...
return (
<div>
...
<Comments post_id={id}/>
</div>
);
}
export default Post;
Hoca websoket için client/src/apollo.js içinde değişiklik yaptı.
Apollo client sürüm 3.7.11 ve sonrası için HTTP üzerinden çok parçalı abonelikleri destekler. Bu nedenle apollo.js içinde değişiklik yapmaya gerek kalmaz.
server tarafında resolvers kısmında createPost mutasyonuna verinin son haline uyumlu olması için modif yapıldı.
Home/queries.js alanına POSTS_SUBSCRIPTION
sorgusu eklendi.
import { gql } from "@apollo/client";
...
export const POSTS_SUBSCRIPTION = gql`
subscription MySubscription {
postCreated {
id
title
short_description
user {
profile_photo
}
}
}
`;
Home/index.js içinde aşağıdaki güncellem ile subscription kullanıldı.
import { useEffect } from "react";
...
import { GET_POSTS, POSTS_SUBSCRIPTION } from "./queries";
...
function Home() {
const {
loading,
error,
data,
subscribeToMore, // dinlemek için gereken fonksiyon
} = useQuery(GET_POSTS);
useEffect(() => {
// veri değiştiğinde reactta kullanılması için useEffect hooku kullanıldı.
subscribeToMore({
document: POSTS_SUBSCRIPTION, // dinlenilecek sorgu
updateQuery: (
prev, // mevcut durum
{ subscriptionData } // dinlenen kanaldan gelen
) => {
if (!subscriptionData.data) return prev;
return {
posts: [subscriptionData.data.postCreated, ...prev.posts], // posts tanımı cache olarak tutulan veri keyinden alındı
};
},
});
}, [subscribeToMore]);
if (loading) {
return <Loading />;
}
if (error) {
return <div>Error: {error.message}</div>;
}
return (
<div>
...
</div>
);
}
export default Home;
components/App/HeaderMenu.js kompanenti components/HeaderMenu/index.js olarak taşındı. Gereken import ve style tanımları düzenlendi.
components/PostCounter/queries.js oluşturuldu ve içine subscription sorgusu eklendi.
import { gql } from "@apollo/client";
export const POST_COUNT_SUBSCRİPTİON = gql`
subscription {
postsCount
}
`;
components/PostCounter/index.js oluşturuldu. queries.js içindeki sorgu import edildi ve useSubscription() hooku ile abone olundu.:
import styles from "./styles.module.css";
import { Badge, Avatar } from "antd";
import { useSubscription } from "@apollo/client";
import { POST_COUNT_SUBSCRİPTİON } from "./queries";
function PostCounter() {
const {loading, data} = useSubscription(POST_COUNT_SUBSCRİPTİON);
return (
<div className={styles.container}>
<Badge count={loading ? "?" : data.postsCount }>
<Avatar shape="square" size="medium">
<span className={styles.counterTitle}>Posts</span>
</Avatar>
</Badge>
</div>
);
}
export default PostCounter;
App/index.js içinde daha önce HeaderMenu kompanentinin tek başına yer aldığı kısım Row/Col yapısyla bölündü ve içine PostCounter kompanenti eklendi.
...
import PostCounter from "components/PostCounter";
function App() {
return (
<div className={styles.container}>
<Row justify="center">
<Col span={14} className={styles.col}>
<Row>
<Col span={18}>
<HeaderMenu />
</Col>
<Col span={6}>
<PostCounter />
</Col>
</Row>
...
</Col>
</Row>
</div>
);
}
export default App;
Post içinde comment post_id parametresi ile abonelik oluşturacak sorguyu Post/queries.js içine ekledik.
import { gql } from "@apollo/client";
...
export const COMMENTS_SUBSCRIPTIONS = gql`
subscription ($postId: ID) {
commentCreated(post_id: $postId) {
id
text
user {
id
fullName
profile_photo
}
}
}
`;
Post/Comments.js alanında tıklayınca sorgu başlatan useLazyQuery
hooku içinden subscribeToMore fonksiyonunu çektik ve useEffect
hooku ile kullandık.
import { useEffect } from "react";
function Comments({ post_id }) {
const [loadComments, { called, loading, data, subscribeToMore }] =
useLazyQuery(GET_POST_COMMENTS, { variables: { id: post_id } });
useEffect(() => {
if (!loading && called) {
subscribeToMore({
document: COMMENTS_SUBSCRIPTIONS,
updateQuery: (prev, { subscriptionData }) => {
if (!subscriptionData.data) return prev;
return {
post: {
...prev.post,
comments: [
...prev.post.comments,
subscriptionData.data.commentCreated,
],
},
};
},
});
}
}, [loading, called, subscribeToMore]);
if (called && loading) return <Loading />;
return (
...
);
}
export default Comments;
Birden fazla query içinde aynı tanımları çekmemiz gerekti. İleride birini güncellememiz gerektiğinde hepsini güncellememiz gerekecek. Bu gibi durumlarda aksaklık yaşanmaması ve işin kolaylaşması için sorgu parametreleri fragment yapılarına alınabilir.
Post/queries.js içinde sorguda aynı kullanılan parametreler fragment içinde toplandı.
const commentFragment = gql`
fragment CommentFragment on Comment {
# commentsFragment içinde CommentsFragment adıyla aşağıdaki parametreleri saklar.
id
text
user {
id
fullName
profile_photo
}
}
`;
Toplanan parametreler ilgili sorgulara import edildi ve kullanıldı.
export const GET_POST_COMMENTS = gql`
query postComments($id: ID!) {
post(id: $id) {
comments {
...CommentFragment
# Bu kısım değişkenleri yerleştirmek için
}
}
}
${commentFragment}
# bu kısım değişkeni import etmek için
`;
export const COMMENTS_SUBSCRIPTIONS = gql`
subscription ($postId: ID) {
commentCreated(post_id: $postId) {
...CommentFragment
}
}
${commentFragment}
`;
Aynı uygulamanın Home/queries.js versiyonu
import { gql } from "@apollo/client";
const postFragments = gql`
fragment PostFragmens on Post {
id
title
short_description
user {
profile_photo
}
}
`;
export const GET_POSTS = gql`
query getAllPosts {
posts {
...PostFragmens
}
}
${postFragments}
`;
export const POSTS_SUBSCRIPTION = gql`
subscription MySubscription {
postCreated {
...PostFragmens
}
}
${postFragments}
`;
pages/NewPost/NewPostForm.js dosyası oluşturuldu. İçine:
import React from "react";
import { Button, Checkbox, Form, Input, Select } from "antd";
const { Option } = Select;
function NewPostForm() {
return (
<Form
name="basic"
initialValues={{
remember: true,
}}
// onFinish={onFinish}
// onFinishFailed={onFinishFailed}
autoComplete="off"
>
<Form.Item
// label="Username"
name="username"
rules={[
{
required: true,
message: "Please input your username!",
},
]}
>
<Input size="large" placeholder="Title" />
</Form.Item>
<Form.Item name="shortDescription">
<Input size="large" placeholder="Short description" />
</Form.Item>
<Form.Item name="description">
<Input.TextArea size="large" placeholder="Description" />
</Form.Item>
<Form.Item name="cover">
<Input size="large" placeholder="Cover" />
</Form.Item>
<Form.Item
name="user"
rules={[
{
required: true,
message: "Please select user!",
},
]}
>
<Select size="large" placeholder="Select your user">
<Option value="male">Male</Option>
<Option value="female">Female</Option>
<Option value="other">Other</Option>
</Select>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
Submit
</Button>
</Form.Item>
</Form>
);
}
export default NewPostForm;
Oluşturlan kompanent pages/NewPost/index.js içine import edildi.
import React from "react";
import { Typography } from "antd";
import NewPostForm from "./NewPostForm";
const { Title } = Typography;
function NewPost() {
return (
<div>
<Title level={3}>New Post</Title>
<NewPostForm />
</div>
);
}
export default NewPost;
Formdaki satır arası boşlukları azaltmak için src/style.css içine
.ant-form-item{
margin-bottom: 12px;
}
eklendi
pages\NewPost\queries.js içinde userlar için gereken query tanımlandı.
import { gql } from "@apollo/client";
export const GET_USER = gql`
query {
users {
fullName
id
}
}
`;
Tanımlanan query NewPost\NewPostForm.js içinde kullanıldı.
..
import { useQuery } from "@apollo/client";
import { GET_USER } from "./queries";
import styles from "./styles.module.css"
..
function NewPostForm() {
const {
loading: get_users_loading, // yeniden adlandırdık
data: users_data,
} = useQuery(GET_USER);
return (
...
<Form.Item
name="user"
rules={[
{
required: true,
message: "Please select user!",
},
]}
>
<Select
disabled={get_users_loading}
loading={get_users_loading}
size="large"
placeholder="Select your user"
>
{users_data &&
users_data.users.map(item => (
<Option value={item.id} key={item.id}>
{item.fullName}
</Option>
))}
</Select>
</Form.Item>
<Form.Item className={styles.buttons}>
<Button size="large" type="primary" htmlType="submit">
Submit
</Button>
</Form.Item>
</Form>
);
}
export default NewPostForm;
\NewPost\styles.module.css içinde butonlar için stil tanımı yapıldı.
.buttons{
text-align: right;
}
yani post için mutation tanımı pages\NewPost\queries.js dosyasına eklendi.
import { gql } from "@apollo/client";
...
export const NEW_POST_MUTATION = gql`
mutation ($data: CreatePostInput!) {
createPost(data: $data) {
id
title
}
}
`;
Hazırlanan mutation NewPost\NewPostForm.js içine import edilip kullanıldı.
import React from "react";
import { Button, Form, Input, Select, message } from "antd";
import { useNavigate } from "react-router-dom";
import { useQuery, useMutation } from "@apollo/client";
import { GET_USER, NEW_POST_MUTATION } from "./queries";
import styles from "./styles.module.css";
const { Option } = Select;
function NewPostForm() {
const navigate = useNavigate()
const [
savePost, // bizim mutation fonksiyonuna verdiğimiz ad.
{ loading, error }, // işlem sonunda dönen data
] = useMutation(NEW_POST_MUTATION);
const { loading: get_users_loading, data: users_data } = useQuery(GET_USER);
const handleSubmit = async (values) => {
try {
await savePost({
variables: {
data: values,
},
});
message.success("Post saved", [4]);
navigate("/")
} catch (e) {
message.error(`Post not saved!. Error: ${error.message}`, [10]);
}
};
return (
<Form
name="basic"
initialValues={{
remember: true,
}}
onFinish={handleSubmit}
// onFinishFailed={onFinishFailed}
autoComplete="off"
>
<Form.Item
name="title" // bu kısım value tanımında key olarak gönderilir. mutation içindeki key ile aynı olmak zorunda.
rules={[
{
required: true,
message: "Please input a title!",
},
]}
>
<Input disabled={loading} size="large" placeholder="Title" />
</Form.Item>
<Form.Item name="short_description">
<Input
disabled={loading}
size="large"
placeholder="Short description"
/>
</Form.Item>
<Form.Item name="description">
<Input.TextArea
disabled={loading}
size="large"
placeholder="Description"
/>
</Form.Item>
<Form.Item name="cover">
<Input disabled={loading} size="large" placeholder="Cover" />
</Form.Item>
<Form.Item
name="user_id"
rules={[
{
required: true,
message: "Please select user!",
},
]}
>
<Select
disabled={get_users_loading || loading}
loading={get_users_loading}
size="large"
placeholder="Select your user"
>
{users_data &&
users_data.users.map((item) => (
<Option value={item.id} key={item.id}>
{item.fullName}
</Option>
))}
</Select>
</Form.Item>
<Form.Item className={styles.buttons}>
<Button loading={loading} size="large" type="primary" htmlType="submit">
Submit
</Button>
</Form.Item>
</Form>
);
}
export default NewPostForm;
Post altına Comment klasörü açıldı ve CommentList.js altına taşındı.
Yeni comment eklemek için kullanacağımız formda yer alması için user listesini almaya yarayacak query pages\Post\Comments\queries.js içinde tanımlandı.
import { gql } from "@apollo/client";
export const GET_USER = gql`
query {
users {
fullName
id
}
}
`;
Comments\NewCommentForm.js dosyası içinde form tanımlandı.
import React from "react";
import { Button, Col, Form, Input, Row, Select, message } from "antd";
import { useQuery, useMutation } from "@apollo/client";
import { GET_USER } from "./queries";
import styles from "./styles.module.css";
const { Option } = Select;
function NewCommentForm() {
const { loading: get_users_loading, data: users_data } = useQuery(GET_USER);
const handleSubmit = async (values) => {
console.log(values);
};
return (
<Form name="basic" onFinish={handleSubmit} autoComplete="off">
<Row gutter={24}>
<Col span={6}>
<Form.Item
name="user_id"
rules={[
{
required: true,
message: "Please select user!",
},
]}
>
<Select
disabled={get_users_loading}
loading={get_users_loading}
size="medium"
placeholder="Select your user"
>
{users_data &&
users_data.users.map((item) => (
<Option value={item.id} key={item.id}>
{item.fullName}
</Option>
))}
</Select>
</Form.Item>
</Col>
<Col span={14}>
<Form.Item
name="text"
rules={[
{
required: true,
message: "Please enter a message!",
},
]}
>
<Input size="medium" placeholder="Message" />
</Form.Item>
</Col>
<Col span={4}>
<Form.Item className={styles.buttons}>
<Button size="medium" type="primary" htmlType="submit">
Submit
</Button>
</Form.Item>
</Col>
</Row>
</Form>
);
}
export default NewCommentForm;
Oluşturulan kompanent Comments\CommentsList.js içinde import edildi ve kullanıldı.
...
import NewCommentForm from "./NewCommentForm";
function CommentsList({ post_id }) {
...
return (
<>
<Divider>Comments</Divider>
{!called && (
<div className={styles.showCommentsButton}>
<Button loading={loading} onClick={() => loadComments()}>
Show Comments
</Button>
</div>
)}
{!loading && data && (
<>
<List
className="demo-loadmore-list"
loading={false}
itemLayout="horizontal"
// loadMore={loadMore}
dataSource={data.post.comments}
renderItem={(item) => (
<List.Item>
<List.Item.Meta
avatar={<Avatar src={item.user.profile_photo} />}
title={item.user.fullName}
description={item.text}
/>
</List.Item>
)}
/>
<Divider>New Comment</Divider>
<NewCommentForm />
</>
)}
</>
);
}
export default CommentsList;
yeni comment eklemek için gereken mutation Post\Comments\queries.js içine eklendi.
import { gql } from "@apollo/client";
...
export const CREATE_COMMENT_MUTATION = gql`
mutation ($data: CreateCommetInput!) {
createComment(data: $data) {
id
}
}
`;
Mutation çalışması için kullanılacak post_id verisi prop olarak Post\Comments\CommentsList.js içinden gönderilir.
...
<NewCommentForm post_id={post_id} />
...
formdaki veriler ve prop olarak gelen post_id kullanılarak mutation fonksiyona tanımlandı ve forma eklendi.
import { useRef } from "react";
import { Button, Col, Form, Input, Row, Select, message } from "antd";
import { useQuery, useMutation } from "@apollo/client";
import { GET_USER, CREATE_COMMENT_MUTATION } from "./queries";
import styles from "./styles.module.css";
const { Option } = Select;
function NewCommentForm({ post_id }) {
const [createComment, { loading }] = useMutation(
CREATE_COMMENT_MUTATION
);
const { loading: get_users_loading, data: users_data } = useQuery(GET_USER);
const formRef = useRef(); // formu ilk haline getirmesi için.
const handleSubmit = async (values) => {
try {
await createComment({
variables: {
data: { ...values, post_id },
},
});
message.success("Comment saved", [4]);
formRef.current.resetFields(); // formu resetleyen fonksiyon
} catch (e) {
message.error(`Comment not saved!.`, [10]);
}
};
return (
<Form name="basic" onFinish={handleSubmit} autoComplete="off" ref={formRef}>
{/* resetleme için ref içinde formRef verildi */}
<Row gutter={24}>
<Col span={6}>
<Form.Item
name="user_id"
rules={[
{
required: true,
message: "Please select user!",
},
]}
>
<Select
disabled={get_users_loading || loading}
loading={get_users_loading}
size="medium"
placeholder="Select your user"
>
{users_data &&
users_data.users.map((item) => (
<Option value={item.id} key={item.id}>
{item.fullName}
</Option>
))}
</Select>
</Form.Item>
</Col>
<Col span={14}>
<Form.Item
name="text"
rules={[
{
required: true,
message: "Please enter a message!",
},
]}
>
<Input disabled={loading} size="medium" placeholder="Message" />
</Form.Item>
</Col>
<Col span={4}>
<Form.Item className={styles.buttons}>
<Button
disabled={loading}
size="medium"
type="primary"
htmlType="submit"
>
Add
</Button>
</Form.Item>
</Col>
</Row>
</Form>
);
}
export default NewCommentForm;
300 ms den kısa sürelerde olan işlemlerde loading spinerının görünmesi kullanıcı deneyimi için olumsuz bir durumdur. Bu nedenle client\src\components\Loading\index.js içinde Spin etiketine delay (gecikme) eklendi.
import React from "react";
import { LoadingOutlined } from "@ant-design/icons";
import { Spin } from "antd";
import styles from "./styles.module.css"
function Loading() {
return (
<div className={styles.loading}>
<Spin delay={300} indicator={<LoadingOutlined style={{ fontSize: 32 }} spin />} />
{/* 300 milisn ve daha kısa süren durumlarda loading görünmez. */}
</div>
);
}
export default Loading;
sevrer tarafındaki datalar mongoDB üzerine taşınacak. Oradan okunup oraya yazılacak.
MongoDB Atlas'a üye olup giriş yapıyoruz. New Project > Create Project > New Database
mongoDB compass uygulamasını yükledik (bizde zaten yüklü).
MongoDB Atlasta kurduğumuz server sayfasında connect'i tıkladık. "Allow Access from Anywhere" > IP değiştirilmeden onayla > Username: "root" password: "1234" > "Create Database User" > "Choose a connection method" > "Compass" > Bu işlemde aldığımız link mongoDB compass uygulamasında kullanıldı. (linkteki <password> kısmına parolamız girildi.)
nodejs tarafında mongoose paketini kullanacağız. server tarafında terminale
npm i mongoose
server/.env oluşturuldu
MONGO_URI=mongodb+srv://root:1234@cluster0.83fcihf.mongodb.net/?retryWrites=true&w=majority
src\db.js oluşturuldu
import mongoose from "mongoose";
import dotenv from "dotenv";
dotenv.config(); // .env dosyasından ortam değişkeni almak için kullanıldı. "process.env.MONGO_URI" bu şekilde çağırıldı.
export default () => {
mongoose.connect(
process.env.MONGO_URI,
{
useUnifiedTopology: true,
}
);
mongoose.connection.on(`open`, () =>
console.log(`MongoDB: Connected`)
);
mongoose.connection.on(`error`, (e) =>
console.log(`MongoDB: Not Connected!`, e)
);
};
server\src\index.js içinde mongodb bağlantısı import edilip eklendi.
import { createYoga, createSchema, createPubSub } from "graphql-yoga";
import { createServer } from "node:http";
import db from "./db"; // mongoDB
db();
import data from "./data.js"; // fake data
import resolvers from "@resolvers";
import typeDefs from "@type-defs";
const pubSub = createPubSub();
const yoga = createYoga({
schema: createSchema({
typeDefs,
resolvers,
}),
context: {
pubSub,
db: data,
},
});
const server = createServer(yoga);
server.listen(4000, () => {
console.info("Server is running on http://localhost:4000/graphql");
});
server\src\models\User.js içinde User modelini oluşturduk.
import mongoose from "mongoose";
const Schema = mongoose.Schema;
const UserSchema = new Schema({
fullName: {
type: String, // veri tipi
required: true, // zorunlu alan mı?
},
age: {
type: Number,
required: true,
},
profile_photo: String,
});
export default mongoose.model("User", UserSchema);
Oluşturduğumuz User modelini server\src\index.js içinde import edip context ile gönderdik.
import { createYoga, createSchema, createPubSub } from "graphql-yoga";
import { createServer } from "node:http";
import db from "./db"; // mongoDB
db();
import User from "./models/User";
import data from "./data.js"; // fake data
import resolvers from "@resolvers";
import typeDefs from "@type-defs";
const pubSub = createPubSub();
const yoga = createYoga({
schema: createSchema({
typeDefs,
resolvers,
}),
context: {
pubSub,
db: data, // fake DB
_db: { // mongoDB
User,
}
},
});
const server = createServer(yoga);
server.listen(4000, () => {
console.info("Server is running on http://localhost:4000/graphql");
});
context içindeki _db: User ifadesini server\src\graphql\resolvers\resolvers\Query.js içinde kullandık.
export const Query = {
users: async (_, __, { _db }) => { // database bağlantısı bekleneceğinden fonksiyon asenkron tanımlandı.
const users = await _db.User.find(); // db içindeki tüm user kısmı çekildi.
return users;
},
user: async (_, args, { _db }) => {
const user = _db.User.findById(args.id);
if (!user) {
return new Error("User not found");
}
return user;
},
posts: (_, __, { db }) => db.posts,
post: (parent, args, { db }) => db.posts.find((post) => post.id === args.id),
comments: (_, __, { db }) => db.comments,
comment: (_, args, { db }) =>
db.comments.find((comment) => comment.id === args.id),
};
mongodb atlas üzerinden bir user ekledik. "browse Collections" > users > "INSERT DOCUMENT"
Yukarıdaki işlemin aynısını Post için yapıyoruz.
server\src\models\Post.js
import mongoose from "mongoose";
const Schema = mongoose.Schema;
const PostSchema = new Schema({
title: {
type: String,
required: true,
},
description: String,
short_description: String,
cover: String,
});
export default mongoose.model("Post", PostSchema)
server\src\index.js
import { createYoga, createSchema, createPubSub } from "graphql-yoga";
import { createServer } from "node:http";
import db from "./db"; // mongoDB
db();
//Models
import User from "./models/User";
import Post from "./models/Post";
import data from "./data.js"; // fake data
import resolvers from "@resolvers";
import typeDefs from "@type-defs";
const pubSub = createPubSub();
const yoga = createYoga({
schema: createSchema({
typeDefs,
resolvers,
}),
context: {
pubSub,
db: data, // fake DB
_db: { // mongoDB
User,
Post,
}
},
});
const server = createServer(yoga);
server.listen(4000, () => {
console.info("Server is running on http://localhost:4000/graphql");
});
server\src\graphql\resolvers\resolvers\Query.js
export const Query = {
users: async (_, __, { _db }) => {
const users = await _db.User.find();
return users;
},
user: async (_, args, { _db }) => {
const user = _db.User.findById(args.id);
if (!user) {
return new Error("User not found");
}
return user;
},
posts: async (_, __, { _db }) => {
const posts = await _db.Post.find();
return posts;
},
post: async (_, args, { _db }) => {
const post = await _db.Post.findById(args.id);
return post
},
comments: (_, __, { db }) => db.comments,
comment: (_, args, { db }) =>
db.comments.find((comment) => comment.id === args.id),
};
Yapıyı test etmek için mongoDB atlas içinde post datası oluşturuldu.
Yukarıdaki işlemin aynısını Comment için yapıyoruz.
server\src\models\Comment.js
import mongoose from "mongoose";
const Schema = mongoose.Schema;
const CommentSchema = new Schema({
text: {
type: String,
required: true,
},
});
export default mongoose.model("Comment", CommentSchema);
server\src\index.js
import { createYoga, createSchema, createPubSub } from "graphql-yoga";
import { createServer } from "node:http";
import db from "./db"; // mongoDB
db();
//Models
import User from "./models/User";
import Post from "./models/Post";
import Comment from "./models/Comment";
import data from "./data.js"; // fake data
import resolvers from "@resolvers";
import typeDefs from "@type-defs";
const pubSub = createPubSub();
const yoga = createYoga({
schema: createSchema({
typeDefs,
resolvers,
}),
context: {
pubSub,
db: data, // fake DB
_db: {
// mongoDB
User,
Post,
Comment,
},
},
});
const server = createServer(yoga);
server.listen(4000, () => {
console.info("Server is running on http://localhost:4000/graphql");
});
server\src\graphql\resolvers\resolvers\Query.js
export const Query = {
users: async (_, __, { _db }) => {
const users = await _db.User.find();
return users;
},
user: async (_, args, { _db }) => {
const user = _db.User.findById(args.id);
if (!user) {
return new Error("User not found");
}
return user;
},
posts: async (_, __, { _db }) => {
const posts = await _db.Post.find();
return posts;
},
post: async (_, args, { _db }) => {
const post = await _db.Post.findById(args.id);
return post;
},
comments: async (_, __, { _db }) => {
const comments = await _db.Comment.find();
return comments;
},
comment: async (_, args, { _db }) => {
const comment = await _db.Comment.findById(args.id);
return comment;
},
};
Yapıyı test etmek için mongoDB atlas içinde comment datası oluşturuldu.
Verileri ilişkilendirmek için:
{
type: Schema.Types.ObjectId,
ref: "Comment"
}
yapısını kullanacağız. İlişkilendireceğimiz veri array ise []içine alacağız.
server\src\models\User.js
import mongoose from "mongoose";
const Schema = mongoose.Schema;
const UserSchema = new Schema({
fullName: {
type: String, // veri tipi
required: true, // zorunlu alan mı?
},
age: {
type: Number,
required: true,
},
profile_photo: String,
posts: [
{
type: Schema.Types.ObjectId,
ref: "Post",
},
],
comments: [
{
type: Schema.Types.ObjectId,
ref: "Comment"
}
]
});
export default mongoose.model("User", UserSchema);
server\src\models\Comment.js
import mongoose from "mongoose";
const Schema = mongoose.Schema;
const CommentSchema = new Schema({
text: {
type: String,
required: true,
},
user: {
type: Schema.Types.ObjectId,
ref: "User"
},
post: {
type: Schema.Types.ObjectId,
ref: "Post"
}
});
export default mongoose.model("Comment", CommentSchema);
server\src\models\Post.js
import mongoose from "mongoose";
const Schema = mongoose.Schema;
const PostSchema = new Schema({
title: {
type: String,
required: true,
},
description: String,
short_description: String,
cover: String,
user: {
type: Schema.Types.ObjectId,
ref: "User",
},
comments: [
{
type: Schema.Types.ObjectId,
ref: "Comment",
},
],
});
export default mongoose.model("Post", PostSchema);
resolvers yapılarını modellere uygun hale getiriyoruz.
server\src\graphql\resolvers\resolvers\User.js
export const User = {
posts: async (parent, __, { _db }) =>
await _db.Post.find({ user: parent.id }),
comments: async (parent, __, { _db }) =>
await _db.Comment.find({ user: parent.id }),
};
server\src\graphql\resolvers\resolvers\Comment.js
export const Comment = {
post: async (parent, __, { _db }) => await _db.Post.findById(parent.post),
user: async (parent, __, { _db }) => await _db.User.findById(parent.user),
};
server\src\graphql\resolvers\resolvers\Post.js
export const Post = {
comments: async (parent, __, { _db }) =>
await _db.Comment.find({post: parent.id}),
user: async (parent, __, { _db }) =>
await _db.User.findById(parent.user),
};
type-defs içindeki tanımlarda artık veri tabanında kullanılmayanları siliyoruz.
Mutation işlemlerimizi mongoose üzerinden yapmak için server\src\graphql\resolvers\resolvers\Mutation.js klasörü user alanı aşağıdaki gibi düzenlendi.
import { nanoid } from "nanoid";
export const Mutation = {
// User
createUser: async (_, { data }, { pubSub, _db }) => {
const newUser = new _db.User({
...data,
});
const user = await newUser.save();
pubSub.publish("userCreated", { userCreated: user });
return user;
},
updateUser: async (_, { id, data }, { pubSub, _db }) => {
const is_user_exist = await _db.User.findById(id);
if (!is_user_exist) {
throw new Error("User not found.");
}
const update_user = await _db.User.findByIdAndUpdate(id, data, {
new: true, // bize güncellenen kullanıcının datasını dönmesi için yazıldı.
})
pubSub.publish("userUpdated", { userUpdated: update_user });
return update_user;
},
deleteUser: async (_, { id }, { pubSub, _db }) => {
const is_user_exist = await _db.User.findById(id);
if (!is_user_exist) {
throw new Error("User not found.");
}
const deleted_user = await _db.User.findByIdAndDelete(id)
pubSub.publish("userDeleted", { userDeleted: deleted_user });
return deleted_user;
},
deleteAllUsers: async (_, __, { _db }) => {
const delete_users = await _db.User.deleteMany({});
return {
count: delete_users.deletedCount,
};
},
// Post
...
}
Mutation işlemlerimizi mongoose üzerinden yapmak için server\src\graphql\resolvers\resolvers\Mutation.js klasörü post alanı aşağıdaki gibi düzenlendi.
import { nanoid } from "nanoid";
export const Mutation = {
...
// Post
createPost: async (_, { data }, { pubSub, _db }) => {
const newPost = new _db.Post({
...data,
});
const post = await newPost.save();
const user = await _db.User.findById(data.user);
user.posts.push(post.id);
user.save(); // bu üç satır post bilgisini ilgili usera eklemek için
const postsCount = await _db.Post.countDocuments();
pubSub.publish("postCreated", { postCreated: post });
pubSub.publish("postsCount", { postsCount });
return post;
},
updatePost: async (_, { id, data }, { pubSub, _db }) => {
const is_post_exist = await _db.Post.findById(id);
if (!is_post_exist) {
throw new Error("Post not found.");
}
const updated_post = await _db.Post.findByIdAndUpdate(id, data, {
new: true, // bize güncellenen kullanıcının datasını dönmesi için yazıldı.
});
pubSub.publish("postUpdated", { postUpdated: updated_post });
return updated_post;
},
deletePost: async (_, { id }, { pubSub, _db }) => {
const is_post_exist = await _db.Post.findById(id);
if (!is_post_exist) {
throw new Error("User not found.");
}
const postDeleted = await _db.Post.findByIdAndDelete(id);
const postsCount = await _db.Post.countDocuments();
pubSub.publish("postDeleted", { postDeleted });
pubSub.publish("postsCount", { postsCount });
return postDeleted;
},
deleteAllPosts: async (_, __, { pubSub, _db }) => {
const deleted_posts = await _db.Post.deleteMany({});
pubSub.publish("postsCount", { postsCount: 0 });
return {
count: deleted_posts.deletedCount,
};
},
...
};
server\src\graphql\type-defs\Post.graphql de aşağıdaki değişiklik yapıldı
...
}
input CreatePostInput {
title: String!
description: String
short_description: String
cover: String
user: ID! # user_id yerine user yazıldı.
}
input UpdatePostInput {
title: String
description: String
short_description: String
cover: String
user: ID
}
Mutation işlemlerimizi mongoose üzerinden yapmak için server\src\graphql\resolvers\resolvers\Mutation.js klasörü comment alanı aşağıdaki gibi düzenlendi.
export const Mutation = {
...
// Comment
createComment: async (_, { data }, { pubSub, _db }) => {
const newComment = new _db.Comment(data);
const createdComment = await newComment.save();
const post = await _db.Post.findById(data.post);
const user = await _db.User.findById(data.user);
post.comments.push(createdComment.id);
user.comments.push(createdComment.id);
await post.save();
await user.save();
pubSub.publish("commentCreated", { commentCreated: createdComment });
return createdComment;
},
updateComment: async (_, { id, data }, { pubSub, _db }) => {
const is_comment_exist = await _db.Comment.findById(id);
if (!is_comment_exist) {
throw new Error("Comment not found.");
}
const updated_comment = await _db.Comment.findByIdAndUpdate(id, data, {
new: true, // bize güncellenen kullanıcının datasını dönmesi için yazıldı.
});
pubSub.publish("commentUpdated", { commentUpdated: updated_comment });
return updated_comment;
},
deleteComment: async (_, { id }, { pubSub, _db }) => {
const is_commet_exist = await _db.Comment.findById(id);
if (!is_commet_exist) {
throw new Error("Comment not found.");
}
const commentDeleted = await _db.Comment.findByIdAndDelete(id);
pubSub.publish("commentDeleted", { commentDeleted });
return commentDeleted;
},
deleteAllComments: async (_, __, { _db }) => {
const deleted_comment = await _db.Comment.deleteMany({});
return {
count: deleted_comment.deletedCount,
};
},
};
server\src\graphql\type-defs\Comment.graphql içindeki user_id ve post_id user
ve post
olarak güncellendi.
type Comment {
id: ID!
text: String!
post: Post!
user: User!
}
input CreateCommetInput {
text: String!
post: ID!
user: ID!
}
input UpdateCommentInput {
text: String
post: ID
user: ID
}
...
server\src\graphql\resolvers\resolvers\Subscription.js dosyasında aşağıdaki değişiklikler yapıldı.
import { filter, pipe } from "graphql-yoga";
export const Subscription = {
...
// Post
postCreated: {
subscribe: (_, args, { pubSub }) => {
return pipe(
pubSub.subscribe("postCreated"),
filter(
(
value
) =>
args.user_id
? value.postCreated.user == args.user_id // value.postCreated.user_id değiştirildi
: true
)
);
},
},
...
postsCount: {
subscribe: async (_, __, { pubSub, _db }) => {
const postsCount = await _db.Post.countDocuments(); // mevcut post sayısı çekildi.
setTimeout(() => {
pubSub.publish("postsCount", { postsCount });
});
return pubSub.subscribe("postsCount");
},
},
...
// Comment
commentCreated: {
subscribe: (_, args, { pubSub }) => {
return pipe(
pubSub.subscribe("commentCreated"),
filter((value) =>
args.post_id ? value.commentCreated.post == args.post_id : true // value.commentCreated.post_id değiştirildi
)
);
},
},
...
};
server\src\graphql\type-defs alanındaki tip tanımlarında type User, type Post ve type Comment alanlarındaki id parametresi _id olarak güncellendi.
client tarafında queries.js dosyalarında id ifadeleri _id olarak güncellendi.
client\src\pages\Home\index.js dosyasında item.id ifadeleri item._id olarak güncellendi.
client\src\pages\Post\Comments\NewCommentForm.js dosyasında item.id ifadeleri item._id olarak güncellendi. user_id ifadesi user olarak güncellendi. variables altında data olarak gönderilen ifade data: { ...values, post_id } yerine data: { ...values, post: post_id } olarak güncellendi.
client\src\pages\NewPost\NewPostForm.js içinde item.id ifadesi item._id olarak güncellendi. user_id ifadesi user olarak güncellendi.
Postlar listelenirken en günceli en üstte olması için server tarafında server\src\graphql\resolvers\resolvers\Query.js dosyasında aşağıdaki güncelleme yapıldı.
export const Query = {
...
posts: async (_, __, { _db }) => {
const posts = await _db.Post.find().sort({ _id: -1 });
return posts;
},
...
};
GraphQl backendleri geliştirebileceğimiz bir ortam. hasura.io. Alternatif olarak prisma.io/graphql
Hasura.io>"login">"project">"new project">"create free project"
Yeni proje içinden "launch console" tıklandı Gelen ekranda "Data" > "postgres" > "neon" > "connect neon database" denilerek yeni database oluşturuldu
Oluşan database içinde public altında "new table" denildi. Gelen form dolduruldu. Tablo doldururma düzeni SQL ile aynı. Yardım gerekirse SQL notlarına bakılabilir. "add table" ile tablomuz kaydedildi
database alanında users tıklanarak oluşturduğumuz database'e ulaşıp işlem yapabiliriz.
"DATA" alanına users tablosunu oluşturduktan sonra "API" alanına döndüğümüzde bizi:
query sorguları SQL ile uyumlu. Sayfa içindeki "Explorer" sekmesi bütün sorgulanabilir parametreleri içeriyor.
Mutationlar genel anlamda bildiğimiz gibi. SQL sorgusu yapısıyla kurallar ekleyebiliyoruz. "Explorer" kısmında hepsi var
insert_users ile birden fazla user eklemek için object içine array içinde object yapısıyla bu userlar yerleştirilir.
"Explorer" sekmesi ile çok rahat keşfedilebiliyor.
Hasura databasete olan her değişikliği subscribe edebiliyor.
SQL tipi limitlemeler konulabiliyor.
database altında todos adında bir tablo oluşturduk. Tablonun user_id parametresini "Foreign Keys" yapısı ile user tablosundaki id ile eşleştirdik. Bu sırada "On Delete Violation": "cascade" olarak seçildi. Bu durumda bir user silindiğinde ona bağlı tüm todo elemanları da silinecek.
users tablosunda "Relationships" sekmesinde bize bir array relationship öneriyor. Bunu ekleyip adını "todos" olarak seçtik.
todos tablosunda "Relationships" sekmesinde bize bir object relationship öneriyor. Bunu ekleyip adını "user" olarak seçtik.
Yaptığımız işlem sonucunda user sorgusu altında todo, todo sorgusu altında da user otomatik olarak yer alıyor.
Hasurada "ACTIONS" sekmesi altından ulaşılıyor. Hasuranın hazır verdikleri dışında yapmak istediğimiz işlemler için kullanıyoruz.
"Action Defination" alanına yeni dahil edeceğimiz query veya mutation yazılır.
type Query {
hello: HelloOutput!
}
"Type Configuration" alanına yukarıdaki işlem için gereken yeni tip tanımı yapılır
type HelloOutput {
message: String!
}
"Webhook (HTTP/S) Handler" verinin çekileceği yeri belirtir. (Şimdilik http://host.docker.internal:3000
yazdık)
"Create Action" dediğimizde yeni bir query tanımlamış olduk.
Actions>hello tıklanarak "Codegen" sekmesi açıldığında backendde çalışması gereken fonksiyon görünür. "try on glich" denilerek kod glitch.com üzerinde açıldı. Bu bize backend oluşturmadan actionumuzu deneme fırsatı sunar.
Glitch içinde src/server.js de dönen response message: "world" olarak düzenlendi. "preview" sekmesi sayesinde glitch projesinin linli alındı. Bu linki kendi actionumuzda handler alanına .../hello endpointi ile gireceğiz.
"Webhook (HTTP/S) Handler" alanı glitch serverındaki yönlendirme ile değiştirildi: https://vintage-abalone-manuscript.glitch.me/hello
API alanına
query helloQuery {
hello {
message
}
}
yazdığımızda
{
"data": {
"hello": {
"message": "world"
}
}
}
çıktısı alınıyor.
"Action Defination"
type Query {
hello(data: HelloInput): HelloOutput!
}
"Type Configuration" > "Declare New Types"
type HelloOutput {
message: String!
}
input HelloInput{
name: String!
}
Glitch içinde src/server.js
const express = require("express");
const bodyParser = require("body-parser");
const app = express();
const PORT = process.env.PORT || 3000;
app.use(bodyParser.json());
// paste the code from codegen here
app.post("/hello", async (req, res) => {
const { name } = req.body.input.data;
return res.json({
message: `hello ${name}`,
});
});
app.listen(PORT);
API alanına
query helloQuery {
hello(data: {name: "Murat"}) {
message
}
}
yazdığımızda
{
"data": {
"hello": {
"message": "hello Murat"
}
}
}
çıktısı alınıyor.
"Action Defination"
type Mutation {
singUp (data: SingUpInput!): SingUpOutput!
}
"Type Configuration" > "Declare New Types"
type SingUpOutput {
accessToken: String!
}
input SingUpInput {
email: String!
}
"Webhook (HTTP/S) Handler" e şimdilik http://host.docker.internal:3000
yazdık.
"Create Action"
Actions>singUp>"Codegen">"Try on glitch"
Glitch içinde src/server.js
const express = require("express");
const bodyParser = require("body-parser");
const app = express();
const PORT = process.env.PORT || 3000;
app.use(bodyParser.json());
app.post("/singUp", async (req, res) => {
const { email } = req.body.input.data;
return res.json({
accessToken: `thisIsAnAccessTokenFor${email}`,
});
});
app.listen(PORT);
"Webhook (HTTP/S) Handler" alanı glitch serverındaki yönlendirme ile değiştirildi: https://vintage-abalone-manuscript.glitch.me/singUp
API alanına
mutation SingUp {
singUp(data: {email: "drmuratgokduman@gmail.com"}) {
accessToken
}
}
yazdığımızda
{
"data": {
"singUp": {
"accessToken": "thisIsAAccessTokenFordrmuratgokduman@gmail.com"
}
}
}
çıktısı alınıyor.
Örnek 3 üzerinden devam ediyoruz.
Yaptığımız işlemin hasura üzerindeki databasee işlem yapabilmesi için "Hasura Cloud" içinde proje sayfamızdaki "Env vars" alanındaki HASURA_GRAPHQL_ADMIN_SECRET
alanına ihtiyacımız var. Biz onu şimdilik 123456 olarak belirledik.
Glitch tarafında bu işlemi yapabilmek için iki pakete ihtiyacımız var. "+ ADD PACKAGE" ile "grapgql" ve "graphql-request" kuruldu. "graphql-request" güncel versiyon çalışmadı biz de package.json içinde "graphql-request": "^3.3.0" yazıp eski versiyonu kurdurduk. Glitch node versiyonu güncel halini çalıştıramıyor.
src/clients/hasura.js oluşturuldu.
import { GraphQLClient } from "graphql-request";
const headers = {
"Content-Type": "application/json",
"x-hasura-admin-secret": process.env.HASURA_ADMIN_SECRET,
};
const client = new GraphQLClient(process.env.HASURA_ENDPOINT, { headers });
export default client;
.env içinde "HASURA_ENDPOINT" ve "HASURA_ADMIN_SECRET" oluşturuldu.
export const INSERT_USER_MUTATION = `
mutation AddUser($input: users_insert_input!) {
insert_users_one(
object: $input
) {
id
email
fullName
}
}
`;
tüm eklenenler src/server.js içine import edilip kullanıldı.
import express from "express";
import bodyParser from "body-parser";
import Hasura from "./clients/hasura";
import { INSERT_USER_MUTATION } from "./queries";
const app = express();
const PORT = process.env.PORT || 3000;
app.use(bodyParser.json());
app.post("/singUp", async (req, res) => {
const { email, fullName } = req.body.input.data;
const { insert_users_one } = await Hasura.request(INSERT_USER_MUTATION, {
input: {
fullName,
email,
},
});
return res.json({
id: insert_users_one.id,
});
});
app.listen(PORT);
actions>singUp (aksiyonumuz)>"Relationship">"Add a relationship" deyip tabloyu dolduruyoruz. Böylece response olarak dönen id bilgisini users tablosuna bağlıyoruz.
Hasura üzerinden aşağıdaki mutation gerçekleşince
mutation MyMutation {
singUp(data: {email: "mx@m", fullName: "Murat 3"}) {
id
user {
fullName
email
age
created_at
todos {
id
text
}
}
}
}
aşağıdaki sonuç alınır.
{
"data": {
"singUp": {
"id": 22,
"user": {
"fullName": "Murat 3",
"email": "mx@m",
"age": null,
"created_at": "2023-07-22T18:04:09.612366+00:00",
"todos": []
}
}
}
}
Graphql yapısı ile hazırlanmış başka bir projeyi kendi hasura projemize dahil etmek için tek gereken eklemek istediğimiz projenin endpointi.
Hasura içinde "Remote Schemas">"Add" dedikten sonra "GraphQL Service URL" alanına eklemek istediğimiz endpointi yerleştirip formu dolduruyoruz ve "Add Remote Schemas" diyoruz.
Örnek graphql api için https://github.com/graphql-kit/graphql-apis
Veri tabanında bir değişiklik olduğunda bir olay başlatmak için kullanılır. Örneğin yeni bir kullanıcı eklendiğinde o kullanıcıya hoşgeldiniz maili göndermek gibi.
Cron trigers kısmında da zamana bağlı tetiklenen aksiyonlar yazılabilir.
One-off Scheduled Events spesifik bir zamanda çalışacak aksiyonlar yazılabilir. Örneğin meeting uygulaması geliştirirken toplantıdan 30 dk önce tüm katılımcılara mesaj at vs.
Senaryo: Yeni bir kullanıcı eklendiğinde kullanıcıya hoşgeldin maili gönder.
"Event Triggers">"Create"> ile gelen formu dolduruyoruz. Şimdilik Webhook (HTTP/S) Handler alanına requestcatcher.com sitesinde yarattığımız https://hasura.requestcatcher.com/test
adresini giriyoruz. Bu durumda hasurada event tetiklendiğinde giden requesti göreceğiz. Sonra "Create Event Trigger" deyip eventi ekliyoruz.
DATA alanına elle bir kullanıcı ekledik. Bunun sonucunda https://hasura.requestcatcher.com/ sayfasında
POST /test HTTP/1.1
Host: hasura.requestcatcher.com
Accept-Encoding: gzip
Content-Length: 570
Content-Type: application/json
User-Agent: hasura-graphql-engine/v2.30.0-cloud.1
X-B3-Parentspanid: 6462ab0e7396c73e
X-B3-Sampled: 1
X-B3-Spanid: f36b8d8071646d5f
X-B3-Traceid: 178ea92cb8a666af9c790c407ac51580
{"created_at":"2023-07-24T04:55:44.799386","delivery_info":{"current_retry":0,"max_retries":0},"event":{"data":{"new":{"age":5,"created_at":"2023-07-24T04:55:44.799386+00:00","email":"bora@bora.com","fullName":"Bora Gökduman","id":23,"updated_at":"2023-07-24T04:55:44.799386+00:00"},"old":null},"op":"INSERT","session_variables":{"x-hasura-role":"admin"},"trace_context":{"span_id":"412a8a544a7f6506","trace_id":"178ea92cb8a666af9c790c407ac51580"}},"id":"a2946095-c160-40a4-b8b3-14c5db415a05","table":{"name":"users","schema":"public"},"trigger":{"name":"insert_user"}}
çıktısı alındı.
Daha önce kullandığımız glitch serverina geri dönüp işlemlerimize oradan devam edeceğiz. Mail gönderimi için "nodemailer" paketi projeye eklendi.
server.js
import express from "express";
import bodyParser from "body-parser";
import Hasura from "./clients/hasura";
import { INSERT_USER_MUTATION } from "./queries";
import nodemailer from "nodemailer";
const app = express();
const PORT = process.env.PORT || 3000;
app.use(bodyParser.json());
...
const transporter = nodemailer.createTransport({
service: "gmail",
auth: {
user: "myhasurabackendd@gmail.com",
pass: "efuvzfpjocfdemnc",
},
});
app.post("/send-email", async (req, res) => {
const { email, fullName } = await req.body.event.data.new;
const mailOptions = {
from: "myhasurabackendd@gmail.com",
to: email,
subject: "Aramıza hoşgeldin",
text: `Aramıza hoşgeldin ${fullName}`,
};
transporter.sendMail(mailOptions, (e, i) => {
if(e){
throw new Error("Error while sending email")
}
res.json({
message: "Email sent ->" + email
})
})
});
app.listen(PORT);
Gmail hesabının üçüncü taraf uygulamasına kendi adına mail izni vermesi için 2 aşamalı doğrulama açılmalı ve o kısmın içinden uygulama şifresi oluşturuldu.
"Webhook (HTTP/S) Handler" alanı glitch serverındaki yönlendirme ile değiştirildi: https://vintage-abalone-manuscript.glitch.me/send-email
Bu durumda her yeni kullanıcı oluştuğunda kullanıcıya mail atılır.
Bu bölümde daha önce yaptığımız comment uygulamasının backendini hasura ile yazacağız.
Lokalde hasura çalışturmak için Quickstart with Hasura using Docker dökümanını kullanacağız. Bunun için Docker ve Docker Compose version 2.0 or higher ilgili adreslerden indirilip kurulur. Docker Compose yeni versiyonlard Docşker içinde gelmektedir. Bu nedenle ayrıca kurmaya gerek yoktur.
Yeni bir proje klasörü oluşturduk: "comment-challenge-with-hasura" ve altına server klasörü oluşturduk. Terminalde bu server klasörüne gidip
curl https://raw.githubusercontent.com/hasura/graphql-engine/stable/install-manifests/docker-compose/docker-compose.yaml -o docker-compose.ymldedik ve cevap olarak server içinde
docker-compose.ymldosyası oluştu.
docker desktop yönetici olarak açıldı ve girildi. Docker'ın açılması için bilgisayara WSL kuruldu ve WSL 2 ye updaye edildi. Bunun için bu döküman kullanıldı
Terminale
docker-compose upyazmak gerekir ancak bu işlev yönetici yetkisi olmadan iş bilgisayarında açılmıyor. Bu nedenle server klasöründe dosya>"windows powersheli aç">"windows powersheli yönetici olarak aç" denir ve burada konut girilir.
"docker-compose up" için "postgres:15" ve "hasura/graphql-engine:v2.30.0"ve "hasura/graphql-data-connector:v2.30.0" image dosyaları gerekiyor. Bunları kendisi indiriyor. Sonra server http://localhost:8080/ ayağa kalkıyor. Burada hasura arayüzü bizi karşılıyor.
DATA altından "Connect Database" kısmında "Connect Existing Database" denilir ve Database URL kısmında server\docker-compose.yml dosyasında "HASURA_GRAPHQL_METADATA_DATABASE_URL": karşısındaki url yazılır.
terminale
hasura consolyazıp çalıştırmak için Hasura CLI bilgisayara kuruldu ve Hasura'yı global olarak erişilebilir kılmak için yolu PATH ortam değişkenine ekledik. sonra terminale
hasura inityazdık ve enter yaptık. server altında hasura adlı bir dizin oluştu. Bunun içindeki metadata hasurada oluşturduğumuz actionları vs, migration tablo oluşturmak için kullanılan sql konutlarını, seeds içinde de tanımları saklar.
cd hasura
hasura console
yazınca http://localhost:9695/ portunda hasura açıldı. Burada yaptığımız her düzenleme hasura dizininde kaydedilir.
localhost:8080 tarafında yaptığımız işlemler kaydedilmeyeceğinden bu kısımda çalışmamıza gerek yok. Buraya girişi engellemek için server\docker-compose.yml içindeki environment altında HASURA_GRAPHQL_ENABLE_CONSOLE: ifadesi false yapılır. ve terminal server'dayken
docker-compose up --builddenilerek değişiklikler devreye alınır.
hasura, hasura console tanımıyla çalıştırılırken şifre istesin istiyorsak server\docker-compose.yml içindeki environment altında HASURA_GRAPHQL_ADMIN_SECRET: karşısına bir değer eklenir ve hasura tekrar build edilir. Production ortamı için gerekli. development ortamı için olmasına gerek yok.
Bu kısımda lokalde çalıştırdığımız Hasura kullanıldı. Daha önceki hasura derslerindeki yol ile "DATA" altında users, posts ve comments tabloları "DATA" oluşturuldu. Aralarında forign key yapısı ile bağ kuruldu. "Relationship" ile de üç tablo birbirine bağlandı.
Daha önce comment-app olarak oluşturduğumuz client bu projeye kopyalandı. Bunun üzerinde çalışacağız
porjemiz graphql-yoga tarafındayken subscription web soket paketine ihtiyaç olmadan alınıyordu. Hasura backendinde apolloclient bu pakete ihtiyaç duyuyor. Bunun için client içinden terminale
npm i subscriptions-transport-wsyazılır. Ve client\src\apollo.js dosyası aşağıdaki şekilde düzenlenir.
import { ApolloClient, InMemoryCache, split, HttpLink } from "@apollo/client";
import { WebSocketLink } from "@apollo/client/link/ws";
import { getMainDefinition } from "@apollo/client/utilities";
const wsLink = new WebSocketLink({
uri: "ws://localhost:8080/v1/graphql",
options: {
reconnect: true
}
})
const httpLink = new HttpLink({
uri: "http://localhost:8080/v1/graphql"
})
const splitLink = split(
({query }) => {
const defination = getMainDefinition(query)
return defination.kind === 'OperationDefinition' && defination.operation === "subscription"
},
wsLink,
httpLink
)
const client = new ApolloClient({
link: splitLink,
cache: new InMemoryCache(),
});
export default client;
client\src\components\PostCounter\queries.js adresindeki postları sayan subscriptionu hasuraya göre güncelledik.
import { gql } from "@apollo/client";
export const POST_COUNT_SUBSCRIPTION = gql`
subscription postCount {
posts_aggregate {
aggregate {
count
}
}
}
`;
yeni veri yapısına uygun olarak subscriptiondan veri çekip client\src\components\PostCounter\index.js içinde kullandık.
import styles from "./styles.module.css";
import { Badge } from "antd";
import { useSubscription } from "@apollo/client";
import { POST_COUNT_SUBSCRIPTION } from "./queries";
function PostCounter() {
const {loading, data} = useSubscription(POST_COUNT_SUBSCRIPTION);
const postCount = data?.posts_aggregate?.aggregate?.count
return (
<div className={styles.container}>
<Badge count={loading ? "?" : postCount }>
<span className={styles.counterTitle}>Post{loading ? "" : postCount > 1 && "s"}</span>
</Badge>
</div>
);
}
export default PostCounter;
Hasura içinde bütün postları getiren ve değişikliğini takip eden bir subscription var. Biz daha önce query ile postları çekip güncellemeyi subscription ile takip ediyorduk. Yeni halinde direk subscription kullandık. client\src\pages\Home\queries.js:
import { gql } from "@apollo/client";
export const POSTS_SUBSCRIPTION = gql`
subscription posts {
posts(order_by: { id: desc }) {
id
title
short_description
user {
profile_photo
}
}
}
`;
client\src\pages\Home\index.js
import { Avatar, List } from "antd";
import { useSubscription } from "@apollo/client";
import Loading from "components/Loading";
import { POSTS_SUBSCRIPTION } from "./queries";
import { Link } from "react-router-dom";
import styles from "./styles.module.css";
function Home() {
const { loading, error, data } = useSubscription(POSTS_SUBSCRIPTION);
if (loading) {
return <Loading />;
}
if (error) {
return <div>Error: {error.message}</div>;
}
return (
<div>
<List
className="demo-loadmore-list"
loading={false}
itemLayout="horizontal"
// loadMore={loadMore}
dataSource={data.posts}
renderItem={(item) => (
<List.Item key={item._id}>
<List.Item.Meta
avatar={<Avatar src={item.user.profile_photo} />}
title={
<Link to={`/post/${item.id}`} className={styles.listTitle}>
{item.title}
</Link>
}
description={
<Link to={`/post/${item.id}`} className={styles.listItem}>
{item.short_description}
</Link>
}
/>
</List.Item>
)}
/>
</div>
);
}
export default Home;
client\src\pages\Post\queries.js dosyasında tekil post detayı veren sorgu güncellendi.
import { gql } from "@apollo/client";
export const GET_POST = gql`
query post($id: Int!) {
posts_by_pk(id: $id) {
id
title
description
cover
user {
id
fullName
}
}
}
`;
client\src\pages\Post\index.js dosyasında gelen veriyi almak için gereken düzenleme yapıldı.
import styles from "./styles.module.css";
import Loading from "components/Loading";
import { useParams } from "react-router-dom";
import { useQuery } from "@apollo/client";
import { GET_POST } from "./queries";
import { Typography, Image } from "antd";
import CommentsList from "./Comments/CommentsList";
const { Title } = Typography;
function Post() {
const { id } = useParams();
const { loading, error, data } = useQuery(GET_POST, {
variables: {
id,
},
});
if (loading) {
return <Loading />;
}
if (error) {
return <div>Error: {error.message}</div>;
}
const { posts_by_pk: post } = data;
return (
<div>
<Title level={3}>{post.title}</Title>
<Image src={post.cover} />
<div className={styles.description}>{post.description}</div>
<CommentsList post_id={id}/>
</div>
);
}
export default Post;
client\src\pages\Post\Comments\queries.js dosyasında commentleri listeleyen ve yeni comment oluşturan sorgular düzenlendi.
import { gql } from "@apollo/client";
export const GET_USER = gql`
query {
users {
fullName
id
}
}
`;
export const CREATE_COMMENT_MUTATION = gql`
mutation createComment($input: comments_insert_input!) {
insert_comments_one(object: $input) {
id
}
}
`;
export const COMMENTS_SUBSCRIPTION = gql`
subscription getComment($post_id: Int!) {
comments(where: { post_id: { _eq: $post_id } }) {
id
text
user {
fullName
profile_photo
}
}
}
`;
client\src\pages\Post\Comments\CommentsList.js dosyasında listeleme için gereken düzenlemeler yapıldı.
import Loading from "components/Loading";
import { Divider } from "antd";
import { useSubscription } from "@apollo/client";
import { COMMENTS_SUBSCRIPTION } from "./queries";
import { Avatar, List } from "antd";
import NewCommentForm from "./NewCommentForm";
function CommentsList({ post_id }) {
const { data, error, loading } = useSubscription(COMMENTS_SUBSCRIPTION, {
variables: {
post_id,
},
});
if (loading) {
return <Loading />;
}
if (error) {
return <div>Error: {error.message}</div>;
}
return (
<>
<Divider>Comments</Divider>
{!loading && data && (
<>
<List
className="demo-loadmore-list"
loading={false}
itemLayout="horizontal"
// loadMore={loadMore}
dataSource={data.comments}
renderItem={(item) => (
<List.Item key={item.id}>
<List.Item.Meta
avatar={<Avatar src={item.user.profile_photo} />}
title={item.user.fullName}
description={item.text}
/>
</List.Item>
)}
/>
<Divider>New Comment</Divider>
<NewCommentForm post_id={post_id} />
</>
)}
</>
);
}
export default CommentsList;
client\src\pages\Post\Comments\NewCommentForm.js dosyasında yeni comment eklenmesi için gereken düzenlemeler yapıldı.
import { useRef } from "react";
import { Button, Col, Form, Input, Row, Select, message } from "antd";
import { useQuery, useMutation } from "@apollo/client";
import { GET_USER, CREATE_COMMENT_MUTATION } from "./queries";
import styles from "./styles.module.css";
const { Option } = Select;
function NewCommentForm({ post_id }) {
const [createComment, { loading }] = useMutation(CREATE_COMMENT_MUTATION);
const { loading: get_users_loading, data: users_data } = useQuery(GET_USER);
const formRef = useRef(); // formu ilk haline getirmesi için.
const handleSubmit = async (values) => {
try {
await createComment({
variables: {
input: { ...values, post_id },
},
});
message.success("Comment saved", [4]);
formRef.current.resetFields(); // formu resetleyen fonksiyon
} catch (e) {
message.error(`Comment not saved!.`, [10]);
}
};
return (
<Form name="basic" onFinish={handleSubmit} autoComplete="off" ref={formRef}>
{/* resetleme için ref içinde formRef verildi */}
<Row gutter={24}>
<Col span={6}>
<Form.Item
name="user_id"
rules={[
{
required: true,
message: "Please select user!",
},
]}
>
<Select
disabled={get_users_loading || loading}
loading={get_users_loading}
size="medium"
placeholder="Select your user"
>
{users_data &&
users_data.users.map((item) => (
<Option value={item.id} key={item.id}>
{item.fullName}
</Option>
))}
</Select>
</Form.Item>
</Col>
<Col span={14}>
<Form.Item
name="text"
rules={[
{
required: true,
message: "Please enter a message!",
},
]}
>
<Input disabled={loading} size="medium" placeholder="Message" />
</Form.Item>
</Col>
<Col span={4}>
<Form.Item className={styles.buttons}>
<Button
disabled={loading}
size="medium"
type="primary"
htmlType="submit"
>
Add
</Button>
</Form.Item>
</Col>
</Row>
</Form>
);
}
export default NewCommentForm;
client\src\pages\NewPost\queries.js dosyasında yeni post oluşturan mutation güncellendi.
import { gql } from "@apollo/client";
export const GET_USER = gql`
query {
users {
fullName
id
}
}
`;
export const NEW_POST_MUTATION = gql`
mutation createPost($data: posts_insert_input!) {
insert_posts_one(object: $data) {
id
title
}
}
`;
client\src\pages\NewPost\NewPostForm.js dosyasında gereken düzenlemeler yapıldı.
import React from "react";
import { Button, Form, Input, Select, message } from "antd";
import { useNavigate } from "react-router-dom";
import { useQuery, useMutation } from "@apollo/client";
import { GET_USER, NEW_POST_MUTATION } from "./queries";
import styles from "./styles.module.css";
const { Option } = Select;
function NewPostForm() {
const navigate = useNavigate();
const [
savePost, // bizim mutation fonksiyonuna verdiğimiz ad.
{ loading, error }, // işlem sonunda dönen data
] = useMutation(NEW_POST_MUTATION);
const { loading: get_users_loading, data: users_data } = useQuery(GET_USER);
const handleSubmit = async (values) => {
try {
await savePost({
variables: {
data: values,
},
});
message.success("Post saved", [4]);
navigate("/");
} catch (e) {
message.error(`Post not saved!. Error: ${error.message}`, [10]);
}
};
return (
<Form name="basic" onFinish={handleSubmit} autoComplete="off">
<Form.Item
name="title" // bu kısım value tanımında key olarak gönderilir. mutation içindeki key ile aynı olmak zorunda.
rules={[
{
required: true,
message: "Please input a title!",
},
]}
>
<Input disabled={loading} size="large" placeholder="Title" />
</Form.Item>
<Form.Item name="short_description">
<Input
disabled={loading}
size="large"
placeholder="Short description"
/>
</Form.Item>
<Form.Item name="description">
<Input.TextArea
disabled={loading}
size="large"
placeholder="Description"
/>
</Form.Item>
<Form.Item name="cover">
<Input disabled={loading} size="large" placeholder="Cover" />
</Form.Item>
<Form.Item
name="user_id"
rules={[
{
required: true,
message: "Please select user!",
},
]}
>
<Select
disabled={get_users_loading || loading}
loading={get_users_loading}
size="large"
placeholder="Select your user"
>
{users_data &&
users_data.users.map((item) => (
<Option value={item.id} key={item.id}>
{item.fullName}
</Option>
))}
</Select>
</Form.Item>
<Form.Item className={styles.buttons}>
<Button loading={loading} size="large" type="primary" htmlType="submit">
Submit
</Button>
</Form.Item>
</Form>
);
}
export default NewPostForm;
proje klasörü içinde terminale
mkdir backend
cd backend
curl https://raw.githubusercontent.com/hasura/graphql-engine/stable/install-manifests/docker-compose/docker-compose.yaml -o docker-compose.yml
hasura init
backend\docker-compose.yml içinde "HASURA_GRAPHQL_ENABLE_CONSOLE": false yapıldı.
backend klasörü içinde "powershell" yönetici olarak açıldı ve içine
docker-compose upyazılarak server ayağa kaldırıldı.
backend\hasura konumunda terminale
hasura consoleyazılır. Hasura http://localhost:9695/ portunda açıldı.
backend\docker-compose.yml içinde HASURA_GRAPHQL_METADATA_DATABASE_URL: keyine karşılık gelen "postgres://postgres:postgrespassword@postgres:5432/postgres" urli DATA içinde database eklerken kullandık. Altına da 3 tane tablo açtık.
"Relationship" kısmında ilişkiler belirtildi.
Terminale
mkdir client
cd client
npx create-react-app .
Kurulumun ardından terminale
npm install @apollo/client graphql
Kurulumun ardından terminale
npm i react-router-dom
client\src\index.js içi aşağıdaki gibi güncellendi.
import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import App from "./App";
import { ApolloProvider } from "@apollo/client";
import client from "./apollo";
import { BrowserRouter as Router } from "react-router-dom";
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
<ApolloProvider client={client}>
<Router>
{/* Router sarmalaması App.js yerine burada yapıldı.*/}
<App />
</Router>
</ApolloProvider>
);
Hem "apollo client"in hem de "react router dom"un providerleri ile proje sarmalandı.
"apollo provider"de kullanıdğımız ve bizim backende bağlanmamızı sağlayan client tanımı client\src\apollo.js dosyasında oluşturuldu.
import { ApolloClient, InMemoryCache, split, HttpLink } from "@apollo/client";
import { WebSocketLink } from "@apollo/client/link/ws";
import { getMainDefinition } from "@apollo/client/utilities";
const wsLink = new WebSocketLink({
uri: "ws://localhost:8080/v1/graphql",
options: {
reconnect: true
}
})
const httpLink = new HttpLink({
uri: "http://localhost:8080/v1/graphql"
})
const splitLink = split(
({query }) => {
const defination = getMainDefinition(query)
return defination.kind === 'OperationDefinition' && defination.operation === "subscription"
},
wsLink,
httpLink
)
const client = new ApolloClient({
link: splitLink,
cache: new InMemoryCache(),
});
export default client;
Bu tanım için gereken paketi yüklemek için terminale
npm i subscriptions-transport-ws
client\src\App.js içinde routing yapısı kuruldu.
import { Routes, Route, Link } from "react-router-dom";
import Questions from "./pages/Questions";
import NewQuestion from "./pages/New";
function App() {
return (
<div className="App">
<nav>
<Link to="/">Questions</Link>
<Link to="/new">New Question</Link>
</nav>
<hr />
<Routes>
<Route path="/" element={<Questions />} />
<Route path="/new" element={<NewQuestion />} />
</Routes>
</div>
);
}
export default App;
Bu yapıda kullanılan kompanentler yer tutucu olarak oluşturuldu.
client\src\index.css içine stil tanımları yapıldı.
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
monospace;
}
.App {
padding: 20px;
}
nav > a {
padding: 0 10px 10px 0;
color: #000;
text-decoration: none;
}
nav > a:hover {
text-decoration: underline;
}
Soruları listeleyecek subscription hasurada belirlendi ve client\src\pages\Questions\queries.js dosyasında yazıldı.
import { gql } from "@apollo/client";
export const QUESTIONS_SUBSCRIPTION = gql`
subscription {
questions(order_by: { id: desc }) {
id
title
}
}
`;
Yazılan subscription client\src\pages\Questions\index.js dosyasında kullanıldı.
import React from "react";
import { useSubscription } from "@apollo/client";
import { QUESTIONS_SUBSCRIPTION } from "./queries";
import Loading from "../../components/Loading";
import { Link } from "react-router-dom";
function Questions() {
const { loading, data } = useSubscription(QUESTIONS_SUBSCRIPTION);
if (loading) {
return <Loading />;
}
return (
<div>
{data.questions.map((question) => (
<div key={question.id}>
<Link to={`/q/${question.id}`}>{question.title}</Link>
</div>
))}
</div>
);
}
export default Questions;
loading durumunda kullanılan kompanent client\src\components\Loading\index.js dosyasına yazıldı.
client\src\components\Loading\index.js
client\src\pages\New\index.js içinde form tanımları yapıldı.
import { useState } from "react";
const initialOptions = [{ title: "" }, { title: "" }];
function NewQuestion() {
const [title, setTitle] = useState();
const [options, setOptions] = useState(initialOptions);
const handleChangeOption = ({target}) => {
const newArray = options;
newArray[target.id].title = target.value
setOptions([...newArray])
}
return (
<div>
<h2>Question</h2>
<input
placeholder="Type your question..."
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<h2>Options</h2>
{options.map((option, i) => (
<div key={i}>
<input
placeholder="Type your option..."
value={option.title}
id={i}
onChange={handleChangeOption}
/>
</div>
))}
<button onClick={() => setOptions([...options, { title: "" }])}>
New Option
</button>
<button>Save</button>
</div>
);
}
export default NewQuestion;
client\src\index.css içinde input ve button için stil tanımı yapıldı.
...
input{
padding: 6px;
font-size: 18px;
}
button{
padding: 6px;
margin-top: 10px;
}
Hasura içinde yeni question ve options eklemek için gereken mutation tanımlandı ve client\src\pages\New\queries.js dosyasında kullanıldı.
import { gql } from "@apollo/client";
export const NEW_QUESTION_MUTATION = gql`
mutation newQuestion($input: questions_insert_input!) {
insert_questions_one(object: $input) {
id
title
}
}
`;
İlgili mutation client\src\pages\New\index.js dosyasında formdan gelen veriler ile kullanıldı.
import { useState } from "react";
import { useMutation } from "@apollo/client";
import { NEW_QUESTION_MUTATION } from "./queries";
const initialOptions = [{ title: "" }, { title: "" }];
function NewQuestion() {
const [addQuestion, { loading }] = useMutation(NEW_QUESTION_MUTATION);
const [title, setTitle] = useState();
const [options, setOptions] = useState(initialOptions);
const handleChangeOption = ({ target }) => {
const newArray = options;
newArray[target.id].title = target.value;
setOptions([...newArray]);
};
const handleSave = () => {
const filledOptions = options.filter((option) => option.title !== ""); // Boşları database e göndermemek için optionlardan sadece title değeri olanları filtreledi.
if (title === null || filledOptions.length < 2) return false;
addQuestion({
variables: {
input: {
title,
options: {
data: filledOptions,
},
},
},
});
setOptions(initialOptions);
setTitle("");
};
return (
<div>
<h2>Question</h2>
<input
placeholder="Type your question..."
value={title}
onChange={(e) => setTitle(e.target.value)}
disabled={loading}
/>
<h2>Options</h2>
{options.map((option, i) => (
<div key={i}>
<input
placeholder="Type your option..."
value={option.title}
id={i}
onChange={handleChangeOption}
disabled={loading}
/>
</div>
))}
<button
onClick={() => setOptions([...options, { title: "" }])}
disabled={loading}
>
New Option
</button>
<button onClick={handleSave} disabled={loading}>
Save
</button>
</div>
);
}
export default NewQuestion;
client\src\App.js içinde route yapısı düzenlendi.
import { Routes, Route, Link } from "react-router-dom";
import Questions from "./pages/Questions";
import NewQuestion from "./pages/New";
import Detail from "./pages/Detail";
function App() {
return (
<div className="App">
<nav>
<Link to="/">Questions</Link>
<Link to="/new">New Question</Link>
</nav>
<hr />
<Routes>
<Route path="/" element={<Questions />} />
<Route path="/new" element={<NewQuestion />} />
<Route path="/q/:id" element={<Detail />} />
</Routes>
</div>
);
}
export default App;
client\src\pages\Detail\queries.js içinde question detaylarını çeken subscription ve yeni oy ekleyen mutation kullanıldı.
import { gql } from "@apollo/client";
export const QUESTION_DETAIL_SUBSCRIPTION = gql`
subscription questionDetail($id: Int!) {
questions_by_pk(id: $id) {
id
title
options {
id
title
votes_aggregate {
aggregate {
count
}
}
}
}
}
`;
export const NEW_VOTE_MUTATIONS = gql`
mutation newVote($id: Int) {
insert_votes_one(object: { option_id: $id }) {
id
option {
title
}
}
}
`;
client\src\pages\Detail\index.js dosyasında bu sorgular kullanıldı.
import { useState } from "react";
import { useParams } from "react-router-dom";
import { useMutation, useSubscription } from "@apollo/client";
import { NEW_VOTE_MUTATIONS, QUESTION_DETAIL_SUBSCRIPTION } from "./queries";
import Loading from "../../components/Loading";
import Error from "../../components/Error";
function Detail() {
const { id } = useParams();
const [selectedOptionId, setSelectedOptionId] = useState();
const { data, loading, error } = useSubscription(
QUESTION_DETAIL_SUBSCRIPTION,
{
variables: {
id,
},
}
);
const [newVote, { loading: loadingVote }] = useMutation(NEW_VOTE_MUTATIONS);
const handleClickVote = () => {
newVote({
variables: {
id: selectedOptionId
}
})
};
if (loading) {
return <Loading />;
}
if (error) {
return <Error message={error.message} />;
}
const {
questions_by_pk: { options, title },
} = data;
console.log(data.questions_by_pk);
return (
<div>
<h2>{title}</h2>
{options.map((option, i) => (
<label htmlFor={i} key={i}>
<input
type="radio"
name="selected"
value={option.id}
onChange={({ target }) => setSelectedOptionId(target.value)}
/>
<span>{option.title}</span>
</label>
))}
<button disabled={loadingVote} onClick={handleClickVote}>Vote</button>
</div>
);
}
export default Detail;
Error kompanenti client\src\components\Error\index.js dosyasında oluşturuldu.
import React from 'react'
function Error({message}) {
return (
<div>Error: {message}</div>
)
}
export default Error
client\src\index.css içine label stil tanımı eklendi.
label{
display: block;
margin-bottom: 8px;
}
Oyların dağılımını gösteren progress tagını kullandık. Oy verme durumunda butonu ortadan kaldırıp güncel oy sayılarını gösterdik.
client\src\pages\Detail\index.js dosyasının son hali:
import { useState } from "react";
import { useParams } from "react-router-dom";
import { useMutation, useSubscription } from "@apollo/client";
import { NEW_VOTE_MUTATIONS, QUESTION_DETAIL_SUBSCRIPTION } from "./queries";
import Loading from "../../components/Loading";
import Error from "../../components/Error";
function Detail() {
const { id } = useParams();
const [isVoted, setIsVoted] = useState(false);
const [selectedOptionId, setSelectedOptionId] = useState();
const { data, loading, error } = useSubscription(
QUESTION_DETAIL_SUBSCRIPTION,
{
variables: {
id,
},
}
);
const [newVote, { loading: loadingVote }] = useMutation(NEW_VOTE_MUTATIONS, {
onCompleted: () => {
// işlem tamamlandığında
setIsVoted(true); // bunu çalıştır
},
});
const handleClickVote = () => {
newVote({
variables: {
id: selectedOptionId,
},
});
};
if (loading) {
return <Loading />;
}
if (error) {
return <Error message={error.message} />;
}
const {
questions_by_pk: { options, title },
} = data;
const total = options.reduce(
(t, value) => t + value.votes_aggregate.aggregate.count,
0
); // 0 dan başlayarak tüm değerleri toplar
return (
<div>
<h2>{title}</h2>
{options.map((option, i) => (
<div key={i}>
<label htmlFor={i}>
<input
type="radio"
name="selected"
id={i}
value={option.id}
onChange={({ target }) => setSelectedOptionId(target.value)}
/>
<span>{option.title}</span>
{isVoted && (
<span className="vote_count">
(%
{(
(option.votes_aggregate.aggregate.count /
(total === 0 ? 1 : total)) *
100
).toFixed(2)}
){/* virgülden sonra 2 karakter gösterilmesini sağlar. */}
</span>
)}
</label>
{isVoted && (
<div>
<progress
value={option.votes_aggregate.aggregate.count}
max={total}
/>
</div>
)}
</div>
))}
{!isVoted && (
<button disabled={loadingVote} onClick={handleClickVote}>
Vote
</button>
)}
</div>
);
}
export default Detail;
client\src\index.css dosyasının son hali:
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
monospace;
}
.App {
padding: 20px;
}
nav > a {
padding: 0 10px 10px 0;
color: #000;
text-decoration: none;
}
nav > a:hover {
text-decoration: underline;
}
input {
padding: 6px;
font-size: 18px;
}
button {
padding: 6px;
margin-top: 10px;
}
label {
display: block;
margin-bottom: 4px;
}
.vote_count {
font-size: 16;
margin-left: 4px;
}
progress {
margin-bottom: 8px;
}
Docker'ın çalıştığı herhangi bir sunucuda hasura backendimiz de çalışır. Bu dökümandan faydalandık.
Biz DigitalOcean kullanacağız. DigitalOcean aws üzerinden bir makina açıp kullanımıza sunuyor.
DigitalOcean'a kayıt yapıp giriş yapıyoruz. "Create">"Create Droplets">"Ubuntu"
"Choose Region" da en yakın yeri seçtik. Devamında en ucuz makineyi seçiyoruz. Formun devamını dolduruyoruz. Parola belirliyoruz vs. En son "Create" deyip sunucuyu oluşturuyoruz.
terminale
ssh root@<host-id>ile bağlanıyoruz. Host id digitalOcean içinden alıyoruz.
sudo apt-get update
sudo apt-get install ca-certificates curl gnupg
apt install docker.io
apt install docker-compose
ile gerekli olan şeyleri kuruyoruz. (detaylar docker dersinde)
systemctl status dockerdockerın çalışma durumunu sorguluyoruz.
cd /tmp
mkdir hasura
cd hasura
ile temp içinde hasura klasörü oluşturup içine giriyoruz.
curl https://raw.githubusercontent.com/hasura/graphql-engine/stable/install-manifests/docker-compose/docker-compose.yaml -o docker-compose.ymlile hasurayı ayağa kaldıracak olan docker-compose.yml dosyasını indiriyoruz.
docker-compose upile docker ayağa kaldırıldı.
host-ip:8080 ile ayağa kalkan projenin konsoluna ulaşabiliriz. Burada DATA alanında docker-compose.yml içindeki endpoint ile makineye hasura kurulurken kurulan postgreSQL endpointini kullanarak database bağlantısını sağladık.
Proje dizinimizde terminale
cd backend/hasurayazarak migration tanımlarını olduğu klasöre geldik.
hasura metadata apply --endpoint --adminsecretsintaxında endpointten sonra kendi endpointimizi girdik. Bizde:
hasura metadata apply --endpoint http://134.122.78.209:8080admin secret olmadığından gönderilmedi.
İşlem tamamlanınca deploy edilen tarafın arayüzünde bizi karşılayan ekranda "reload metadata" diyoruz.
Tablolarımız geldi. Şimdi de migration işlemini yapacağız.
hasura migrate apply --endpoint --adminsecretsintaxında endpointten sonra kendi endpointimizi girdik. Bizde:
hasura migrate apply --endpoint http://134.122.78.209:8080admin secret olmadığından gönderilmedi.
İşlem tamamlanınca deploy edilen tarafın arayüzünde bizi karşılayan ekranda "Reload all databases" i seçip "reload metadata" diyoruz.
Netlfy kayıt ol ve giriş yap. Git repomuzu kaydettik.
Client bir git reposuna alındı.
Netlify>new site> ilgili repo seçilir. Form kontrol edilir ve deploy tıklanır.
client\src\apollo.js içinde hala local endpointlerimiz var. Bunları deploy ettiğimiz hasuranın endpointi ile güncelliyoruz. Tekrar git push yapıyoruz ki değişiklikler netlifyde uygulansın.
hasura docker-compose ile yüklendiğinde https/wss yapısını desteklemiyor. Bu nedenle de websoket tarafında sorun yaşıyoruz. Bu kısmı daha sonra tekrar zorlarız.
Kök dizin dışından açma ve sayfa yenileme işlemlerinde sorun yaşamamak için client\public\_redirects dosyası oluşturuldu ve içine
/* /index.html 200yazıldı.
Toplantı planlama uygulaması. Görüşmeye birileri davet edilebilecek. Kabul edenlere son 1 saat kala yeni bir mail gelecek.
Hoca docker ile lokal kurup ilerliyor. Biz hasura cloud ile ilerleyeceğiz. Çünkü daha sonra digitalocean ile deploy etmeye çalışınca hata alıyoruz.
hasura cloud üzerinde yeni bir server oluşturduk. DB olarak postgreSQL bağladık.
users adında bir tablo oluşturduk.
meetings adında bir tablo oluşturduk.
participants adında bir tablo oluşturduk.
Örnek veriler girildi ve test edildi.
Login, register vs için kullanılacak backendi hazırlayacağız.
proje dosyasında terminale
cd backend
npm init -y
ile npm başlatıldı.
npm i expressile express indirildi.
npm i --save-d nodemonile nodemon dev dependencies olarak kuruldu.
backend\src\app.js
const express = require("express");
const app = express();
const port = process.env.PORT || 3001;
app.use(express.json());
app.post("/register", (req, res) => {
const input = req.body.input.data;
console.log(input);
res.json({
accessToken: "accessToken",
});
});
app.listen(port, () => console.log(`Server is up and running. Port: ${port}`));
backend\package.json script alanına nodemon eklendi (gerçi çok da işimize yaramadı.)
"scripts": {
"dev": "nodemon ./src/app.js"
},
Hasura cloud üzerinden action>create>new action
Action Definition
type Mutation {
register (data: RegisterInput!): RegisterOutput
}
Type Configuration
type RegisterOutput {
accessToken: String!
}
input RegisterInput {
email: String!
password: String!
}
hoca new action için webhook işlemini lokalde docker-compose üzerinden env olarak yapıyor. Biz online serverda çalıştığımızdan backendimizi de deta.space ile online'a aldık.
Webhook (HTTP/S) Handler: https://backend-1-m7357908.deta.app/register
Online'a almak için backend\Spacefile
# Spacefile Docs: https://go.deta.dev/docs/spacefile/v0
v: 0
micros:
- name: backend
src: ./
engine: nodejs16
primary: true
run: node ./src/app.js
public: true
Yapımızda EC6 kullanabilmek için sucrase isimli bir araç kullanacağız. Sucrase bir babel alternatifi. Terminale
npm i sucraseyazılarak kuruldu.
artık app.js içinde EC6 söz dizimini kullanabilriz.
import express from "express";
const app = express();
const port = process.env.PORT || 3001;
app.use(express.json());
app.get("/", (req, res) => {
res.send("Hello World");
});
app.post("/register", (req, res) => {
const input = req.body.input.data;
console.log(input);
res.json({
accessToken: "accessToken",
});
});
app.listen(port, () => console.log(`Server is up and running. Port: ${port}`));
nodemon kullanılırken de sucrase nin çalışması için backend\nodemon.json dosyası oluşturuldu ve içine
{
"execMap": {
"js": "node -r sucrase/register"
}
}
yazıldı.
backend\package.json dosyasında scripts alanı düzenlendi.
"scripts": {
"dev": "nodemon ./src/app.js",
"build": "sucrase ./src -d ./dist --transforms imports"
},
terminale npm run buildyazılarak build alındı. Alınan buildi bir türlü deta.space içine dahil edemeyince ben de kopyalayıp test adlı bir klasöre koydum. Buna bağlı olarak da backend\Spacefile dosyası güncellendi.
# Spacefile Docs: https://go.deta.dev/docs/spacefile/v0
v: 0
micros:
- name: backend
src: ./
engine: nodejs16
primary: true
public: true
run: node test/app.js
route tanımlarını app.js içinden ayrı bir yere taşıdık. Bunun için backend\src\routes\auth\index.js dosyası oluşturduk ve içine
import express from "express";
const router = express.Router();
router.post("/register", (req, res) => {
const input = req.body.input.data;
res.json({ accessToken: "accessToken" });
});
export default router;
yazdık. Bu routerı backend\src\app.js içinde import edip kullandık.
import express from "express";
const app = express();
import auth from "./routes/auth";
const port = process.env.PORT || 3001;
app.use(express.json());
app.get("/", (req, res) => {
res.send("Hello World");
});
app.use("/auth", auth);
app.listen(port, () => console.log(`Server is up and running. Port: ${port}`));
Bu hali ile hasura cloud üzerindeki action endpointimiz kökdizin/register -> kökdizin/auth/register haline geldi. Bu güncellemeyi de hasura claud üzerinde yaptık.
package.json üzerindeki dist tanımını da test ile değiştirdik ki buildler bizim oluşturduğumuz test klasörüne gelsin :D
"scripts": {
"dev": "nodemon ./src/app.js",
"build": "sucrase ./src -d ./test --transforms imports"
},
Hasuraya bağlanabilmek için terminale
npm i graphql-requestyazarak gerekli kütüphaneyi kurduk.
backend\src\clients\hasura.js dosyasını oluşturup hasura bağlantısını sağladık.
import { GraphQLClient } from "graphql-request";
const headers = {
"Content-Type": "application/json",
"x-hasura-admin-secret": "test1234",
};
export default new GraphQLClient(
"https://meeting-app-server.hasura.app/v1/graphql",
{
headers,
}
);
Hata olduğunda onu göndermek için bir kütüphane kurduk.
npm i boom
backend\src\app.js üzerinde error catching tanımlarını girdik.
import express from "express";
import Boom from "boom";
import auth from "./routes/auth";
const app = express();
const port = process.env.PORT || 3001;
app.use(express.json());
app.get("/", (req, res) => {
res.send("Hello World");
});
app.use("/auth", auth);
//route tanımı olmayan bir sayfaya istek yapıldığında:
app.use((req, res, next) => {
return next(Boom.notFound("Not Found"));
});
//hata gönderildiğinde:
app.use((err, req, res, next) => {
if (err) {
if (err.output) {
return res.status(err.output.statusCode || 500).json(err.output.payload);
}
}
return res.status(500).json(err);
});
app.listen(port, () => console.log(`Server is up and running. Port: ${port}`));
backend\src\routes\auth\index.js içinde ilk validasyon işlemimizi tanımladık.
import Boom from "boom";
import express from "express";
const router = express.Router();
router.post("/register", (req, res, next) => {
const input = req.body.input.data;
if (!input.email || !input.password) {
return next(Boom.badRequest("Email and Password are requared!"));
}
res.json({ accessToken: "accessToken" });
});
export default router;
npm i graphql
hasura cloud üzerinde action>register>Type Configuration alanı name ve surname alması için aşağıdaki gibi düzenlendi.
input RegisterInput {
email: String!
name: String!
surname: String!
password: String!
}
type RegisterOutput {
accessToken: String!
}
backend\src\routes\auth\queries.js altında gerekli sorgular yapıldı.
// Kullanıcının varlığını sorgulayan query
export const IS_EXIST_USER = `
query isExist($email: String!) {
users(where: {email: {_eq: $email}}) {
id
}
}
`;
// Kullanıcı ekleyen mutation
export const INSERT_USER_MUTATION = `
mutation insertUser ($input: users_insert_input!){
insert_users_one(object: $input) {
id
name
}
}
`;
Bu sorgular backend\src\routes\auth\index.js içinde kullanılacak.
Validasyon için
npm install joikuruldu
backend\src\routes\auth\validations.js içinde validasyon tanımları yapıldı.
import Joi from "joi";
export const registerSchema = Joi.object({
email: Joi.string().email().required(),
name: Joi.string().min(1).max(60).required(),
surname: Joi.string().min(1).max(60).required(),
password: Joi.string().min(6).max(60).required(),
});
Bu tanım backend\src\routes\auth\index.js içinde kullanılacak.
Parolaları şifrelemek için:
npm i bcryptjskuruldu.
backend\src\routes\auth\index.js
import express from "express";
import Boom from "boom";
import bcrypt from "bcryptjs";
import Hasura from "../../clients/hasura";
import { IS_EXIST_USER, INSERT_USER_MUTATION } from "./queries";
import { registerSchema } from "./validations";
const router = express.Router();
router.post("/register", async (req, res, next) => {
const input = req.body.input.data;
input.email = input.email.toLowerCase();
// validasyon işlemi
const { error } = registerSchema.validate(input);
if (error) {
return next(Boom.badRequest(error.details[0].message));
}
try {
const isExistUser = await Hasura.request(IS_EXIST_USER, {
email: input.email,
});
// user zaten varsa hata ver
if (isExistUser.users.length > 0) {
throw Boom.conflict(`user already exist (${input.email})`);
}
// Parola şifreleme işlemi
const salt = await bcrypt.genSalt(10);
const hash = await bcrypt.hash(input.password, salt); // şifrelenmiş parola
// user yoksa user oluştur
const user = await Hasura.request(INSERT_USER_MUTATION, {
input: {
...input,
password: hash,
},
});
res.json({ accessToken: "accessToken" });
} catch (err) {
return next(Boom.badRequest(err));
}
});
export default router;
accessToken ile auth işlemleri
Öncelikle product ortamında görünmesini istemediğimiz "HASURA_ADMIN_SECRET" ve bu uygulamada accessToken üretmek için kullanacağımız "JWT_ACCESS_TOKEN_SECRET" key/value çiftlerini backend\.env dosyasına taşıdık.
JWT_ACCESS_TOKEN_SECRET=b***4
HASURA_ADMIN_SECRET=t***4
Bunların bizim deta.space uygulamamızda da çalışması için Spacefile dosyasını güncelledik.
# Spacefile Docs: https://go.deta.dev/docs/spacefile/v0
v: 0
micros:
- name: backend
src: ./
engine: nodejs16
primary: true
public: true
run: node test/app.js
presets:
env:
- name: JWT_ACCESS_TOKEN_SECRET
description: JWT access token için secret
default: "b***4"
- name: HASURA_ADMIN_SECRET
description: hasura admin secret
default: "t***4"
.env dosyasının okunabilmesi için
npm i dotenvile ilgili paket kuruldu. backend\src\app.js içinde çalıştırıldı.
...
import dotenv from "dotenv";
...
dotenv.config();
...
backend\src\clients\hasura.js içinde de aynı işlem tekrarlandı.
import { GraphQLClient } from "graphql-request";
import dotenv from "dotenv";
dotenv.config();
const headers = {
"Content-Type": "application/json",
"x-hasura-admin-secret": process.env.HASURA_ADMIN_SECRET,
};
export default new GraphQLClient(
"https://meeting-app-server.hasura.app/v1/graphql",
{
headers,
}
);
accessToken üretmek için
npm i jsonwebtokenile ilgili paket yüklendi. backend\src\routes\auth\helpers.js içine
import JWT from "jsonwebtoken";
import Boom from "boom";
export const singAccessToken = (user) => {
return new Promise((resolve, reject) => {
const payload = {
"https://hasura.io/jwt/claims": {
// hasurada kullanabilmemiz için gereken tanımlar.
"x-hasura-allowed-roles": ["user"],
"x-hasura-default-role": "user",
"x-hasura-user-id": user.id.toString(),
},
email: user.email,
};
const options = {
expiresIn: "100d", // ne kadar süre için geçerli
issuer: "graphql-egitimi", // tokenı kim vermiş
audience: user.id.toString(), // token kime verilmiş
};
JWT.sign(
payload,
process.env.JWT_ACCESS_TOKEN_SECRET,
options,
(err, token) => {
if (err) {
return reject(Boom.internal("JWT sing error"));
}
resolve(token);
}
); // ilk parametre payload, ikinci parametre access token secret, üçüncü parametre options, dördüncü parametre callback fonksiyon
});
};
user tanımı içinde email olması için backend\src\routes\auth\queries.js içinde gerekli güncelleme yapıldı.
// Kullanıcının varlığını sorgulayan query
export const IS_EXIST_USER = `
query isExist($email: String!) {
users(where: {email: {_eq: $email}}) {
id
}
}
`;
// Kullanıcı ekleyen mutation
export const INSERT_USER_MUTATION = `
mutation insertUser ($input: users_insert_input!){
insert_users_one(object: $input) {
id
email
}
}
`;
backend\src\routes\auth\helpers.js içinde ürettiğimiz accessToken üretme fonksiyonu backend\src\routes\auth\index.js içinde import edilip kullanıldı.
import express from "express";
import Boom from "boom";
import bcrypt from "bcryptjs";
import { singAccessToken } from "./helpers"; // accessToken için import edildi.
import Hasura from "../../clients/hasura";
import { IS_EXIST_USER, INSERT_USER_MUTATION } from "./queries";
import { registerSchema } from "./validations";
const router = express.Router();
router.post("/register", async (req, res, next) => {
const input = req.body.input.data;
input.email = input.email.toLowerCase();
// validasyon işlemi
const { error } = registerSchema.validate(input);
if (error) {
return next(Boom.badRequest(error.details[0].message));
}
try {
const isExistUser = await Hasura.request(IS_EXIST_USER, {
email: input.email,
});
// user zaten varsa hata ver
if (isExistUser.users.length > 0) {
throw Boom.conflict(`user already exist (${input.email})`);
}
// Parola şifreleme işlemi
const salt = await bcrypt.genSalt(10);
const hash = await bcrypt.hash(input.password, salt); // şifrelenmiş parola
// user yoksa user oluştur
const { insert_users_one: user } = await Hasura.request(
INSERT_USER_MUTATION,
{
input: {
...input,
password: hash,
},
}
);
const accessToken = await singAccessToken(user); // accessToken üretildi.
res.json({ accessToken });
} catch (err) {
return next(Boom.badRequest(err));
}
});
export default router;
jwt.io sitesinde aldığımız accesstoken içinde neyi barındırıyor görebiliriz.
Hasura cloudun accessToken'ı tanıması için https://cloud.hasura.io/project içinde projemize girip "Env vars" alanına "HASURA_GRAPHQL_JWT_SECRET" adında
{"key":"buraya_secret_key_yazıyoruz_1234","type":"HS256"} tanımında bir girdi eklenir. "key" karşısına bizim belirlediğimiz "JWT_ACCESS_TOKEN_SECRET" değeri girildi.
Aynı alana yetkisiz kullanıcı tanımı için: "HASURA_GRAPHQL_UNAUTHORIZED_ROLE" adında anonymousdeğeri girildi
hasura cloud console alanında "DATA">users>"Permissions" alanında user ve anonymous tanımarı eklendi.
hasura cloud console alanında "Request Headers" alanına "key": "Authorization", "value": "Bearer <acessToken>" yazılır ve "x-hasura-admin-secret" tiki kaldırılırsa sadece "user" için verilen yetkiyle yapılacak işlemler görünür. Diğer işlemlere izin vermez.
Login için gereken action oluşturulacak.
Hasura Cloud Console > "ACTIONS" > "Create"
Action Definition
type Mutation {
# Define your action here
login (data: LoginInput!): LoginOutput!
}
Type Configuration
type LoginOutput {
accessToken: String!
}
input LoginInput {
email: String!
password: String!
}
"Webhook (HTTP/S) Handler": https://backend-1-m7357908.deta.app/auth/login "Actions" > "login" > "Permissions" alanında "anonymous" için yrtki verildi. "user" bu yetkiye sahip değil. Aynı işlem register için de tekrarlandı.
backend\src\routes\auth\queries.js dosyasına login için gereken query eklendi.
...
export const LOGIN_QUERY = `
query login($email: String!) {
users(
where: {
email: {
_eq: $email
}
}
limit: 1
){
id
email
password
}
}
`;
backend\src\routes\auth\validations.js dosyasına login validasyonu eklendi.
...
export const loginSchema = Joi.object({
email: Joi.string().email().required(),
password: Joi.string().min(6).max(60).required(),
});
backend\src\routes\auth\index.js içine login işlemi tanımlandı.
...
router.post("/login", async (req, res, next) => {
const input = req.body.input.data;
input.email = input.email.toLowerCase();
const { error } = loginSchema.validate(input);
if (error) {
return next(Boom.badRequest(error.details[0].message));
}
try {
const { users } = await Hasura.request(LOGIN_QUERY, {
email: input.email,
});
if (users.length === 0) {
throw Boom.unauthorized("Email or password is incorrect");
}
const user = users[0];
const isMatch = await bcrypt.compare(input.password, user.password);
if (!isMatch) {
throw Boom.unauthorized("Email or password is incorrect");
}
const accessToken = await singAccessToken(user);
return res.json({ accessToken });
} catch (err) {
return next(err);
}
});
...
accessToken üzerinden kullanıcıyı bulacağız.
Hasura Cloud Console alanıda "Actions" > "Create"
Action Definition
type Query {
me: MeOutput
}
Type Configuration
type MeOutput {
user_id: String!
}
"Webhook (HTTP/S) Handler": https://backend-1-m7357908.deta.app/auth/me Permission alanında "user" rolüne yetki verildi.
backend\src\routes\auth\helpers.js içinde token verfy işlemi için fonksiyon yazıldı.
...
export const verifyAccessToken = (req, res, next) => {
const authHeader = req.headers.authorization || req.query.token?.toString();
if (!authHeader) {
return next(Boom.unauthorized("No token provided"));
}
const bearerToken = authHeader.split(" ");
const token = bearerToken[bearerToken.length - 1];
JWT.verify(token, process.env.JWT_ACCESS_TOKEN_SECRET, (err, decoded) => {
if (err) {
const message =
err.name === "JsonWebTokenError" ? "Unouthorized" : err.message;
return next(Boom.unauthorized(message));
}
req.payload = decoded;
req.token = token;
next();
});
};
Bu fonksiyon backend\src\routes\auth\index.js içinde import edildi ve "/me" route'u içinde kullanıldı. "/me" route'u ve verifyAccessToken fonksiyonu kullanılarak header ile gelen accessToken'ın ait olduğu id dönüldü.
...
router.post("/me", verifyAccessToken, async (req, res, next) => {
const { aud } = req.payload;
return res.json({
user_id: aud,
});
});
...
"Actions" > "me" > "Relationship" > "Add a relationship"
Gelen formda user_id ile user tablosundaki id eşleştirildi. Bu sayede me ile elde ettiğimiz user_id ile userın izin verilen tüm bilgilerine ulaşabildik.
Önce meeting ile görmek istediğimiz veri için bir sorgu yazıyoruz. İzinleri bunun üzerinden kurgulayacağız.
query MyQuery {
meetings{
id
title
user{
id
name
surname
}
participants{
user{
id
name
surname
}
}
}
}
"DATA" altından her tablo için "Permissions" alanında izinler tanımlanır.
meetings > permissions alanında user için "Without any checks" ile tüm alanlara izin verildi. "Allow role user to make aggregation queries" de seçildi. aynı işlem participants > permissions altında da yapıldı.
Bu haliyle kullanıcı kendisinin dahil olmadığı meetingleri de görür. Bunu engellemek için "Without any checks" "With custom check" olarak güncellendi.
{
"_or":[
{
"user_id":{
"_eq":"X-Hasura-User-Id"
}
},
{
"participants":{
"user_id":{
"_eq":"X-Hasura-User-Id"
}
}
}
]
}
Bu durumda user sadece oluşturduğu veya katılımcısı olduğu meeting'leri görüntüler.
Oluşturduğumuz tabloda olmayan ama tablodaki alanlarla hesaplanarak oluşturulabilen alanlardır. Örneğin user tablosunda name ve surname var. Bunlarla fullname yapılabilir.
"DATA" > "users" > "Modify" > "Computed Fields"
Computed Field Name: fullName
Function Schema:public
Function Name: "Create New" ile postgreSQL dilinde bir fonksiyon yazıyoruz.
CREATE FUNCTION user_full_name(user_row users)
RETURNS TEXT AS $$
SELECT user_row.name || ' ' || user_row.surname
$$ LANGUAGE sql STABLE;
fonksiyonu kaydediyoruz. Sonra baştaki adımları tekrar yapıyoruz. Function Name: kısmında az önce oluşturduğumuz fonksiyonu seçiyoruz ve kaydediyoruz.
DATA > users > permissions alanıda user için bu alana da izin verdikten sonra artık sorguda fullName diye bir parametremiz de oluyor.
meeting katılımcılarına mail gönderme işlemi. Öncelikle mailspons.com üzerinden her user için fake mail alıp veri tabanımıza ekledik. Bu istediğimiz gibi çalışmadığından (biraz yavaş) tempinbox.xyz üzerinden tekrar fake mail alıp user'larımıza tanımladık.
Hasura Cloud Console > "EVENT" > "Event Triggers" > "Create"
Trigger Name: meeting_created
Database: default
Schema/Table: public, meetings
Trigger Operations: Insert
Webhook (HTTP/S) Handler: https://backend-1-m7357908.deta.app/webhooks/meeting_created
"Create Event Triger"
backend\src\app.js içinde webhooks için gereken routing yapıldı.
...
// routes
...
import webhooks from "./routes/webhooks";
...
backend\src\routes\webhooks\queries.js içinde webhook ile weri çekilecek sorgu yazıldı.
export const GET_MEETING_PARTICIPANTS = `
query meeting_participant($id: Int!) {
meetings_by_pk(id: $id) {
title
user {
fullName
}
participants {
user {
email
}
}
}
}
`;
mail gönderebilmek için
npm i nodemailerpaketi kuruldu.
backend\src\routes\webhooks\index.js içinde events triger ile gelen veri alındı. Bu veri ile sorgu yapıldı. Sorgudan gelen veriler ile de mail gönderildi.
import express from "express";
import nodemailer from "nodemailer";
import Hasura from "../../clients/hasura";
import { GET_MEETING_PARTICIPANTS } from "./queries";
const router = express.Router();
const transporter = nodemailer.createTransport({
service: "gmail",
auth: {
user: "myhasurabackendd@gmail.com",
pass: "efuvzfpjocfdemnc",
},
});
router.get("/meeting_created", (req, res) => {
res.send("Hello World");
});
router.post("/meeting_created", async (req, res, next) => {
const meeting = req.body.event.data.new;
const { meetings_by_pk } = await Hasura.request(GET_MEETING_PARTICIPANTS, {
id: meeting.id,
});
const title = meeting.title;
const { fullName } = meetings_by_pk.user;
const participants = meetings_by_pk.participants
.map(({ user }) => user.email)
.toString();
const mailOptions = {
from: "myhasurabackendd@gmail.com",
to: participants,
subject: `${fullName} sizi bir görüşmeye davet etti`,
text: `${fullName} sizi ${title} adlı görüşmeye davet etti.`,
};
transporter.sendMail(mailOptions, (error, info) => {
if (error) {
throw new Error(error);
}
res.json({ info });
});
});
export default router;
Bu dosyada kullanılan process.env.... kısımları backend\.env ve backend\Spacefile dosyalarına eklendi.
Mail il gelen daveti kabul etmek
"Hasura Cloud Console" > "DATA" > participants kısmına "is_approved" adında "boolean" default değeri "false" bir sütun oluşturduk.
"Hasura Cloud Console" > "DATA" > participants > "Permissions" altında user için update kısmına "With custom check" ile
{"user_id":{"_eq":"X-Hasura-User-Id"}} koşulu ile "is_approved" alanına izin verdik. "is_approved" için select izni de verdik
Görüşmeye yarım saat kala katılımcılara mail gönder.
"Hasura Cloud Console" > "EVENTS" > One-off Scheduled Events içinde bu dökümandan faydalanarak backend\src\routes\webhooks\index.js içinde bir obje oluşturuyoruz.
zaman tanımları için
npm i momentile kurduğumuz paketi kullanacağız. İstek göndermek için de
npm i axioskuruldu.
"Hasura Cloud Console" > "DATA" > meeting içinde "meeting_date" data tipi "timestamp with time zone" olarak güncellendi.
meeting_date bilgisini alabilmek için backend\src\routes\webhooks\queries.js içindeki query güncellendi.
backend\src\routes\webhooks\index.js içinde Scheduled Events oluşturacak yapı kurgulandı.
...
import moment from "moment";
import axios from "axios";
...
router.post("/meeting_created", async (req, res, next) => {
...
const schedule_event = {
// zamanlanmış görev eklemek için gereken obje
type: "create_scheduled_event",
args: {
webhook: "https://backend-1-m7357908.deta.app/webhooks/meeting_reminder", //görev başlatılınca çalıştırılacak webhook
schedule_at: moment(meetings_by_pk.meeting_date).subtract(2, "min"), // görev başlangıç tarihinden 2 dk çıkardık.
payload: {
//webhook tetiklendiğinde gönderilecek data
meeting_id: meeting.id,
},
},
};
const add_event = await axios(
"https://meeting-app-server.hasura.app/v1/query",
{
// endpoint olarak hasura endpointimizi sonuna qraphql yerine query yazarak kullanıyoruz.
method: "POST",
data: JSON.stringify(schedule_event), // ile hazırladığımız Scheduled Events gönderildi.
headers: {
"x-hasura-admin-secret": process.env.HASURA_ADMIN_SECRET,
},
}
);
const event_data = add_event.data;
...
});
router.post("/meeting_reminder", async (req, res, next) => {
// Scheduled Events ile tetiklenecek işlev.
});
export default router;
insert_meeting_one mutation'unu user kullanıcısı ile kullanamabilmek için, "DATA" > "meetings" > "Permissions" alanında user > insert için "with custom check"
{"user_id":{"_eq":"X-Hasura-User-Id"}} kontrolü ile, "Column insert permissions": "title, meeting_date" seçili, "Column presets" - "user_id" - "from session variable" -> "X-Hasura-user-id" izin verildi.
participant ekleyebilmesi için de "DATA" > "meetings" > "Permissions" alanında "with custom check"
{"meeting":{"user_id":{"_eq":"X-Hasura-User-Id"}}} kontrolü ile, "Column insert permissions": "meeting_id, user_id" seçili izin verildi.
backend\src\routes\webhooks\queries.js içine aşağıdaki sorguyu ekledik.
export const GET_MEETING_PARTICIPANTS_REMINDER = `
query meeting_participant($id: Int!) {
meetings_by_pk(id: $id) {
title
meeting_date
user {
fullName
email
}
participants(
where: {
is_approved: {
_eq: true
}
}
) {
user {
email
}
}
}
}
`;
bu sorguda bize sadece katılımını onaylayan katılımcıları verdi.
backend\src\routes\webhooks\index.js içinde katılımcılara ve meeting oluşturana mail gönderecek olan fonksiyonlar yazıldı. Dosyanın son hali:
import express from "express";
import nodemailer from "nodemailer";
import moment from "moment";
import axios from "axios";
import Hasura from "../../clients/hasura";
import {
GET_MEETING_PARTICIPANTS,
GET_MEETING_PARTICIPANTS_REMINDER,
} from "./queries";
const router = express.Router();
const transporter = nodemailer.createTransport({
service: "gmail",
auth: {
user: "myhasurabackendd@gmail.com",
pass: "efuvzfpjocfdemnc",
},
});
router.get("/meeting_created", (req, res) => {
res.send("Hello World");
});
router.post("/meeting_created", async (req, res, next) => {
const meeting = req.body.event.data.new;
const { meetings_by_pk } = await Hasura.request(GET_MEETING_PARTICIPANTS, {
id: meeting.id,
});
const title = meeting.title;
const { fullName } = meetings_by_pk.user;
const participants = meetings_by_pk.participants
.map(({ user }) => user.email)
.toString();
const schedule_event = {
// zamanlanmış görev eklemek için gereken obje
type: "create_scheduled_event",
args: {
webhook: "https://backend-1-m7357908.deta.app/webhooks/meeting_reminder", //görev başlatılınca çalıştırılacak webhook
schedule_at: moment(meetings_by_pk.meeting_date).subtract(2, "min"), // görev başlangıç tarihinden 2 dk çıkardık.
payload: {
//webhook tetiklendiğinde gönderilecek data
meeting_id: meeting.id,
},
},
};
const add_event = await axios(
"https://meeting-app-server.hasura.app/v1/query",
{
// endpoint olarak hasura endpointimizi sonuna qraphql yerine query yazarak kullanıyoruz.
method: "POST",
data: JSON.stringify(schedule_event),
headers: {
"x-hasura-admin-secret": process.env.HASURA_ADMIN_SECRET,
},
}
);
const event_data = add_event.data;
const mailOptions = {
from: "myhasurabackendd@gmail.com",
to: participants,
subject: `${fullName} sizi bir görüşmeye davet etti`,
text: `${fullName} sizi ${title} adlı görüşmeye davet etti.`,
};
transporter.sendMail(mailOptions, (error, info) => {
if (error) {
throw new Error(error);
}
res.json({ info });
});
});
router.post("/meeting_reminder", async (req, res, next) => { // One-off Scheduled Events tetiklendiğinde yönleneceği endpoint route'u
const { meeting_id } = req.body.payload; // One-off Scheduled Events ile gönderilen data
const { meetings_by_pk } = await Hasura.request(
GET_MEETING_PARTICIPANTS_REMINDER,
{
id: meeting_id,
}
);
const title = meetings_by_pk.title;
const { email } = meetings_by_pk.user;
const participants = meetings_by_pk.participants.map(
({ user }) => user.email
);
participants.push(email);
const mailOptions = {
from: "myhasurabackendd@gmail.com",
to: participants.toString(),
subject: `"${title}" başlıklı görüşmeniz birazdan başlayacak`,
text: `"${title}" başlıklı görüşmeniz iki dakika sonra başlayacak Katılmak için aşağıdaki bağlantıyı kullanın`,
};
transporter.sendMail(mailOptions, (error, info) => {
if (error) {
throw new Error(error);
}
res.json({ info });
});
});
export default router;