오늘은 하스켈로 GraphQL 서버를 가볍게 만들어보자. 이전에 Clojure나 Kotlin(Spring)으로 프로젝트할 때 GraphQL 서버를 만들어 본 적이 있고 각각 자주 사용하는 라이브러리가 있어 어떤 라이브러리를 쓸지 고민을 하지 않았다. 하지만 하스켈로 GraphQL 서버를 만들 수 있는 패키지는 몇 가지 있어 조금 고민을 했다. 우리 프로젝트는 클라이언트와 GraphQL로 통신하고 서버끼리는 gRPC로 통신하기 때문에 처음에 mu-haskell 패키지를 사용했다. 지금 배포된 mu-haskell도 gRPC와 GraphQL 서버를 만드는데 문제가 없지만 프로젝트 진행이 느린 것 같아 지금은 조금 더 많이 사용하고 있는 Morpheus GraphQL 패키지를 사용하고 있다. 특히 Morpheus GraphQL은 stackage resolver에 등록이 되어 있는데 mu-haskell은 등록되어 있지 않다. 물론 stack.yaml
에 수동으로 추가해주면 문제가 없다.
Morpheus GraphQL는 많은 사람이 프로젝트에 기여하고 있지는 않지만 그래도 관리자가 열심히 업데이트하고 있다. 조금 익숙해지면 프로젝트에 기여를 해보는 것도 좋을 것 같다.
Morpheus GraphQL은 GraphQL 스키마를 기반으로 하스켈 코드를 생성하거나 아니면 직접 생성될 하스켈 코드를 작성해서 GraphQL 서버를 만들 수 있다. 물론 GraphQL Introspection을 지원하기 때문에 코드로 생성한 서버도 스키마를 얻을 수 있다. 하지만 GraphQL 스키마를 기반으로 작성하는 것이 편리하기 때문에 GraphQL 스키마를 기반으로 서버를 만드는 방식으로 작업하고 있다.
먼저 stack
을 사용한다고 생각하고 package.yaml
에 morpheus-graphql
의존성을 추가한다. 글을 쓰는 시점 기준으로 morpheus-graphql 패키지의 최신 버전은 0.20.0
이지만 우리가 쓰고 있는 stack
최신 resolver인 lts-18.28
에는 morpheus-graphql
이 0.17.0
버전을 사용하고 있다. 우리 프로젝트는 프로젝트 생성 당시 최신 버전인 0.18.0
을 사용하고 있기 때문에 stack.yaml
에 extra-deps
에 0.18.0
버전을 다음과 같이 추가하고 개발하고 있다. 최신 버전에 몇 가지 하위 버전 지원하지 않는 업데이트가 있어 천천히 0.20.0
으로 올려야겠다.
extra-deps:
- morpheus-graphql-0.18.0
- morpheus-graphql-core-0.18.0
- morpheus-graphql-app-0.18.0
- morpheus-graphql-code-gen-0.18.0
GraphQL 쿼리가 하나 있는 간단한 스키마를 만들어 서버를 만들어 보자.
type Droid {
id: ID!
name: String!
primaryFunction: String
}
type Query {
droid(id: String!): Droid!
}
Droid
라는 간단한 타입과 droid
라는 쿼리 하나가 있는 단순한 스키마이다. 이제 하스켈 코드에서 이 스키마를 읽어 하스켈 타입으로 만들자.
importGQLDocument "schema.graphql"
Data.Morpheus.Document
모듈에 importGQLDocument
함수는 파일명을 받는데 파일을 읽어 하스켈 템플릿으로 코드를 생성해준다. 생성되는 타입은 Query
와 Droid
와 같은 타입이다. 그리고 생성된 코드에는 여러 가지 언어 확장을 사용하기 때문에 다음과 같이 언어 확장을 추가해준다.
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE TypeFamilies #-}
특히 생성된 타입의 필드명이 중복될 수 있기 때문에 DuplicateRecordFields
를 추가하는 것을 잊지 말자. 다음은 쿼리 (또는 필드)에 대한 GraphQL resolver를 만들어 줘야 한다. 지금은 Query 밖에 없지만 Mutation과 Subscription resolver도 지원하기 때문에 Morpheus GraphQL은 RootResolver
라고 하는 타입에 Query, Muation, Subscription resolver 함수를 지정해 준다. RootResolver
타입은 Query, Muation, Subscription에 대한 타입 변수를 받고 각 타입은 importGQLDocument
가 생성해준다. 이 예제에서는 Query
타입이 생성되고 Query
타입은 droid
라고 하는 resolver 함수를 필드로 갖는 데이터 타입이다.
rootResolver :: RootResolver IO () Query Undefined Undefined
rootResolver =
RootResolver
{ queryResolver = Query { droid = resolveDroid }
, mutationResolver = Undefined
, subscriptionResolver = Undefined
}
Muation, Subscription은 사용하지 않기 때문에 Morpheus가 제공하는 Undefined
타입과 데이터 생성자로 표시했다. 이제 resolveDroid
라는 resolver 함수를 구현하면 된다. droid
쿼리에 대응하는 resolver 함수의 시그니처는 다음과 같다.
resolveDroid :: Arg "id" Text -> ResolverQ e IO Droid
함수의 인자 타입은 droid
의 인자인 id
에 해당하는 타입이다. Arg
타입을 사용해서 인자 이름과 같은 타입 리터럴 문자열을 주면 해당 인자에 순서와 상관없이 매칭 해서 쓸 수 있다. 리턴 타입은 ResolverQ
인데 두 번째 타입 변수인 IO
를 베이스 모나드로 하는 모나드 트랜스포머다. ResolverQ
는 Resolver QUERY
의 타입 동의어로 Muation resolver의 경우에는 ResolverM
을 쓴다. 또는 풀어서 Resolver MUTATION e IO Droid
이라고 해도 된다. 첫 번째 타입 변수는 이벤트라고 하는데 아직 쓰는 법을 찾아보지 않았다. 이제 ResolverQ
에 맞춰 resovler를 구현해주면 된다. 그냥 정해진 값을 내려주는 형태로 구현했다.
resolveDroid :: Arg "id" Text -> ResolverQ e IO Droid
resolveDroid (Arg droidId) =
pure Droid
{ id = pure $ ID droidId
, name = pure "R2-D2"
, primaryFunction = pure Nothing
}
하스켈 템플릿으로 생성된 Droid
타입은 필드 타입 역시 Resolver
타입이다. 그래서 필드별로 따로 resolver를 만들 수 있다. 여기서는 pure
(또는 return
)를 써서 ResolverQ
로 만들어 줬다. primaryFunction
의 경우는 필수 값이 아니기 때문에(스키마에 !
가 없다) Maybe
타입으로 생성된다.
이제 RootResolver
을 GraphQL 요청과 응답으로 바꿔주는 함수를 사용하면 거의 완성된다. Data.Morpheus
모듈에 있는 interpreter
의 인자로 RootResolver
타입을 넘겨주면 된다. 여기서는 ByteString
형식의 GraphQL 요청을 받아 ByteString
형식의 응답을 주도록 했다.
gqlApi :: ByteString -> IO ByteString
gqlApi = interpreter rootResolver
gqlApi
함수에 문자열로 GraphQL 요청을 넣어 보면 결과가 잘 나오는 것을 볼 수 있다.
-- >>> gqlApi "{\"query\": \"{ droid (id:\\\"UjItRDIK\\\") { name } } \"}"
-- "{\"data\":{\"droid\":{\"name\":\"R2-D2\"}}}"
이제 웹 인터페이스를 붙여보자. /graphql
POST
요청에 응답하는 간단한 동작이기 때문에 간단한 scotty 패키지를 사용하자.
main :: IO ()
main = scotty 8080 $ do
post "/graphql" $ raw =<< (liftIO . gqlApi =<< body)
scotty 모나드 컨텍스트에서 body
함수로 값을 가져와 gqlApi
로 넘긴다. 그리고 raw
함수로 문자열 그대로 응답해주도록 연결했다. 여기까지 하면 POST /graphql
로 GraphQL 요청을 받을 수 있다. 편하게 확인하기 위해 Graphql Playground UI를 붙여보자. 다음과 같이 graphql-playground.html
파일을 만들고 scotty 라우터에 추가하자.
<!DOCTYPE html>
<html>
<head>
<meta charset=utf-8 />
<meta name="viewport" content="user-scalable=no, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, minimal-ui">
<title>GraphQL Playground</title>
<link rel="stylesheet" href="//cdn.jsdelivr.net/npm/graphql-playground-react/build/static/css/index.css" />
<link rel="shortcut icon" href="//cdn.jsdelivr.net/npm/graphql-playground-react/build/favicon.png" />
<script src="//cdn.jsdelivr.net/npm/graphql-playground-react/build/static/js/middleware.js"></script>
</head>
<body>
<div id="root">
<style>
body {
background-color: rgb(23, 42, 58);
font-family: Open Sans, sans-serif;
height: 90vh;
}
#root {
height: 100%;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.loading {
font-size: 32px;
font-weight: 200;
color: rgba(255, 255, 255, .6);
margin-left: 20px;
}
img {
width: 78px;
height: 78px;
}
.title {
font-weight: 400;
}
</style>
<img src='//cdn.jsdelivr.net/npm/graphql-playground-react/build/logo.png' alt=''>
<div class="loading"> Loading
<span class="title">GraphQL Playground</span>
</div>
</div>
<script>window.addEventListener('load', function (event) {
GraphQLPlayground.init(document.getElementById('root'), {
// options as 'endpoint' belong here
endpoint: "/graphql"
})
})</script>
</body>
</html>
startServer :: IO ()
startServer = scotty 8080 $ do
post "/graphql" $ raw =<< (liftIO . gqlApi =<< body)
get "/graphql-playground" $ do
setHeader "Content-Type" "text/html; charset=utf-8"
file "graphql-playground.html"
브라우저에서 localhost:8080/graphql-playground
을 띄워 확인해 볼 수 있다.
Morpheus GraphQL의 간단한 기능만 살펴봤다. 나중에 조금 더 알게 되면 자세한 내용을 남겨보겠다. 전체 코드는 아래!
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE TypeFamilies #-}
module Main where
import Control.Monad.Trans
import Data.ByteString.Lazy.Char8 (ByteString)
import Data.Morpheus
import Data.Morpheus.Document
import Data.Morpheus.Types
import Data.Text
import System.Environment (getArgs)
import Web.Scotty
importGQLDocument "schema.graphql"
resolveDroid :: Arg "id" Text -> ResolverQ e IO Droid
resolveDroid (Arg droidId) =
pure Droid
{ id = pure $ ID droidId
, name = pure "R2-D2"
, primaryFunction = pure Nothing
}
rootResolver :: RootResolver IO () Query Undefined Undefined
rootResolver =
RootResolver
{ queryResolver = Query { droid = resolveDroid }
, mutationResolver = Undefined
, subscriptionResolver = Undefined
}
gqlApi :: ByteString -> IO ByteString
gqlApi = interpreter rootResolver
main :: IO ()
main = scotty 8080 $ do
post "/graphql" $ raw =<< (liftIO . gqlApi =<< body)
get "/graphql-playground" $ do
setHeader "Content-Type" "text/html; charset=utf-8"
file "graphql-playground.html"
오늘도 끗!