(feat. NomadCoder, Apollo, tech.kakao)
1. Movie API 만들기
개념잡기
1) GraphQL은 일종의 SQL(Structed Query Language)
2) GraphQL의 목적: 클라이언트가 서버로부터 데이터를 효율적으로 가져오는 것
•
일반 SQL의 목적: DB 내 데이터를 효율적으로 가져오는 것
3) Query vs Mutation
•
Query: DB에 특정 데이터를 요청하는 명령
•
Mutation: 서버로부터 받은 데이터를 가공하는 작업
•
차이점: Query는 데이터를 읽는데 사용함(R), Mutation은 데이터를 변조하는데 사용함(CUD)
4) Schema/Type
•
schema: 각 데이터 자료형에 대한 타입 / Query의 명령의 반환값에 대한 타입
•
주의할 점: resolver의 쿼리 명령과 shema 내 명령이 같아야 함
5) resolver: JS 기반으로 Query에 대한 함수 선언
가. 환경설정
1) yarn init / yarn add graphql-yoga
•
graphql-yoga: graphql을 쉽게 사용할 수 있는 패키지
2) yarn global add nodemon
3) package.json
"scripts": {
"start": "nodemon"
}
TypeScript
복사
4) yarn start 입력 시 에러 발생
가) 에러 메세지
[nodemon] Internal watch failed: EBUSY: resource busy or locked, lstat 'D:\pagefile.sys'
error Command failed with exit code 1.
TypeScript
복사
→ 구글링 결과, mallware에 의해 컴퓨팅 자원이 너무 많이 소모되어서 그렇다던데... 해당 되는 mallware는 없음
→ 프로젝트 파일 이동(D 드라이브 → C 드라이브)
5) babel 모듈 설치
yarn add babel-node --dev
yarn global add babel-cli
yarn add babel-preset-env babel-preset-stage-3 --dev
TypeScript
복사
•
babel stage 3가 deprecated 등 여러 이슈 발생에 따라 아래의 패키지 추가 설치
npm i nodemon -D
npm i @babel/cli -D
npm i @babel/core -D
npm i @babel/node -D
npm i @babel/preset-env -D
TypeScript
복사
6) 기타 설정
// package.json
"scripts": {
"start": "nodemon --exec babel-node index.js"
}
// .babelrc
{
"presets": [
[
"@babel/preset-env",
{
"useBuiltIns": "entry"
}
]
]
}
TypeScript
복사
7) 서버 관련 코드
// index.js
import { GraphQLServer } from "graphql-yoga";
// ... or using `require()`
// const { GraphQLServer } = require('graphql-yoga')
const server = new GraphQLServer();
server.start(() => console.log("Server is running on localhost:4000"));
TypeScript
복사
8) useBuiltIns 에러 해결
We noticed you're using the `useBuiltIns` option without
declaring a core-js version.
TypeScript
복사
•
.babelrc 설정 추가
{
"presets": [
[
"@babel/preset-env",
{
"useBuiltIns": "entry",
"corejs": 3
}
]
]
}
TypeScript
복사
8) this.context = props.context; 에러 발생
C:\Users\master\Desktop\[git]LocalRepository\201115_MovieApp_GraphQL_Apollo\node_modules\graphql-yoga\dist\index.js:89
this.context = props.context;
^
TypeError: Cannot read property 'context' of undefined
TypeScript
복사
•
GraphQLServer() 내 인자 전달이 없어서 발생하는 문제이므로 아래와 같이 넣어주면 해결됨
const typeDefs = `
type Query {
hello(name: String): String!
}
`;
const resolvers = {
Query: {
hello: (_, { name }) => `Hello ${name || "World"}`,
},
};
const server = new GraphQLServer({ typeDefs, resolvers });
TypeScript
복사
9) node-fetch 설치
•
A light-weight module that brings window.fetch to Node.js
•
yarn add node-fetch
나. GraphQL로 해결할 수 있는 문제
1) Over-fetching
•
실제 기능에 필요한 정보 보다 더 많은 정보를 서버에서 받는 것
•
Over-fetching의 문제는 데이터를 비효율적으로 받아온다는 것과 협업 시 해당 요청의 목적에 대해 명확성이 떨어진다는 것
2) Under-fetching
•
REST API에서 하나의 기능을 완성하기 위해 여러번 서버에 요청하는 것
3) GraphQL의 해결방법
•
한 번의 요청(하나의 쿼리)으로 특정 기능에 필요한 정보를 모두 받을 수 있음
다. Schema & Query
1) creating the first query
가) schema.graphql 작성
•
'!'는 필수적으로 반환해야 하는 값을 명시
type Query {
name: String!
}
TypeScript
복사
나) resolvers 작성
const resolvers = {
Query: {
name: () => "MJ",
},
};
export default resolvers;
TypeScript
복사
다) index.js 작성
import { GraphQLServer } from "graphql-yoga";
import resolvers from "./graphql/resolvers";
const server = new GraphQLServer({
typeDefs: "./graphql/schema.graphql",
resolvers,
});
server.start(() => console.log("Server is running on localhost:4000"));
TypeScript
복사
2) creating the second query
가) schema.graphql 작성
type Person {
name: String!
age: Int!
gender: String!
}
type Query {
MJ: Person!
}
TypeScript
복사
나) resolvers 작성
const Person = {
name: "Manjin Bae",
age: 30,
gender: "Male",
};
const resolvers = {
Query: {
MJ: () => Person,
},
};
export default resolvers;
TypeScript
복사
3) Array 반환 쿼리 작성
가) schema.graphql 작성
type Person {
id: Int!
name: String!
age: Int!
gender: String!
}
type Query {
showPeople: [Person]!
}
TypeScript
복사
나) resolvers 작성
import { People } from "./db";
const resolvers = {
Query: {
showPeople: () => People,
},
};
export default resolvers;
TypeScript
복사
다) db.js 작성
const People = [
{
id: 0,
name: "MJ",
age: 10,
gender: "male",
},
{
id: 1,
name: "M",
age: 10,
gender: "male",
},
{
id: 2,
name: "J",
age: 10,
gender: "male",
},
{
id: 3,
name: "MJ",
age: 110,
gender: "female",
},
{
id: 4,
name: "MaaJ",
age: 1220,
gender: "female",
},
];
export { People };
TypeScript
복사
4) 함수 반환 쿼리 작성
가) schema.graphql 작성
type Person {
id: Int!
name: String!
age: Int!
gender: String!
}
type Query {
showPeople: [Person]!
showFilteredPeople(id: Int!): Person!
}
TypeScript
복사
나) resolvers 작성
•
인자를 넘길 때, 아래와 같은 (_, arg) 형태임
import { People, getById } from "./db";
const resolvers = {
Query: {
showPeople: () => People,
showFilteredPeople: (_, arg) => getById(arg.id),
},
};
export default resolvers;
TypeScript
복사
다) db.js 작성
const People = [
// 생략
{
id: 4,
name: "MaaJ",
age: 1220,
gender: "female",
},
];
const getById = (id) => {
const filteredPeople = People.filter((person) => person.id === id);
return filteredPeople[0];
};
export { People, getById };
TypeScript
복사
라. Mutation
1) addMovie Mutation 추가
가) schema.graphql 작성
type Movie {
id: Int!
name: String!
score: Int!
}
type Query {
getMovies: [Movie]!
getFilteredMovie(id: Int!): Movie!
}
type Mutation {
addOneMovie(name: String!, score: Int!): Movie!
}
TypeScript
복사
나) resolvers 작성
•
인자를 넘길 때, 아래와 같은 (_, arg) 형태임
import { Movies, getById, addMovie, deleteMovie } from "./db";
const resolvers = {
Query: {
getMovies: () => Movies,
getFilteredMovie: (_, arg) => getById(arg.id),
},
Mutation: {
addOneMovie: (_, arg) => addMovie(arg.name, arg.score),
},
};
export default resolvers;
TypeScript
복사
다) db.js 작성
const Movies = [
// 생략
{
id: 4,
name: "MaaJ",
score: 2,
},
];
const getById = (id) => {
const filteredMovie = Movies.filter((movie) => movie.id === id);
return filteredMovie[0];
};
const addMovie = (name, score) => {
const newMovie = {
id: Movies.length + 1,
name,
score,
};
Movies.push(newMovie);
return newMovie;
};
TypeScript
복사
2) deleteMovie Mutation 추가
•
deleteMovie의 경우 반환타입이 boolean이고 하나만 있으므로 subfiled가 없음. playground에 deleteOneMovie(id: 0)만 입력해도 결과값 확인 가능
가) schema.graphql 작성
type Mutation {
addOneMovie(name: String!, score: Int!): Movie!
deleteOneMovie(id: Int!): Boolean!
}
TypeScript
복사
나) resolvers 작성
// 생략
Mutation: {
addOneMovie: (_, arg) => addMovie(arg.name, arg.score),
deleteOneMovie: (_, arg) => deleteMovie(arg.id),
},
};
export default resolvers;
TypeScript
복사
다) db.js 작성
const deleteMovie = (id) => {
const leftMovies = Movies.filter((movie) => movie.id !== id);
if (leftMovies.length < Movies.length) {
Movies = leftMovies;
return true;
} else {
return false;
}
};
TypeScript
복사
마. Wrapping REST API with GraphQL
1.
Fetch를 활용한 영화정보 API Wrapping
가) node-fetch 설치
나) 영화정보 API에 필요한 데이터에 한해 fetch
const fetch = require("node-fetch");
fetch(REQUESTED_URI)
.then((res) => res.json())
.then((json) => json.data.movies);
TypeScript
복사
다) Movie 타입 및 Query 타입 정리
type Movie {
id: Int!
title: String!
rating: Float!
runtime: Int!
language: String!
genres: [String]!
summary: String!
medium_cover_image: String!
}
type Query {
getAllMovies(limit: Int!, minRating: Float!): [Movie]!
}
TypeScript
복사
라) fetch 함수 정의
const MovieAPI_URL = "https://yts.mx/api/v2/list_movies.json?";
const fetch = require("node-fetch");
const getMovies = (limit, minRating) => {
let REQUESTED_URI = MovieAPI_URL;
if (limit > 0) {
REQUESTED_URI += `&limit=${limit}`;
}
if (minRating > 0) {
REQUESTED_URI += `&minimum_rating=${minRating}`;
}
return fetch(REQUESTED_URI)
.then((res) => res.json())
.then((json) => json.data.movies);
};
export { getMovies };
TypeScript
복사
마) Resulvers 정의
import { getMovies } from "./db";
const resolvers = {
Query: {
getAllMovies: (_, args) => getMovies(args.limit, args.minRating),
},
};
export default resolvers;
TypeScript
복사
2. Axios를 활용한 영화정보 API Wrapping
나) Movie 타입 및 Query 타입 정리
type Movie {
id: Int!
title: String!
rating: Float
language: String
genres: [String]
summary: String
medium_cover_image: String
}
type Query {
getAllMovies(limit: Int!, minRating: Float!): [Movie]!
getOneMovie(id: Int!): Movie!
getSuggestedMovies(id: Int!): [Movie]!
}
TypeScript
복사
다) axios 활용하여 함수 정의
const axios = require("axios");
const MOVIE_BASE_URL = "https://yts.mx/api/v2/";
const LIST_MOVIES_URL = `${MOVIE_BASE_URL}list_movies.json`;
const MOVIE_DETAILS_URL = `${MOVIE_BASE_URL}movie_details.json`;
const MOVIE_SUGGESTIONS_URL = `${MOVIE_BASE_URL}movie_suggestions.json`;
const getMovies = async (limit, minRating) => {
const {
data: {
data: { movies },
},
} = await axios(LIST_MOVIES_URL, {
params: {
limit,
minimum_rating: minRating,
},
});
return movies;
};
const getMovie = async (id) => {
const {
data: {
data: { movie },
},
} = await axios(MOVIE_DETAILS_URL, { params: { movie_id: id } });
return movie;
};
const getSuggestions = async (id) => {
const {
data: {
data: { movies },
},
} = await axios(MOVIE_SUGGESTIONS_URL, { params: { movie_id: id } });
return movies;
};
export { getMovies, getMovie, getSuggestions };
TypeScript
복사
라) Resulvers 정의
import { getMovie, getMovies, getSuggestions } from "./db";
const resolvers = {
Query: {
getAllMovies: (_, args) => getMovies(args.limit, args.minRating),
getOneMovie: (_, args) => getMovie(args.id),
getSuggestedMovies: (_, args) => getSuggestions(args.id),
},
};
export default resolvers;
TypeScript
복사
2. Movie WebApp 만들기
가. 환경설정
1) npx create-react-app apollo / 불필요 파일 정리
2) yarn add styled-components react-router-dom apollo-boost @apollo/react-hooks graphql
3) React Route 설정
// App.js
import React from "react";
import { HashRouter as Router, Route } from "react-router-dom";
import Home from "../routes/Home";
import Detail from "../routes/Detail";
function App() {
return (
<Router>
<Route exact path="/" component={Home} />
<Route exact path="/:id" component={Detail} />
</Router>
);
}
TypeScript
복사
4) reset CSS 설정
•
public/reset.css 생성 및 코드 복붙
•
index.html에 link 태그 추가
→ <link rel="stylesheet" href="%PUBLIC_URL%/reset.css" />
나. Apollo Client 설정
1) apollo 환경설정
•
npm install @apollo/client graphql
2) apollo.js 작성
•
위 챕터에서 작성한 API주소를 URI로 설정함
import { ApolloClient, InMemoryCache } from "@apollo/client";
const client = new ApolloClient({
uri: "http://localhost:4000",
cache: new InMemoryCache(),
});
export default client;
TypeScript
복사
3) index.js 작성
•
App에 대해 ApolloProvider로 Wrapping
import React from "react";
import ReactDOM from "react-dom";
import App from "./components/App";
import { ApolloProvider } from "@apollo/client";
import client from "./apollo";
ReactDOM.render(
<ApolloProvider client={client}>
<App />
</ApolloProvider>,
document.getElementById("root")
);
TypeScript
복사
다. Query 작성
1) GetAllMovies(Home.js)
가) 기본설정: GraphQL 서버로 Query 보내고 응답 받기
import React from "react";
import { useQuery, gql } from "@apollo/client";
const GET_MOVIES = gql`
{
getAllMovies {
id
title
medium_cover_image
}
}
`;
const Home = () => {
const { loading, data } = useQuery(GET_MOVIES);
console.log(loading, data);
if (loading) {
return <h2>loading...</h2>;
}
if (data && data.getAllMovies) {
return data.getAllMovies.map((m) => <h1>{m.id}</h1>);
}
};
export default Home;
TypeScript
복사
나) CSS 적용
import styled from "styled-components";
const Container = styled.div`
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
`;
const Header = styled.header`
background-image: linear-gradient(-45deg, #d754ab, #fd723a);
height: 45vh;
color: white;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%;
`;
const Title = styled.h1`
font-size: 60px;
font-weight: 600;
margin-bottom: 20px;
`;
const Subtitle = styled.h3`
font-size: 35px;
`;
const Loading = styled.div`
font-size: 18px;
opacity: 0.5;
font-weight: 500;
margin-top: 10px;
`;
TypeScript
복사
다) 렌더링 부분 수정
•
JSX 부분에서 && 연산자 실행문 부분에 대한 해석은 다음과 같음
→ && 오른쪽 부분이 참일 경우, 맨 왼쪽 부분이 실행됨
ex) loading && data.getAllMovies && data.getAllMovies.map
→ loading과 data.getAllMovies의 값이 있거나 true일 때, data.getAllMovies.map을 실행
const Home = () => {
const { loading, data } = useQuery(GET_MOVIES);
return (
<Container>
<Header>
<Title>Movie App</Title>
<Subtitle>Movies</Subtitle>
</Header>
{loading && <Loading>Loading...</Loading>}
{!loading &&
data.getAllMovies &&
data.getAllMovies.map((m) => <h1>{m.id}</h1>)}
</Container>
);
};
export default Home;
TypeScript
복사
라) Movie 컴포넌트 생성
•
Home.js에 렌더링 되는 여러 Movie에 해당하는 컴포넌트 생성
•
<a href> 태그를 사용하지 않고 Link 사용을 권장
import React from "react";
import { Link } from "react-router-dom";
function Movie(props) {
return (
<div>
<Link to={`/${props.id}`}>{props.id}</Link>
</div>
);
}
export default Movie;
TypeScript
복사
2) GetOneMovie(Detail.js)
가) URI의 Params 가져오기
•
URI의 Param을 각 Detail page로 가져오기
import { useParams } from "react-router-dom";
const { id } = useParams();
TypeScript
복사
나) Param으로 가져온 id로 특정 영화에 대한 상세정보 가져오기
•
주의할 점) Param으로 가져온 id는 String이므로 ParseInt 메소드로 형변환을 거쳐야 함
const GET_MOVIE = gql`
query getMovie($id: Int!) {
getOneMovie(id: $id) {
id
title
medium_cover_image
}
}
`;
function Detail() {
const { id } = useParams();
const { loading, data } = useQuery(GET_MOVIE, {
variables: { id: parseInt(id) },
});
return <div>Detail</div>;
}
TypeScript
복사
다) JSX 파트: 서버로부터 데이터 받은 후 렌더링 처리!
•
서버로부터 데이터 받기 전에 렌더링하면, 렌더링하려는 자료가 undefined 처리되어 에러 발생
•
위의 문제를 방지하기 위해 삼항연산자 또는 Optional Chaning 활용 권장
<Container>
<Column>
<Title>{loading ? "Loading..." : data.getOneMovie.title}</Title>
<Subtitle>
{loading
? ""
: `${data.getOneMovie.language} · ${data.getOneMovie.rating}`}
</Subtitle>
<Description>{data?.getOneMovie?.description_intro}</Description>
</Column>
<Poster
img={
// 주석은 삼항연산자 처리, 본문은 Optional Chaning 처리
// !loading && data.getOneMovie
// ? data.getOneMovie.medium_cover_image
// : ""
data?.getOneMovie?.medium_cover_image
}
></Poster>
</Container>
TypeScript
복사
3) getSuggestedMovies
가) Detail Page 내 GET_MOVIE gql에 추가
const GET_MOVIE = gql`
query getMovie($id: Int!) {
getOneMovie(id: $id) {
title
medium_cover_image
language
rating
description_intro
}
getSuggestedMovies(id: $id) {
id
title
medium_cover_image
}
}
`;
TypeScript
복사
나) JSX 파트 추가
return (
<Container>
<Header>
<Column>
<Title>{loading ? "Loading..." : data.getOneMovie.title}</Title>
<Subtitle>
{loading
? ""
: `${data.getOneMovie.language} · ${data.getOneMovie.rating}`}
</Subtitle>
<Description>{data?.getOneMovie?.description_intro}</Description>
</Column>
<Poster
img={
// 주석은 삼항연산자 처리, 본문은 Optional Chaning 처리
// !loading && data.getOneMovie
// ? data.getOneMovie.medium_cover_image
// : ""
data?.getOneMovie?.medium_cover_image
}
></Poster>
</Header>
{data?.getSuggestedMovies && (
<>
<br />
<br />
<Subtitle>Suggestions</Subtitle>
<br />
<Movies>
{data.getSuggestedMovies.map((movie) => (
<Movie
key={movie.id}
id={movie.id}
image={movie.medium_cover_image}
/>
))}
</Movies>
</>
)}
</Container>
);
}
TypeScript
복사
라. Local State 처리
1) Local State: API에서 넘어온 data를 client에서 조작
•
client side에서 gql로 받아온 데이터에 필드를 추가할 수 있음
2) 좋아요 기능 추가
가) resolver 추가
•
server side의 resolver와 유사하게 client side의 resolver를 생성함
•
resolver내 필드를 삽입하고 isLiked를 추가함
•
주의할 것) resolver 내 필드는 반드시 접근하려는 데이터의 자료형으로 삽입함
// apollo.js
import { ApolloClient, InMemoryCache } from "@apollo/client";
const client = new ApolloClient({
uri: "http://localhost:4000",
resolvers: {
Movie: {
isLiked: ()=> false
}
},
cache: new InMemoryCache(),
});
export default client;
TypeScript
복사
나) gql에 isLiked 추가
•
'@clinet' 키워드로 client side의 resolver라는 것을 알려줌
// Home.js
const GET_MOVIES = gql`
{
getAllMovies {
id
title
medium_cover_image
isLiked @client
}
}
`;
TypeScript
복사
다) Button 추가 및 렌더링 부분 추가
•
gql에서 받은 데이터를 자식 컴포넌트에 props로 전달
•
자식 컴포넌트에서 props를 받아서 렌더링 부분 생성
// Home.js
<Movies>
{data.getAllMovies.map((movie) => (
<Movie
key={movie.id}
id={movie.id}
image={movie.medium_cover_image}
isLiked={movie.isLiked}
/>
))}
</Movies>
TypeScript
복사
// Movie.js
function Movie(props) {
return (
<Container>
<Link to={`/${props.id}`}>
<Poster img={props.image} />
</Link>
<button>{props.isLiked ? "Unlike" : "Like"}</button>
</Container>
);
}
TypeScript
복사
라) Mutation 추가
•
modify 사용법 변경... Tutorial 보고 바꿔보자
// apollo.js
Mutation: {
likeMovie: (_, { id }, { cache }) => {
console.log("cache", cache);
cache.modify({
id: `Movie:${id}`,
data: {
isLiked: true,
},
});
return null;
},
},
TypeScript
복사
// Movie.js
const LIKE_MOVIE = gql`
mutation likeMovie($id: Int!){
likeMovie(id: $id) @client
}
`;
TypeScript
복사
3. Server / Apollo Tutorial
가. Build a schema(Create the blueprint for your data graph)
나. Connect to data sources(Fetch data from multiple locations)
다. Write query resolvers(Learn how a GraphQL query fetches data)
라. Write mutation resolvers(Learn how a GraphQL mutation modifies data)
마. Connect your graph to Apollo Studio(Learn about essential developer tooling)
가. Build a schema
1) 환경설정
// index.js
require("dotenv").config();
const { ApolloServer } = require("apollo-server");
const typeDefs = require("./schema");
const server = new ApolloServer({ typeDefs });
server.listen().then(() => {
console.log(`
Server is running!
Listening on port 4000
Explore at https://studio.apollographql.com/dev
`);
});
TypeScript
복사
2) Type Definition
•
Object, Query, Mutation, method의 Response의 타입에 대해 정의
•
Object type: App에서 사용할 객체 자료형에 대해 타입을 지정함. 객체 자료형의 이름과 세부속성에 대한 자료형을 지정함
// schema.js
const { gql } = require("apollo-server");
const typeDefs = gql`
type Launch {
id: ID!
site: String
mission: Mission
rocket: Rocket
isBooked: Boolean!
}
type Rocket {
id: ID!
name: String
type: String
}
type User {
id: ID!
email: String!
trips: [Launch]!
token: String
}
type Mission {
name: String
missionPatch(size: PatchSize): String
}
enum PatchSize {
SMALL
LARGE
}
`;
module.exports = typeDefs;
TypeScript
복사
•
Query type: Query title과 return 값의 자료형에 대해 타입을 지정
•
Mutation type: Mutation title, 매개변수명과 타입, 반환형에 대한 타입을 지정
•
Response type: Mutation의 response값에 대한 type을 지정함
→ boolean 값과 이와 연동된 메세지 그리고 result data를 세부 속성으로 포함함
// schema.js
const { gql } = require("apollo-server");
const typeDefs = gql`
// 생략
type Query {
launches: [Launch]!
launch(id: ID!): Launch
me: User
}
type Mutation {
bookTrips(launchIds: [ID]!): TripUpdateResponse!
cancelTrip(launchId: ID!): TripUpdateResponse!
login(email: String): User
}
type TripUpdateResponse {
success: Boolean!
message: String
launches: [Launch]
}
`;
module.exports = typeDefs;
TypeScript
복사
나. Connect to data sources
1) Connect a REST API
•
apollo-datasource-rest package를 활용하여 외부 REST API로부터 data를 fetch할 수 있음
•
예제에서 spaceX API("https://api.spacexdata.com/v2/") 활용
•
Reducer 목적: 외부 REST API로부터 fetch한 data 중 필요한 데이터만 사용하기 위해
// src/datasources/launch.js
const { RESTDataSource } = require("apollo-datasource-rest");
class LaunchAPI extends RESTDataSource {
constructor() {
super();
this.baseURL = "https://api.spacexdata.com/v2/";
}
launchReducer(launch) {
return {
id: launch.flight_number || 0,
cursor: `${launch.launch_date_unix}`,
site: launch.launch_site && launch.launch_site.site_name,
mission: {
name: launch.mission_name,
missionPatchSmall: launch.links.mission_patch_small,
missionPatchLarge: launch.links.mission_patch,
},
rocket: {
id: launch.rocket.rocket_id,
name: launch.rocket.rocket_name,
type: launch.rocket.rocket_type,
},
};
}
async getAllLaunches() {
const response = await this.get("launches");
return Array.isArray(response)
? response.map((launch) => this.launchReducer(launch))
: [];
}
async getLaunchById({ launchId }) {
const response = await this.get("launches", { flight_number: launchId });
return this.launchReducer(response[0]);
}
getLaunchesByIds({ launchIds }) {
return Promise.all(
launchIds.map((launchId) => this.getLaunchById({ launchId }))
);
}
}
module.exports = LaunchAPI;
TypeScript
복사
2) Connect a database
•
apollo-datasource package 활용하여 SQLite database 사용할 수 있도록 customized 함
// src/datasources/user.js
const { DataSource } = require('apollo-datasource');
const isEmail = require('isemail');
class UserAPI extends DataSource {
constructor({ store }) {
super();
this.store = store;
}
/**
* This is a function that gets called by ApolloServer when being setup.
* This function gets called with the datasource config including things
* like caches and context. We'll assign this.context to the request context
* here, so we can know about the user making requests
*/
initialize(config) {
this.context = config.context;
}
/**
* User can be called with an argument that includes email, but it doesn't
* have to be. If the user is already on the context, it will use that user
* instead
*/
async findOrCreateUser({ email: emailArg } = {}) {
const email =
this.context && this.context.user ? this.context.user.email : emailArg;
if (!email || !isEmail.validate(email)) return null;
const users = await this.store.users.findOrCreate({ where: { email } });
return users && users[0] ? users[0] : null;
}
async bookTrips({ launchIds }) {
const userId = this.context.user.id;
if (!userId) return;
let results = [];
// for each launch id, try to book the trip and add it to the results array
// if successful
for (const launchId of launchIds) {
const res = await this.bookTrip({ launchId });
if (res) results.push(res);
}
return results;
}
async bookTrip({ launchId }) {
const userId = this.context.user.id;
const res = await this.store.trips.findOrCreate({
where: { userId, launchId },
});
return res && res.length ? res[0].get() : false;
}
async cancelTrip({ launchId }) {
const userId = this.context.user.id;
return !!this.store.trips.destroy({ where: { userId, launchId } });
}
async getLaunchIdsByUser() {
const userId = this.context.user.id;
const found = await this.store.trips.findAll({
where: { userId },
});
return found && found.length
? found.map(l => l.dataValues.launchId).filter(l => !!l)
: [];
}
async isBookedOnLaunch({ launchId }) {
if (!this.context || !this.context.user) return false;
const userId = this.context.user.id;
const found = await this.store.trips.findAll({
where: { userId, launchId },
});
return found && found.length > 0;
}
}
module.exports = UserAPI;
TypeScript
복사
3) Add data sources to Apollo Server
가) Apollo Server에 외부 API 적용을 위한 환경설정
•
외부 API를 설정한 class를 가져옴
•
const LaunchAPI = require('./datasources/launch');
•
ApolloServer의 매개변수로 전달
// src/index.js
const { ApolloServer } = require('apollo-server');
const typeDefs = require('./schema');
const LaunchAPI = require('./datasources/launch');
const server = new ApolloServer({
typeDefs,
dataSources: () => ({
launchAPI: new LaunchAPI()
})
});
server.listen().then(() => {
console.log(`
Server is running!
Listening on port 4000
Explore at https://studio.apollographql.com/dev
`);
});
TypeScript
복사
나) Apollo Server에 Database 적용을 위한 환경설정
•
Sequalize 설정한 utils.js를 가져옴
•
database class를 가져와서 ApolloServer에 적용
•
this.context를 datasource에서 사용할 경우, new instance를 dataSources에 생성해야 함. 그렇지 않으면 비동기적 이슈 발생 가능
// src/index.js
const { ApolloServer } = require('apollo-server');
const typeDefs = require('./schema');
const { createStore } = require('./utils');
const UserAPI = require('./datasources/user');
const server = new ApolloServer({
typeDefs,
dataSources: () => ({
userAPI: new UserAPI({ store })
})
});
server.listen().then(() => {
console.log(`
Server is running!
Listening on port 4000
Explore at https://studio.apollographql.com/dev
`);
});
TypeScript
복사
다. Write query resolvers
1) The resolver function signature
•
fieldName: (parent, args, context, info) => data;
•
resolver는 위와 같은 형식으로 정의하는데, args와 context 인자에 부분을 주로 사용함
•
args는 매개변수로 전달하는 값
•
context는 모든 resolver가 공유하는 객체로, 특정 상태에 대해 작업할 때 사용함. 특히 index.js 내부의 apollo-server에 선언된 dadta sources에 접근할 때 사용함
2) Define top-level resolvers
•
schema.js에 선언된 query와 resolver에 정의된 query가 mapping됨. query resolver 작성 시, Query title과 return type에 대해 주의해야함
// src/resolvers.js
module.exports = {
Query: {
launches: (_, __, { dataSources }) =>
dataSources.launchAPI.getAllLaunches(),
launch: (_, { id }, { dataSources }) =>
dataSources.launchAPI.getLaunchById({ launchId: id }),
me: (_, __, { dataSources }) => dataSources.userAPI.findOrCreateUser()
}
};
TypeScript
복사
// src/schema.js
type Query {
launches: [Launch]!
launch(id: ID!): Launch
me: User
}
TypeScript
복사
3) Add resolvers to Apollo Server
•
노란색 text와 같이 ApolloSever에 추가함
const { ApolloServer } = require('apollo-server');
const typeDefs = require('./schema');
const { createStore } = require('./utils');
const resolvers = require('./resolvers');
const LaunchAPI = require('./datasources/launch');
const UserAPI = require('./datasources/user');
const store = createStore();
const server = new ApolloServer({
typeDefs,
resolvers,
dataSources: () => ({
launchAPI: new LaunchAPI(),
userAPI: new UserAPI({ store })
})
});
server.listen().then(({ url }) => {
console.log(`🚀 Server ready at ${url}`);
});
TypeScript
복사
4) Test Query
•
두 개 이상의 resolver를 함께 보낼 수 있음. 이때 최상위 Query title은 임의지정 가능. 다만 각각의 query title은 mapping되어 있으므로 type으로 선언한 title을 따라야 함
query GetLaunche_Launches {
launch(id: "4"){
mission{
name
}
}
launches {
id
mission{
name
}
rocket{
name
type
}
}
}
TypeScript
복사
•
쿼리의 변수명을 지정하며 보낼 수 있음. 이때 변수의 타입을 지정함
{
"id": 60
}
query GetLaunche_Launches($id: ID!) {
launch(id: $id){
mission{
name
}
}
launches {
id
mission{
name
}
rocket{
name
type
}
}
}
TypeScript
복사
5) Other resolvers
•
위에서 선언한 Query와 같이 data를 단순 fetch하는 resolver를 추가함
•
Mission의 경우, 매개변수로 전달받는 값에 따라 다른 return값이 설정됨
•
이번에 추가된 resolvers의 경우 첫번째 매개변수에 값이 삽입되어 있음. 이는 해당 필드의 parent resolver가 반환한 값임. 참고로 parent resolver는 child resolver 보다 항상 먼저 실행됨
•
Mission, Launch, User의 경우 아직 선언되지 않은 resolver의 영향을 받으므로 실행이 불가함. 뒤의 chapter에서 관련 내용이 추가될 예정
// src/resolvers.js
module.exports = {
Query: {
// 생략
},
Mission: {
// The default size is 'LARGE' if not provided
missionPatch: (mission, { size } = { size: "LARGE" }) => {
return size === "SMALL"
? mission.missionPatchSmall
: mission.missionPatchLarge;
},
},
Launch: {
isBooked: async (launch, _, { dataSources }) =>
dataSources.userAPI.isBookedOnLaunch({ launchId: launch.id }),
},
User: {
trips: async (_, __, { dataSources }) => {
// get ids of launches by user
const launchIds = await dataSources.userAPI.getLaunchIdsByUser();
if (!launchIds.length) return [];
// look up those launches by their ids
return (
dataSources.launchAPI.getLaunchesByIds({
launchIds,
}) || []
);
},
},
};
TypeScript
복사
6) Paginate results
•
Query.launches의 결과로 너무 많은 데이터를 fetch함. 이는 성능이 떨어지는 결과로 이어지므로 pagination으로 해결
•
cursor-based pagination 적용 시, 서버는 데이터를 작은 chunks 단위로 보내게 되므로 다음의 두 가지 이점이 있음
→ 특정 아이템이 생략되는 것과 같은 아이템이 반복되어 보여지는 것을 방지할 수 있음 ?? 관련 내용 조금 더 찾아보자
// src/schema.js
type Query {
launches(
pageSize: Int
after: String
): LaunchConnection!
launch(id: ID!): Launch
me: User
}
type LaunchConnection {
cursor: String!
hasMore: Boolean!
launches: [Launch]!
}
TypeScript
복사
◦
resolver에 다음과 같이 정의함
◦
const { paginateResults } = require('./utils');
module.exports = {
Query: {
launches: async (_, { pageSize = 20, after }, { dataSources }) => {
const allLaunches = await dataSources.launchAPI.getAllLaunches();
allLaunches.reverse();
const launches = paginateResults({
after,
pageSize,
results: allLaunches
});
return {
launches,
cursor: launches.length ? launches[launches.length - 1].cursor : null,
// if the cursor at the end of the paginated results is the same as the
// last item in _all_ results, then there are no more results after this
hasMore: launches.length
? launches[launches.length - 1].cursor !==
allLaunches[allLaunches.length - 1].cursor
: false
};
},
launch: (_, { id }, { dataSources }) =>
dataSources.launchAPI.getLaunchById({ launchId: id }),
me: async (_, __, { dataSources }) =>
dataSources.userAPI.findOrCreateUser(),
}
};
TypeScript
복사
◦
다음과 같이 쿼리를 전송하면 기존 값인 lauches와 더불어 cursor, hsMore 값이 반환됨
query GetLaunches {
launches(pageSize: 3) {
launches {
id
mission {
name
}
}
cursor
hasMore
}
}
TypeScript
복사
라. Write mutation resolvers
1) Login
// src/resolvers.js
// Query: {
// ...
// },
Mutation: {
login: async (_, { email }, { dataSources }) => {
const user = await dataSources.userAPI.findOrCreateUser({ email });
if (user) {
user.token = new Buffer(email).toString('base64');
return user;
}
},
},
TypeScript
복사
2) authentication
•
Client에서 Server로 gql 요청 시, 매번 context 실행하여 인증 과정을 거침
•
서버에서 처리하는 인증과정은 아래와 같음
→ 요청 data에 포함된 header에서 인증 관련 데이터를 얻음
→ 해당 정보를 복호화함
→ 복호화된 정보가 DB 내 이메일 정보와 같다면 해당 유저의 세부 정보를 반환함
// src/index.js
const isEmail = require('isemail');
const server = new ApolloServer({
context: async ({ req }) => {
// simple auth check on every request
const auth = req.headers && req.headers.authorization || '';
const email = Buffer.from(auth, 'base64').toString('ascii');
if (!isEmail.validate(email)) return { user: null };
// find a user by their email
const users = await store.users.findOrCreate({ where: { email } });
const user = users && users[0] || null;
return { user: { ...user.dataValues } };
},
// Additional constructor options
});
TypeScript
복사
2) bookTrips and cancelTrip
•
query resolver와 같이 mutation resolver의 경우에도, 반환값이 schema에 선언된 mutation의 반환값, 매개변수 등이 mapping되어야 함.
// src/resolvers.js
//Mutation: {
// login: ...
bookTrips: async (_, { launchIds }, { dataSources }) => {
const results = await dataSources.userAPI.bookTrips({ launchIds });
const launches = await dataSources.launchAPI.getLaunchesByIds({
launchIds,
});
return {
success: results && results.length === launchIds.length,
message:
results.length === launchIds.length
? 'trips booked successfully'
: `the following launches couldn't be booked: ${launchIds.filter(
id => !results.includes(id),
)}`,
launches,
};
},
cancelTrip: async (_, { launchId }, { dataSources }) => {
const result = await dataSources.userAPI.cancelTrip({ launchId });
if (!result)
return {
success: false,
message: 'failed to cancel trip',
};
const launch = await dataSources.launchAPI.getLaunchById({ launchId });
return {
success: true,
message: 'trip cancelled',
launches: [launch],
};
},
// 이하 생략
Launch: {}
Mission: {}
User: {}
TypeScript
복사
3) Run test mutations
•
모든 이메일에 대해서 로그인 및 회원가입 처리되므로 임의의 이메일 주소로 로그인 mutation 사용 가능
•
반환된 토큰값으로 BookTrips 등 기타 mutation 사용 가능. 이때, header에 토큰값을 전달해야 함
mutation LoginUser {
login(email: "daisy@apollographql.com") {
token
}
}
// The server will respond like this:
"data": {
"login": {
"token": "ZGFpc3lAYXBvbGxvZ3JhcGhxbC5jb20="
}
}
TypeScript
복사
mutation BookTrips {
bookTrips(launchIds: [67, 68, 69]) {
success
message
launches {
id
}
}
}
// HTTP_HEADERS
{
"authorization": "ZGFpc3lAYXBvbGxvZ3JhcGhxbC5jb20="
}
TypeScript
복사
4. Client / Apollo Tutorial
가. Set up Apollo Client(Connect to your API from your frontend)
나. Fetch data with queries(Working with the useQuery React Hook)
다. Update data with mutations(Learn how to update data with the useMutation hook)
라. Manage local state(Store and query local data in the Apollo cache)
가. Set up Apollo Client
1) ApolloClient instance 생성
•
cache.ts로부터 client-side cache 설정
•
아래와 같이 client.query 부분을 임시로 붙여서 서버와 통신 연동 유무 테스트(browser console 창에서 확인)
// src/index.tsx
import {
ApolloClient,
gql,
NormalizedCacheObject
} from '@apollo/client';
import { cache } from './cache';
const client: ApolloClient<NormalizedCacheObject> = new ApolloClient({
cache,
uri: 'http://localhost:4000/graphql'
});
client
.query({
query: gql`
query TestQuery {
launch(id: 56) {
id
mission {
name
}
}
}
`
})
.then(result => console.log(result));
TypeScript
복사
나. Fetch data with queries
1) Integrate with React
•
ApolloProvider 사용에 따라 client instance(cache, uri) 정보가 react app 모든 곳에서 사용할 수 있게 됨
import {
ApolloClient,
NormalizedCacheObject,
ApolloProvider
} from '@apollo/client';
import { cache } from './cache';
import React from 'react';
import ReactDOM from 'react-dom';
import Pages from './pages';
import injectStyles from './styles';
// Initialize ApolloClient
const client: ApolloClient<NormalizedCacheObject> = new ApolloClient({
cache,
uri: 'http://localhost:4000/graphql',
});
injectStyles();
// Pass the ApolloClient instance to the ApolloProvider component
ReactDOM.render(
<ApolloProvider client={client}>
<Pages />
</ApolloProvider>,
document.getElementById('root')
);
TypeScript
복사
2) Display a list of launches
가) launches.tsx 기본 설정
•
gql 및 types 설정
•
LAUNCH_TILE_DATA: GraphQL fragment를 정의함. fragment는 다양한 원천의 여러 데이터 필드를 한 묶음으로 정의하여 앱 내 다른 컴포넌트에서 재사용할 수 있음(이점: 재사용성)
import React, { Fragment, useState } from 'react';
import { RouteComponentProps } from '@reach/router';
import { gql } from '@apollo/client'
export const LAUNCH_TILE_DATA = gql`
fragment LaunchTile on Launch {
__typename
id
isBooked
rocket {
id
name
}
mission {
name
missionPatch
}
}
`;
interface LaunchesProps extends RouteComponentProps {}
const Launches: React.FC<LaunchesProps> = () => {
return <div />;
}
export default Launches;
TypeScript
복사
나) Define the query
•
${LAUNCH_TILE_DATA} 활용하여 위에서 정의한 fragment 자료형에 launches 필드의 값을 할당
•
cursor: launches의 리스트에서 현재 목록의 위치
•
hasMore: 현재 목록 뒤에 추가적인 데이터의 유무
export const GET_LAUNCHES = gql`
query GetLaunchList($after: String) {
launches(after: $after) {
cursor
hasMore
launches {
...LaunchTile
}
}
}
${LAUNCH_TILE_DATA}
`;
TypeScript
복사
다) Apply the useQuery hook
•
필요한 컴포넌트와 type을 가져온다
•
이때 GetLaunchListTypes는 서버의 schema의 definitons임
import { gql, useQuery } from '@apollo/client';
import { LaunchTile, Header, Button, Loading } from '../components';
TypeScript
복사
import * as GetLaunchListTypes from './__generated__/GetLaunchList';
TypeScript
복사
•
useQuery hook 사용에 따라 세 개의 반환 값 생성
◦
data: a list of lauches
◦
loading: 데이터 fech가 완료되었는 지 여부
◦
error: error 유무
const Launches: React.FC<LaunchesProps> = () => {
const {
data,
loading,
error
} = useQuery<
GetLaunchListTypes.GetLaunchList,
GetLaunchListTypes.GetLaunchListVariables
>(GET_LAUNCHES);
if (loading) return <Loading />;
if (error) return <p>ERROR</p>;
if (!data) return <p>Not found</p>;
return (
<Fragment>
<Header />
{data.launches &&
data.launches.launches &&
data.launches.launches.map((launch: any) => (
<LaunchTile key={launch.id} launch={launch} />
))}
</Fragment>
);
}
TypeScript
복사
라) Add pagination support
•
fetchMore 함수를 사용해서 paginated queries를 처리함
const Launches = () => {
const {
data,
loading,
error,
fetchMore
} = useQuery(GET_LAUNCHES);
const [isLoadingMore, setIsLoadingMore] = useState(false);
// ...
};
TypeScript
복사
•
fetchMore 함수를 버튼 컴포넌트에 연결하면 아래와 같음
•
버튼을 클릭해서 isLoadingMore가 false이면 버튼이 보임
•
버튼을 누르면 isLoadingMore가 true로 바뀌고 fetchMore 함수를 사용하여 다음 페이지에 해당하는 데이터를 가져옴. 새로운 데이터를 가져올 때, lauches.cursor(현재값)을 after로 전달하여 다음의 데이터를 가져올 수 있음
•
데이터를 다 가져오면 isLoadingMore가 false로 바뀜. 이로써 다시 버튼을 클릭하여 위의 로직을 재실행할 수 있음
{data.launches && data.launches.hasMore && (
isLoadingMore
? <Loading />
: <Button
onClick={async () => {
setIsLoadingMore(true);
await fetchMore({
variables: {
after: data.launches.cursor,
},
});
setIsLoadingMore(false);
}}
>
Load More
</Button>
)}
//</Fragment>
TypeScript
복사
마) Merge cached results
•
두 개 이상의 특정 자료를 cache에 병합하여 저장할 때, merge 함수를 활용함
→ Load More 버튼을 click할 때, 기존 리스트에 새로 불러온 리스트를 합칠 경우
→ 기존의 launches 리스트에 새로운 불러온 리스트를 병합해서 sinlge list를 반환함
import { InMemoryCache, Reference } from '@apollo/client';
export const cache: InMemoryCache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
launches: {
keyArgs: false,
merge(existing, incoming) {
let launches: Reference[] = [];
if (existing && existing.launches) {
launches = launches.concat(existing.launches);
}
if (incoming && incoming.launches) {
launches = launches.concat(incoming.launches);
}
return {
...incoming,
launches,
};
}
}
}
}
}
});
TypeScript
복사
3) Display a single launch's details
•
gql 요청에 대해 lauches.tsx에 선언한 LAUNCH_TILE_DATA 활용하여 반환값을 할당
•
FC 제네릭으로 props의 요소에 대한 type을 정의함
import React, { Fragment } from 'react';
import { gql, useQuery } from '@apollo/client';
import { LAUNCH_TILE_DATA } from './launches';
import { Loading, Header, LaunchDetail } from '../components';
import { ActionButton } from '../containers';
import { RouteComponentProps } from '@reach/router';
import * as LaunchDetailsTypes from './__generated__/LaunchDetails';
export const GET_LAUNCH_DETAILS = gql`
query LaunchDetails($launchId: ID!) {
launch(id: $launchId) {
site
rocket {
type
}
...LaunchTile
}
}
${LAUNCH_TILE_DATA}
`;
interface LaunchProps extends RouteComponentProps {
launchId?: any;
}
const Launch: React.FC<LaunchProps> = ({ launchId }) => {
const {
data,
loading,
error,
} = useQuery<
LaunchDetailsTypes.LaunchDetails,
LaunchDetailsTypes.LaunchDetailsVariables
>(GET_LAUNCH_DETAILS,
{ variables: { launchId } }
);
if (loading) return <Loading />;
if (error) return <p>ERROR: {error.message}</p>;
if (!data) return <p>Not found</p>;
return (
<Fragment>
<Header image={data.launch && data.launch.mission && data.launch.mission.missionPatch}>
{data && data.launch && data.launch.mission && data.launch.mission.name}
</Header>
<LaunchDetail {...data.launch} />
<ActionButton {...data.launch} />
</Fragment>
);
}
export default Launch;
TypeScript
복사
4) Display the profile page
•
launch 페이지와 차이는 fetch policy가 추가되었다는 것
import React, { Fragment } from 'react';
import { gql, useQuery } from '@apollo/client';
import { Loading, Header, LaunchTile } from '../components';
import { LAUNCH_TILE_DATA } from './launches';
import { RouteComponentProps } from '@reach/router';
import * as GetMyTripsTypes from './__generated__/GetMyTrips';
export const GET_MY_TRIPS = gql`
query GetMyTrips {
me {
id
email
trips {
...LaunchTile
}
}
}
${LAUNCH_TILE_DATA}
`;
interface ProfileProps extends RouteComponentProps {}
const Profile: React.FC<ProfileProps> = () => {
const {
data,
loading,
error
} = useQuery<GetMyTripsTypes.GetMyTrips>(
GET_MY_TRIPS,
{ fetchPolicy: "network-only" }
);
if (loading) return <Loading />;
if (error) return <p>ERROR: {error.message}</p>;
if (data === undefined) return <p>ERROR</p>;
return (
<Fragment>
<Header>My Trips</Header>
{data.me && data.me.trips.length ? (
data.me.trips.map((launch: any) => (
<LaunchTile key={launch.id} launch={launch} />
))
) : (
<p>You haven't booked any trips</p>
)}
</Fragment>
);
}
export default Profile;
TypeScript
복사
가) Customizing the fetch policy
•
default fetch policy: cache-first
→ Apollo Client는 gql request 전에 cache를 우선 확인(gql request의 내용이 cache에 있는 지). 관련 request 결과물이 cache에 있다면 서버로 request를 보내지 않음. 이러한 경우, cached data가 항상 최신의 데이터라고 보장할 수 없음
•
network-only
→ 해당 fetch policy가 적용된 gql request의 경우, Apollo Client는 cache에 관련 request 결과물의 유무에 관계없이 서버로 request를 보냄
다. Update data with mutations
1) Define the mutation
// src/pages/login.tsx
import React from 'react';
import { gql, useMutation } from '@apollo/client';
import { LoginForm, Loading } from '../components';
import * as LoginTypes from './__generated__/login';
export const LOGIN_USER = gql`
mutation Login($email: String!) {
login(email: $email) {
id
token
}
}
`;
TypeScript
복사
2) Apply the useMutation hook
가) useMutation
•
useMutation은 실행 결과값을 반환하지 않음(useQueryd와 차이점). mutate function을 반환함
•
login FC의 mutate function은 login이고 이를 LoginForm component에 할당하여 로그인 시 사용함
export default function Login() {
const [login, { loading, error }] = useMutation<
LoginTypes.login,
LoginTypes.loginVariables
>(LOGIN_USER);
if (loading) return <Loading />;
if (error) return <p>An error occurred</p>;
return <LoginForm login={login} />;
}
TypeScript
복사
나) Persist the user's token and ID
•
onCompleted 콜백 사용하여 login mutation fuction이 사용되자마자 login.token과 login.id를 localStorage에 저장 가능
•
추후 필요 시, id와 token을 in-memory cache에서 불러올 수 있음
const [login, { loading, error }] = useMutation<
LoginTypes.Login,
LoginTypes.LoginVariables
>(
LOGIN_USER,
{
onCompleted({ login }) {
localStorage.setItem('token', login.token as string);
localStorage.setItem('userId', login.id as string);
}
}
);
TypeScript
복사
3) Add Authorization headers to all requests
•
Server와 Client 간 인증 로직
가) 회원가입 및 로그인 시, 서버에서 token을 생성함
나) Client에서 login 시, 서버에서 생성한 token을 Client localStorage에 저장
다) Client에서 모든 request 보낼 때, header에 localStorage에 저장한 token을 서버에 함께 전달함
라) 서버에서는 token이 필요한 서비스의 경우 header를 참조하여 유효성 검사를 함(token이 필요없는 경우, header를 무시함)
// src/index.tsx
const client: ApolloClient<NormalizedCacheObject> = new ApolloClient({
cache,
uri: 'http://localhost:4000/graphql',
headers: {
authorization: localStorage.getItem('token') || '',
}
});
TypeScript
복사
라. Manage local state
1) Define a client-side schema
•
다른 곳에서 사용한 query type을 확장한다 ??
•
isLoggedIn, cartItems 두 개의 local field를 추가할 수 있음
// src/index.tsx
export const typeDefs = gql`
extend type Query {
isLoggedIn: Boolean!
cartItems: [ID!]!
}
`;
TypeScript
복사
•
위의 client-side shema(local field)를 추가하기 위해 constructor에 위에서 선언한 tyepDefs를 추가함
const client: ApolloClient<NormalizedCacheObject> = new ApolloClient({
cache,
uri: 'http://localhost:4000/graphql',
headers: {
authorization: localStorage.getItem('token') || '',
},
typeDefs,
});
TypeScript
복사
2) Initialize reactive variables
가) client-side schema fields(local fields)에 데이터를 채우는 방법
•
The same in-memory cache: useQuery나 useMutation과 마찬가지로 in-memory cache에 저장해서 사용하는 방법
•
Reactive variables: cache 외부에 저장하고 local fields에 의존하는 데이터를 업데이트하는 방법???
→ 빠르게 배울 수 있으므로 사용 권장
•
Reactive variables initial setup
→ makeVar를 활용하여 초기값을 포함하여 선언함
→ 아래 두개의 Reactive variabless는 모두 함수임
→ 매개변수를 전달하지 않은 채 해당 함수를 호출하면 현재값을 반환함
→ 매개변수를 전달하면 이를 반영하여 현재값을 대체함
// src/cache.ts
import { InMemoryCache, Reference, makeVar } from '@apollo/client';
// Initializes to true if localStorage includes a 'token' key,
// false otherwise
export const isLoggedInVar = makeVar<boolean>(!!localStorage.getItem('token'));
// Initializes to an empty array
export const cartItemsVar = makeVar<string[]>([]);
TypeScript
복사
나) Update login logic
•
cache.tsx로부터 위에서 선언한 Reactive variable를 가져와서 LOGIN_USER의 onCompleted callback에 추가함
•
true를 매개변수로 해당 함수를 실행하면, 값이 replace 됨
import { isLoggedInVar } from '../cache';
export default function Login() {
const [login, { loading, error }] = useMutation<
LoginTypes.login,
LoginTypes.loginVariables
>(
LOGIN_USER,
{
onCompleted({ login }) {
localStorage.setItem('token', login.token as string);
localStorage.setItem('userId', login.id as string);
isLoggedInVar(true);
}
}
);
TypeScript
복사
3) Define field policies
•
local fields의 경우, Reactive variable는 캐시에 저장되지도 않기 때문에 Apollo Client가 어떻게 local fields를 관리할지 지정해야 함
•
isLoggedIn, cartItems, Reactive variable 두 개와 관련된 fields policies는 아래 같음
•
중요!) 아래와 같이 Query fields 아래 Reactive variable를 선언함으로써 다른 server-side의 fields와 마찬가지로 어디에서든 local fileds를 호출함으로써 Reactive variable 사용할 수 있음
•
어디서든지 호출할 수 있는 이유는 read() 함수 때문임
•
구체적인 예제는 다음 챕터를 확인할 것
export const cache: InMemoryCache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
isLoggedIn: {
read() {
return isLoggedInVar();
}
},
cartItems: {
read() {
return cartItemsVar();
}
},
launches: {
// ...field policy definitions...
}
}
}
}
});
TypeScript
복사
4) Query local fields
주의!) local-fields에 대한 쿼리를 작성할 때, @client 키워드를 붙여서 이것이 server로부터 쿼리되는 것이 아님을 표시해야 함
가) Login status
// src/index.tsx
import {
ApolloClient,
NormalizedCacheObject,
ApolloProvider,
gql,
useQuery
} from '@apollo/client';
import Login from './pages/login';
const IS_LOGGED_IN = gql`
query IsUserLoggedIn {
isLoggedIn @client
}
`;
function IsLoggedIn() {
const { data } = useQuery(IS_LOGGED_IN);
return data.isLoggedIn ? <Pages /> : <Login />;
}
ReactDOM.render(
<ApolloProvider client={client}>
<IsLoggedIn />
</ApolloProvider>,
document.getElementById('root')
);
TypeScript
복사
나) Cart items
•
local fields와 remote fields에 대해 gql 작성 시 유일한 차이점은 @client 붙임유무
import React, { Fragment } from 'react';
import { gql, useQuery } from '@apollo/client';
import { Header, Loading } from '../components';
import { CartItem, BookTrips } from '../containers';
import { RouteComponentProps } from '@reach/router';
import { GetCartItems } from './__generated__/GetCartItems';
export const GET_CART_ITEMS = gql`
query GetCartItems {
cartItems @client
}
`;
interface CartProps extends RouteComponentProps {}
const Cart: React.FC<CartProps> = () => {
const { data, loading, error } = useQuery<GetCartItems>(
GET_CART_ITEMS
);
if (loading) return <Loading />;
if (error) return <p>ERROR: {error.message}</p>;
return (
<Fragment>
<Header>My Cart</Header>
{data?.cartItems.length === 0 ? (
<p data-testid="empty-message">No items in your cart</p>
) : (
<Fragment>
{data?.cartItems.map((launchId: any) => (
<CartItem key={launchId} launchId={launchId} />
))}
<BookTrips cartItems={data?.cartItems || []} />
</Fragment>
)}
</Fragment>
);
}
export default Cart;
TypeScript
복사
5) Modify local fields
가) Enable logout
•
로그아웃의 로직은 아래와 같음
1.
ApolloClient의 cache 내 저장되어 있는 'me' fields(User filed)를 제거함
2.
localStorage 내 token과 userId를 제거함
3.
isLoggedInVar(reactive variable)을 false로 변경
import React from 'react';
import styled from 'react-emotion';
import { useApolloClient } from '@apollo/client';
import { menuItemClassName } from '../components/menu-item';
import { isLoggedInVar } from '../cache';
import { ReactComponent as ExitIcon } from '../assets/icons/exit.svg';
const LogoutButton = () => {
const client = useApolloClient();
return (
<StyledButton
data-testid="logout-button"
onClick={() => {
// Evict and garbage-collect the cached user object
client.cache.evict({ fieldName: 'me' });
client.cache.gc();
// Remove user details from localStorage
localStorage.removeItem('token');
localStorage.removeItem('userId');
// Set the logged-in status to false
isLoggedInVar(false);
}}
>
<ExitIcon />
Logout
</StyledButton>
);
}
export default LogoutButton;
const StyledButton = styled('button')(menuItemClassName, {
background: 'none',
border: 'none',
padding: 0,
});
TypeScript
복사
나) Enable trip booking(중요! local fields와 remote fields의 콤보)
•
BookTrips, remote field 처리하는 mutation
→ props로 전달받은 cartItems(launchIds)를 인자로 받아서 server에 gql request 전송
→ 처리가 완료되면 cartItemsVar([]) 실행
→ !data.bookTrips.success 이게 뭐냐??
•
cartItemsVar([]), local field 처리하는 reactive variable
→ local field에 저장한 cartItem 정보에 대해 빈배열 처리
//src/containers/book-trips.tsx
import React from "react";
import { gql, useMutation } from "@apollo/client";
import Button from "../components/button";
import { cartItemsVar } from "../cache";
export const BOOK_TRIPS = gql`
mutation BookTrips($launchIds: [ID]!) {
bookTrips(launchIds: $launchIds) {
success
message
launches {
id
isBooked
}
}
}
`;
const BookTrips = ({ cartItems }) => {
const [bookTrips, { data }] = useMutation(BOOK_TRIPS, {
variables: { launchIds: cartItems }
});
return data && data.bookTrips && !data.bookTrips.success ? (
<p data-testid="message">{data.bookTrips.message}</p>
) : (
<Button
onClick={async () => {
await bookTrips();
cartItemsVar([]);
}}
data-testid="book-button"
>
Book All
</Button>
);
};
export default BookTrips;
TypeScript
복사
다) Enable cart and booking modifications
•
Canceling a trip. 처리 로직은 다음과 같음
1.
버튼(Cancel This Trip)을 누르면 mutate 함수 실행
→ remote field 처리, mutation으로 매개변수로 전달받은 id의 값을 기준으로 gql request 전송
2.
remote field 내 제거한 데이터를 local field에서도 삭제
→ gql request에 대한 response를 받으면 update 콜백함수 실행
→ cache.modify 함수를 실행하여, 삭제하려는 아이디를 찾고, 해당 아이디를 기준으로 삭제하려는 데이터를 찾아서 삭제함
3.
cache.modify 활용법이 헷갈리므로 아래 URI 참고
https://www.apollographql.com/docs/react/caching/cache-interaction/#cachemodify
•
Adding and removing cart items
→ gql 작업은 없음. 매개변수가 있는지 체크(카트에 상품이 들어 있다면)
→ 해당 매개변수가 카트 내 다른 상품과 중복되는 지 확인
→ 중복되지 않는다면 기존 caritems에 추가하여 cartItemsVar(reactived variable)에 추가하여 local field로
// src/containers/action-button.jsx
import React from "react";
import { gql, useMutation, useReactiveVar } from "@apollo/client";
import { GET_LAUNCH_DETAILS } from "../pages/launch";
import Button from "../components/button";
import { cartItemsVar } from "../cache";
export { GET_LAUNCH_DETAILS };
export const CANCEL_TRIP = gql`
mutation cancel($launchId: ID!) {
cancelTrip(launchId: $launchId) {
success
message
launches {
id
isBooked
}
}
}
`;
const CancelTripButton = ({ id }) => {
const [mutate, { loading, error }] = useMutation(CANCEL_TRIP, {
variables: { launchId: id },
update(cache, { data: { cancelTrip } }) {
// Update the user's cached list of trips to remove the trip that
// was just canceled.
const launch = cancelTrip.launches[0];
cache.modify({
id: cache.identify({
__typename: "User",
id: localStorage.getItem("userId")
}),
fields: {
trips(existingTrips) {
const launchRef = cache.writeFragment({
data: launch,
fragment: gql`
fragment RemoveLaunch on Launch {
id
}
`
});
return existingTrips.filter(tripRef => tripRef === launchRef);
}
}
});
}
});
if (loading) return <p>Loading...</p>;
if (error) return <p>An error occurred</p>;
return (
<div>
<Button onClick={() => mutate()} data-testid={"action-button"}>
Cancel This Trip
</Button>
</div>
);
};
const ToggleTripButton = ({ id }) => {
const cartItems = useReactiveVar(cartItemsVar);
const isInCart = id ? cartItems.includes(id) : false;
return (
<div>
<Button
onClick={() => {
if (id) {
cartItemsVar(
isInCart
? cartItems.filter(itemId => itemId !== id)
: [...cartItems, id]
);
}
}}
data-testid={"action-button"}
>
{isInCart ? "Remove from Cart" : "Add to Cart"}
</Button>
</div>
);
};
const ActionButton = ({ isBooked, id }) =>
isBooked ? <CancelTripButton id={id} /> : <ToggleTripButton id={id} />;
export default ActionButton;
TypeScript
복사
5. graphQL-yoga Tutorial
가. 환경설정
1) cloning
나. 테스트
1) 코드 추가
다. deployment
라. Typing
마. deplyment with typed version
6. 에러 처리
가. No files matching 'path/to/app/src/.' were found.
•
file path 중 '[git]' 이 부분에 대해 에러 발생하여 src 내 파일을 읽지 못함
•
프로젝트 폴더를 해당 path를 생략한 곳으로 이동한 후 해결됨
나. button이 렌더링되지 않는 에러
•
Movie의 크기가 충분하지 않아서 가려졌던 것이 원인
•
Container의 height을 350→400px 조정 후 해결