- what is GraphQL?
- How to use GraphQL
- 프로젝트에 적용하기
우선 REST API와 비교해봅시다
REST API : Stateless 하기 때문에 데이터를 주고받기 위해 Client와 독립적인 API를 사용합니다.
-
REST API 의 한계
-
GET /post 요청 : Fetch Post
{ id: '1', title: 'First Post', content: '...', creator: {...} }
만약에 id 값만 필요하고 content는 필요하지 않다면?
-
그리고 서비서가 계속 커진다면 Endpoint(router) 를 계속해서 만들어야하고 관리가 어려워 질것입니다.
이 단점들을 해소하기 위해 GraphQL을 Facebook 에서 만들었습니다.!
-
GraphQL API : GraphQL 또한 Stateless 하지만 데이터를 주고받기 위해 REST API에는 없던 다양한 query를 사용할 수 있습니다. GraphQL은 POST 요청으로 통신하고 body에 query 문을 넣습니다.
따라서 Endpoint를 하나만 사용하고 Client 서버에서 오는 다양한 요청들을 처리할 수 있습니다.
GraphQL Query 구조
{
query {
user{
name
age
}
}
}Operation Types
- Query => Retrieve Data ("GET")
- Mutation => Manipulate Data ("POST", "PUT", "PATCH", "DELETE")
- Subscription => Set up real-time connection via WebSockets
- 일반 Node(+Express) Server!
- One Single Endpoint (typically / graphql)
- POST 요청을 사용해서 body에 데이터 구조를 넣습니다.
- Server-Side에서 Resolver가 request body를 분석해서 데이터를 가져오고 준비해서 리턴합니다.
- socket.io 삭제하기
- app.js 라우터 삭제하기
- router 폴더 삭제하기
- DOCS : https://graphql.org
-
$ npm i --save graphql express-graphql -
graphql/schema.js
-
schema 정의하기
const { buildSchema } = require('graphql'); module.exports = buildSchema(` type TestData { text: String! views: Int! } type RootQuery { hello: TestData! } schema { query: RootQuery } `);
-
-
graphql/resolvers.js
-
resolvers 정의하기
module.exports = { hello() { return { text: 'Hello World!', views: 1234 }; } };
-
-
app.js
-
graphql 가져오기
const graphqlHttp = require('express-graphql'); const graphqlSchema = require('./graphql/schema') const graphqlResolver = require('./graphql/resolvers') app.use( '/graphql', graphqlHttp({ schema: graphqlSchema, rootValue: graphqlResolver }) );
-
-
postman으로 테스트 하기
-
postman request :
http://localhost:8080/graphql로 아래 쿼리문 보내기{ "query": "{ hello { text } }" } -
response
{ "data": { "hello": { "text": "Hello World!" } } }
-
- socket.io 삭제하기
-
graphql/shcmea.js
-
스키마 구성하기
const { buildSchema } = require('graphql'); module.exports = buildSchema(` type Post { _id: ID! title: String! content: String! imageUrl: String! creator: User! createdAt: String! uadatedAt: String! } type User{ _id: ID! name: String! email: String! password: String status: String! posts: [Post!]! } input UserInputData { email: String! name: String! password: String! } type RootMutation { createUser(userInput: UserInputData): User! } schema { mutation: RootMutation } `);
-
-
graphql/resolves.js
-
createUserresolve 구성하기const bcrypt = require('bcryptjs'); const User = require('../models/user'); module.exports = { createUser: async function({ userInput }, req) { //const email = args.userInput.email const existingUser = await User.findOne({ eamil: userInput.email }); if (existingUser) { const error = new Error('User exists already!'); throw error; } const hashedPw = await bcrypt.hash(userInput.password, 12); const user = new User({ email: userInput.email, name: userInput.name, password: hashedPw }); const createdUser = await user.save(); return { ...createdUser._doc, _id: createdUser._id.toString() }; } };
-
-
app.js
-
graphql 정의하는 함수에
graphiql: true넣어주기app.use( '/graphql', graphqlHttp({ schema: graphqlSchema, rootValue: graphqlResolver, graphiql: true }) );
-
-
localhost:8080/graphql로 접속하면 GraphiQL 페이지가 나온다
- 기존 REST API에서는 Router에서 직접 사용자 Input을 검증하는 로직을 사용했습니다.
- GraphQL에서는 Endpoint가 하나 이므로 각 요청을 처리하는 resolve에서 사용자 Input을 검증하는 로직을 사용하도록 하겠습니다.
-
$ npm i --save validator- validator를 설치합니다. 기존 express-validator와 다르게 JavaScript 코드를 사용하여 직접 검증할 수 있습니다.(?)
-
graphql/resolves.js
-
validator 가져오기
const vlidator = require('validator')
-
Email 체크, 비밀번호 길이 검증 로직 구현하기
module.exports = { createUser: async function({ userInput }, req) { const errors = []; if (!validator.isEmail(userInput.email)) { errors.push({ message: '이메일이 유효하지 않습니다.' }); } if ( validator.isEmpty(userInput.password) || !validator.isLength(userInput.password, { min: 5 }) ) { errors.push({ meesage: '비밀번호를 최소 5글자 이상 넣어주세요.' }); } if (errors.length > 0) { const error = new Error('Invalid input.'); error.data = errors; error.code = 422; throw error; } const existingUser = await User.findOne({ email: userInput.email }); if (existingUser) { const error = new Error('User exists already!'); throw error; } const hashedPw = await bcrypt.hash(userInput.password, 12); const user = new User({ email: userInput.email, name: userInput.name, password: hashedPw }); const createdUser = await user.save(); return { ...createdUser._doc, _id: createdUser._id.toString() }; } };
-
-
위 과정에서 잘못된 E-mail을 보내주었을때 아래와 같이 단순한 오류 메세지를 보내줬습니다. 보다시피 오류에 대한 정보가 충분하지 않습니다.
{ "errors": [ { "message": "Invalid input.", "locations": [ { "line": 2, "column": 3 } ], "path": [ "createUser" ] } ], "data": null } -
오류를 더 구체화 해서 Client 서버로 보내줍시다.
-
app.js
-
graphql 함수에 인자로
formatError()메소드를 추가해줍시다.app.use( '/graphql', graphqlHttp({ schema: graphqlSchema, rootValue: graphqlResolver, graphiql: true, formatError(err) { // 타이핑오류라던지 기술적 오류가 없으면 실행 if (!err.originalError) { return err; } const data = err.originalError.data; const message = err.message || 'An error ocuured'; const code = err.originalError.code || 500; return { message: message, status: code, data: data }; } }) );
-
-
graphql/resolves.js
-
Error 객체에 에러 정보 담기
module.exports = { createUser: async function({ userInput }, req) { const errors = []; if (!vlidator.isEmail(userInput.email)) { errors.push({ message: '이메일이 유호하지 않습니다.' }); } if ( !vlidator.isEmpty(userInput.password) || validator.isLength(userInput.password, { min: 5 }) ) { errors.push({ meesage: '비밀번호를 최소 5글자 이상 넣어주세요.' }); } if (errors.length > 0) { // error에 정보 const error = new Error('Invalid input.'); error.data = errors; error.code = 422; throw error; } ...
-
App.js
-
signupHandlersignupHandler = (event, authData) => { event.preventDefault(); this.setState({ authLoading: true }); //GrapQl Query 설정 const graphqlQuery = { query: ` mutation { createUser(userInput: { email: "${authData.signupForm.email.value}", name: "${authData.signupForm.name.value}", password: "${authData.signupForm.password.value}"}) { _id email } } ` }; fetch('http://localhost:8080/graphql', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(graphqlQuery) }) .then(res => { return res.json(); }) .then(resData => { if (resData.errors && resData.errors[0].status === 422) { throw new Error( "Validtaion failed. Make user the email address isn't used yet!" ); } if (resData.errors) { throw new Error('User creation failed!'); } console.log(resData); this.setState({ isAuth: false, authLoading: false }); this.props.history.replace('/'); }) .catch(err => { console.log(err); this.setState({ isAuth: false, authLoading: false, error: err }); }); };
-
-
Back Server : app.js
-
보통 브라우저가 요청을 보내기전에 'OPTIONS'를 서버로 보냅니다. 만약 POST, GET 요청이 아니면 GraphQL에서 자동적으로 다른 요청을 막습니다. 따라서 'OPTIONS' 요청을 허용할 수 있게 해줘야 합니다.
app.use((req, res, next) => { res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader( 'Access-Control-Allow-Headers', 'GET, POST, PUT, PATCH, DELETE' ); res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); console.log(req.method); // OPTIONS 요청 허용 if (req.method === 'OPTIONS') { return res.sendStatus(200); } next(); });
-
- GraphQL에서도 REST API와 마찬가지로 Client를 Stateless 하게 관리합니다. 따라서 토큰을 request에 보내주는 방식을 사용합시다.
-
graphql/schema.js
-
로그인 쿼리와 토큰 설정해주기
... type AuthData { token: String! userId: String! } type RootQuery { login(email: String!, password: String!): AuthData! } ...
-
-
graphql/resolvers.js
-
login 메소드 작성하기, jwt 가져오기
const jwt = require('jsonwebtoken'); module.exports = { ... login: async function ({ email, password }) { const user = await User.findOne({ email: email }); if (!user) { const error = new Error('User not found'); error.code = 401; throw error; } const isEqual = await bcrypt.compare(password, user.password); if (!isEqual) { const error = new Error('Password is incorrect.'); error.code = 401; throw error; } const token = jwt.sign( { userId: user._id.toString(), email: user.email, }, 'somesupersupersecretfromminjae', { expiresIn: '1h' } ); return { token: token, userId: user._id.toString() }; }, };
-
-
Client : App.js
-
loginHandlerGraphQL에 쿼리 보내기
loginHandler = (event, authData) => { event.preventDefault(); const graphqlQuery = { query:
{ login(email: "${authData.email}", password: "${authData.password}) { token userId } }, }; this.setState({ authLoading: true }); fetch('http://localhost:8080/graphql', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(graphqlQuery), }) .then((res) => { return res.json(); }) .then((resData) => { if (resData.errors && resData.errors[0].status === 422) { throw new Error( "Validtaion failed. Make user the email address isn't used yet!" ); } if (resData.errors) { throw new Error('User login failed!'); } // REST API 객체 형태 > GraphQL 객체 형태 console.log(resData); this.setState({ isAuth: true, token: resData.data.login.token, authLoading: false, userId: resData.data.login.userId, }); localStorage.setItem('token', resData.data.login.token); localStorage.setItem('userId', resData.data.login.userId); const remainingMilliseconds = 60 * 60 * 1000; const expiryDate = new Date( new Date().getTime() + remainingMilliseconds ); localStorage.setItem('expiryDate', expiryDate.toISOString()); this.setAutoLogout(remainingMilliseconds); }) ... }; -
-
graphql/schema.js
-
스키마 작성하기
const { buildSchema } = require('graphql'); module.exports = buildSchema(` ... input PostInputData { title: String! content: String! imageUrl: String! } type RootMutation { createUser(userInput: UserInputData): User! createPost(postInput: PostInputData): Post! } ... ` );
-
-
graphql/resolves.js
-
createPostresolve 만들기, mongoose Post 모델 가져오기const Post = require('../models/post'); module.exports = { ... createPost: async function ({ postInput }, req) { // 1. userInput 검사하기 const errors = []; if ( validator.isEmpty(postInput.title) || !validator.isLength(postInput.title, { min: 3 }) ) { errors.push({ message: 'Title is invalid.' }); } if ( validator.isEmpty(postInput.content) || !validator.isLength(postInput.content, { min: 3 }) ) { errors.push({ message: 'content is invalid.' }); } if (errors.length > 0) { const error = new Error('Invalid Input'); error.data = errors; error.code = 422; throw error; } // 2. 로직수행 (토큰 검사) // 3. mongoDB에 저장하기 const post = new Post({ title: postInput.title, imageUrl: postInput.imageUrl, content: postInput.content, }); const createdPost = await post.save(); console.log(createdPost); return { ...createdPost._doc, _id: createdPost._id.toString(), createdAt: createdPost.createdAt.toISOString(), updatedAt: createdPost.updatedAt.toISOString(), }; }, };
-
-
middleware/auth.js
-
토큰 검증 로직 GraphQL 구조에서는 REST API와 비슷하지만 약간 다르게 토큰이 검증되면 error를 바로 throw하지 않고 header에
true또는false상태를 넣어 줍니다.const jwt = require('jsonwebtoken'); module.exports = (req, res, next) => { const authHeader = req.get('Authorization'); if (!authHeader) { req.isAuth = false; return next(); } const token = authHeader.split(' ')[1]; let decodedToken; try { // 2번째 인자는 이전에 설정했던 토큰 암호 decodedToken = jwt.verify(token, 'somesupersupersecretfromminjae'); } catch (err) { req.isAuth = false; return next; } if (!decodedToken) { req.isAuth = false; return next; } // decode 됐기 때문에 이전에 설정해주었던 userId에 접근할 수 있다. req.userId = decodedToken.userId; req.isAuth = true; next(); };
-
-
app.js
-
모든 요청에 토큰 상태 확인하기
const auth = require('./middleware/auth'); app.use(auth);
-
-
graphql/resolvers.js
-
createPost메소드에 토큰 검증 상태 확인하기middleware/auth.js에서 보내준 상태 값으로 올바른 request인지 변조된 request인지 검사합니다.createPost: async function ({ postInput }, req) { // 1. 토큰 검사하기 if (!req.isAuth) { const error = new Error('토큰이 검증되지 않았습니다.'); error.code = 401; throw error; } ... }
-
createPost전체 로직createPost: async function ({ postInput }, req) { // 1. 토큰 검사하기 if (!req.isAuth) { const error = new Error('토큰이 검증되지 않았습니다.'); error.code = 401; throw error; } // 2. userInput 검사하기 const errors = []; if ( validator.isEmpty(postInput.title) || !validator.isLength(postInput.title, { min: 3 }) ) { errors.push({ message: 'Title is invalid.' }); } if ( validator.isEmpty(postInput.content) || !validator.isLength(postInput.content, { min: 3 }) ) { errors.push({ message: 'content is invalid.' }); } if (errors.length > 0) { const error = new Error('Invalid Input'); error.data = errors; error.code = 422; throw error; } // 3. 토큰에서 저장한 user가져오기 const user = await User.findById(req.userId); if (!user) { const error = new Error('Invalid user'); error.code = 401; throw error; } // 4. mongoDB에 저장하기 const post = new Post({ title: postInput.title, imageUrl: postInput.imageUrl, content: postInput.content, creator: user, }); const createdPost = await post.save(); // 5. user에 새로만든 post 넣어주기 user.posts.push(createdPost); await user.save(); return { ...createdPost._doc, _id: createdPost._id.toString(), createdAt: createdPost.createdAt.toISOString(), updatedAt: createdPost.updatedAt.toISOString(), }; },
-
-
Client: .../Feed.js
-
finishEditHandlerfinishEditHandler = (postData) => { this.setState({ editLoading: true, }); const formData = new FormData(); formData.append('title', postData.title); formData.append('content', postData.content); formData.append('image', postData.image); // 1. GraphQL Query 작성하기 const graphqlQuery = { query: ` mutation { createPost(postInput: {title: "${postData.title}", content: "${postData.contetn}", imageUrl: "someUrl"}) { _id title content imageUrl creator { name } createdAt } } `, }; // 2. BackEnd Server로 Query 보내기 fetch('http://localhost:8080/graphql', { method: 'POST', body: JSON.stringify(graphqlQuery), headers: { Authorization: 'Bearer ' + this.props.token, 'Content-Type': 'application/json', }, }) .then((res) => { return res.json(); }) .then((resData) => { if (resData.errors && resData.errors[0].status === 422) { throw new Error( "Validation failed. Make sure the email address isn't used yet!" ); } if (resData.errors) { throw new Error('Create user failed!'); } console.log(resData); const post = { _id: resData.data.createPost._id, title: resData.data.createPost.title, content: resData.data.createPost.content, creator: resData.data.createPost.creator, createdAt: resData.data.createPost.createdAt, }; this.setState((prevState) => { return { isEditing: false, editPost: null, editLoading: false, }; }); }) .catch((err) => { console.log(err); this.setState({ isEditing: false, editPost: null, editLoading: false, error: err, }); }); };
-
-
graphql/schema.js
-
Query 작성하기
module.exports = buildSchema(` ... type PostData { posts: [Post!]! totalPosts: Int! } ... type RootQuery { login(email: String!, password: String!): AuthData! posts: PostData! } schema { mutation: RootMutation query: RootQuery } `);
-
-
graphql/resolvers.js
-
postsresolver 작성하기posts: async function (args, req) { if (!req.isAuth) { const error = new Error('권한이 없습니다.'); error.code = 401; throw error; } const totalPosts = await Post.find().countDocuments(); const posts = await Post.find().sort({ createdAt: -1 }).populate('creator'); //mongodb 객체 형태로 보내주면 graphql이 읽을 수 없으므로 변환해서 보내주기 return { posts: posts.map((p) => { return { ...p._doc, _id: p._id.toString(), createdAt: p.createdAt.toISOString(), updatedAt: p.updatedAt.toISOString(), }; }), totalPosts: totalPosts, }; },
-
-
...Feed.js
-
loadPostsloadPosts = (direction) => { if (direction) { this.setState({ postsLoading: true, posts: [] }); } let page = this.state.postPage; if (direction === 'next') { page++; this.setState({ postPage: page }); } if (direction === 'previous') { page--; this.setState({ postPage: page }); } // 1. graphql Query 작성하기 const graphqlQuery = { query: ` { posts{ posts{ _id title content creator{ name } createdAt } totalPosts } } `, }; // 2. Server로 Query 보내기 fetch('http://localhost:8080/graphql', { method: 'POST', headers: { Authorization: 'Bearer ' + this.props.token, 'Content-Type': 'application/json', }, body: JSON.stringify(graphqlQuery), }) // 3. response 다루기 .then((res) => { return res.json(); }) .then((resData) => { if (resData.errors) { throw new Error('포스트를 가져오는데 Error 발생'); } this.setState({ posts: resData.data.posts.posts.map((post) => { return { ...post, imagePath: post.imageUrl, }; }), totalPosts: resData.data.posts.totalPosts, postsLoading: false, }); }) .catch(this.catchError); };
-
finishEditHandler에서 post생성하고 즉시 업데이트하기finishEditHandler = (postData) => { ... const post = { _id: resData.data.createPost._id, title: resData.data.createPost.title, content: resData.data.createPost.content, creator: resData.data.createPost.creator, createdAt: resData.data.createPost.createdAt, }; // 업데이트하고 setState설정해줘서 바로 업데이트하기 this.setState((prevState) => { let updatedPosts = [...prevState.posts]; if (prevState.editPost) { const postIndex = prevState.posts.findIndex( (p) => p._id === prevState.editPosts._id ); updatedPosts[postIndex] = post; } else { updatedPosts.unshift(post); } return { posts: updatedPosts, isEditing: false, editPost: null, editLoading: false, }; }); }) .... };
-
-
graphql/schema.js
-
posts query에 인자 넣어주기
`posts(page: Int): PostData `
-
-
graphlq/resolvers.js
-
postsposts: async function ({ page }, req) { if (!req.isAuth) { const error = new Error('권한이 없습니다.'); error.code = 401; throw error; } // 1. page 설정이 없으면 1페이지 보여주기 if (!page) { page = 1; } // 2. page 설정 const perPage = 2; const totalPosts = await Post.find().countDocuments(); const posts = await Post.find() .sort({ createdAt: -1 }) .skip((page - 1) * perPage) .limit(perPage) .populate('creator'); ... }
-
-
Client:.../Feed.js
-
loadPostsposts 쿼리에 페이지 인자 넣기const graphqlQuery = { query: ` { posts(page: ${page}){ posts{ _id title content creator{ name } createdAt } totalPosts } } `,
-
finishEditHandler에 페이지 로직에 새로 만든 게시물 스킵하기this.setState((prevState) => { let updatedPosts = [...prevState.posts]; if (prevState.editPost) { const postIndex = prevState.posts.findIndex( (p) => p._id === prevState.editPosts._id ); updatedPosts[postIndex] = post; } else { updatedPosts.pop(); updatedPosts.unshift(post); } return { posts: updatedPosts, isEditing: false, editPost: null, editLoading: false, }; });
-
-


