preply/graphene-federation

Here is a working example with Mongoengine

fabianriewe opened this issue · 3 comments

Hello!

Thank you so much for creating this awesome tool. We are currently working with GraphQL + Mongoengine. We split up into Microservices, so Federation was the thing to do. After fiddling around for a day, I was able to create a working example with Mongoengine. I want to share it, just in case somebody needs it too.

1. Overview

The example is made up of two Services. Each Service has its own DB. We want to be able to connect these to DBs, but have access via 1 GraphQL Endpoint to be able to connect multiple Frontend Applications. I choose the following file-structure:

gateway
  |-gateway.js
graphene_federation
services
  |-models
    |-ReviewModel.py
    |-UserModel.py
  |-service1
    |-app.py
    |-config.py
    |-schema.py
    |-server.py
  |-service2
    |-app.py
    |-config.py
    |-schema.py
    |-server.py

2. Models

We have two DB-Models, a Userand a Review. Each of the models is sitting in its own Database. They are only connected via Mongoengine.

models/ReviewModel.py
from mongoengine import Document
from mongoengine.fields import StringField, ReferenceField

from .UserModel import UserModel


class ReviewModel(Document):
    name = StringField()
    user = ReferenceField(UserModel)
    meta = {'db_alias': 'review-db'}
models/UserModel.py
from mongoengine import Document
from mongoengine.fields import StringField, IntField


class UserModel(Document):
    username = StringField()
    age = IntField()
    meta = {'db_alias': 'user-db'}

3. Services

I wasn't very creative on the service. I just called them service1 and service2. Service1 is handling the users and Service2 the reviews.
Both have the same structure:

serviceX
|
|-app.py
|-config.py
|-schema.py
|-server.py
server.py

The server.py file is the same in both services, except for the port to avoid conflicts. I am using a UploadView. We are using File uploads, so this a custom option.

from app import app
from graphene_file_upload.flask import FileUploadGraphQLView
from mongoengine import connect
from schema import schema

# we need to connect to both databases
connect('service1',
        host=app.config['MONGO_HOST'],
        alias='user-db',
        username=app.config['MONGO_USER'],
        password=app.config['MONGO_PWD'],
        authentication_source="admin")

connect('service2',
        host=app.config['MONGO_HOST'],
        alias='review-db',
        username=app.config['MONGO_USER'],
        password=app.config['MONGO_PWD'],
        authentication_source="admin")

app.add_url_rule('/graphql', view_func=FileUploadGraphQLView.as_view('graphql', schema=schema, graphiql=app.debug))

if __name__ == '__main__':
    app.run(port=5000)
app.py

The app.py is even simpler.

from flask import Flask

app = Flask(__name__)
app.debug = True

app.config.from_object('config.DevConfig')
config.py

Nothing special here. Fill in your own details.

class BaseConfig:
    TESTING = False
    DEBUG = False

    MONGO_USER = '******'
    MONGO_PWD = '*****'

    MONGO_HOST = '*******'


class DevConfig(BaseConfig):
    DEBUG = True
schema.py

The schema file is the big difference in the two services. Let's start by the User.

service1
import graphene
from graphene_mongo import MongoengineObjectType

from graphene_federation import build_schema, key
from models.UserModel import UserModel


# The primary key 
@key('id')
class User(MongoengineObjectType):

    # we resolve a db reference by the id
    def __resolve_reference(self, info, **kwargs):
        # common mongoengine query
        return UserModel.objects.get(id=self.id)

    # Use Model as Type (common graphene_mongoengine)
    class Meta:
        model = UserModel


# define a query
class Query(graphene.ObjectType):
    users = graphene.Field(User)

    def resolve_users(self, info, **kwargs):
        return UserModel.objects.all()


# define a mutation
class CreateUser(graphene.Mutation):
    user = graphene.Field(User)

    class Arguments:
        username = graphene.String()
        age = graphene.Int()

    def mutate(self, info, username, age):
        user = UserModel(username=username, age=age)
        user.save()
        return CreateUser(user)


class Mutation(graphene.ObjectType):
    create_user = CreateUser.Field()


# build schema USE THE FEDERATION PACKAGE
schema = build_schema(Query, types=[User], mutation=Mutation)

As you can see, nothing special happens here. We just need to set the id as our key and add a resolver for the key.
Service2 will now be able to resolve a type from another schema by using the external function.

service2
import graphene
from graphene_mongo import MongoengineObjectType

from graphene_federation import build_schema, key, external, extend
from models.ReviewModel import ReviewModel


# use extend and the key to tell service2 that this a type from another service
@extend('id')
class User(graphene.ObjectType):
    # define key, use graphene.ID because this is the type used by graphene_mongo
    # set it to external
    id = external(graphene.ID())


# set id as key
@key('id')
class Review(MongoengineObjectType):
    # Add user as type
    user = graphene.Field(User)

    # optional: we dont need to resolve the reference
    def __resolve_reference(self, info, **kwargs):
        return ReviewModel.objects.get(id=self.id)

    # Use Model as Type (common graphene_mongoengine)
    class Meta:
        model = ReviewModel


# define a query
class Query(graphene.ObjectType):
    reviews = graphene.Field(Review)

    def resolve_reviews(self, info, **kwargs):
        return ReviewModel.objects.all().first()


# define a mutation
class CreateReview(graphene.Mutation):
    review = graphene.Field(Review)

    class Arguments:
        name = graphene.String()
        user_id = graphene.String()

    def mutate(self, info, name, user_id):
        review = ReviewModel(name=name, user=user_id)
        review.save()
        return CreateReview(review)


class Mutation(graphene.ObjectType):
    create_review = CreateReview.Field()


# build schema USE THE FEDERATION PACKAGE
schema = build_schema(Query, mutation=Mutation)

4. Gateway

To combine our 2 Services into one gateway, we use the Apollo Gateway.
We just need to create the following file:

gateway.js
const { ApolloServer } = require('apollo-server');
const { ApolloGateway } = require("@apollo/gateway");

const gateway = new ApolloGateway({
  serviceList: [
    { name: 'service1', url: 'http://127.0.0.1:5000/graphql' },
    { name: 'service2', url: 'http://127.0.0.1:5001/graphql' },
    // more services
  ],
});

const server = new ApolloServer({
  gateway,

  // Currently, subscriptions are enabled by default with Apollo Server, however,
  // subscriptions are not compatible with the gateway.  We hope to resolve this
  // limitation in future versions of Apollo Server.  Please reach out to us on
  // https://spectrum.chat/apollo/apollo-server if this is critical to your adoption!
  subscriptions: false,
});

server.listen().then(({ url }) => {
  console.log(`🚀 Server ready at ${url}`);
});

Next we need to install the depencies:

npm install @apollo/gateway apollo-server graphql

5. Start it up

The last thing is to just run the two flask applications and start the gateway via
node gateway.js

I hope all the instructions are clear enough. Have a great day!!!

I'm glad that you've found this lib useful for your project.
Thanks a lot for the great example!

P.S. I've added a link in the readme on this example

Hello, example is good but not complete, wondering if you can help wireup following:

  • users (id, email)
  • posts (id, userId, title)
  • user one to many posts

users.py

from graphene import ObjectType, String, ID, List, Int, Field
from flask import Flask
from flask_graphql import GraphQLView
import json
import urllib.request
from graphene_federation import build_schema, key, extend, external


def get_users():
    with urllib.request.urlopen("https://jsonplaceholder.typicode.com/users") as req:
        return json.loads(req.read().decode())


def get_user(id):
    with urllib.request.urlopen("https://jsonplaceholder.typicode.com/users/" + id) as req:
        return json.loads(req.read().decode())


@extend('id')
class Post(ObjectType):
    id = external(ID())
    # userId = ID()
    # user = Field(lambda: User)

    # def resolve_user(self, info):
    #     return get_user(self.userId)


@key('id')
class User(ObjectType):
    name = String()
    username = String()
    email = String()

    id = ID()
    # posts = List(lambda: Post)

    def __resolve_reference(self, info, **kwargs):
        return get_user(self.id)


class Query(ObjectType):
    users = List(lambda: User)
    user = Field(lambda: User, id=ID(required=True))

    def resolve_users(self, info):
        return get_users()

    def resolve_user(self, info, id):
        return get_user(id)


app = Flask(__name__)
app.debug = True
schema = build_schema(query=Query)
app.add_url_rule(
    '/graphql', view_func=GraphQLView.as_view('graphql', schema=schema, graphiql=True))

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5002)

posts.py

from graphene import ObjectType, String, ID, List, Int, Field
from flask import Flask
from flask_graphql import GraphQLView
import json
import urllib.request
from graphene_federation import build_schema, key, extend, external


def get_posts():
    with urllib.request.urlopen("https://jsonplaceholder.typicode.com/posts") as req:
        return json.loads(req.read().decode())


def get_post(id):
    with urllib.request.urlopen("https://jsonplaceholder.typicode.com/posts/" + id) as url:
        return json.loads(url.read().decode())


def get_posts_by(userId):
    with urllib.request.urlopen("https://jsonplaceholder.typicode.com/posts?userId=" + str(userId)) as req:
        return json.loads(req.read().decode())


@extend('id')
class User(ObjectType):
    id = external(ID())
    # userId = ID()
    # posts = List(lambda: Post)

    # def resolve_posts(self, info):
    #     return get_posts_by(self.id)


@key('id')
class Post(ObjectType):
    id = ID()
    userId = ID()
    title = String()
    body = String()
    # user = Field(lambda: User)

    def __resolve_reference(self, info, **kwargs):
        return get_post(self.id)


class Query(ObjectType):
    posts = List(lambda: Post)
    post = Field(lambda: Post, id=ID(required=True))

    def resolve_posts(self, info):
        return get_posts()

    def resolve_post(self, info, id):
        return get_post(id)


app = Flask(__name__)
app.debug = True
schema = build_schema(query=Query)
app.add_url_rule(
    '/graphql', view_func=GraphQLView.as_view('graphql', schema=schema, graphiql=True))

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5001)

Desired schema in gateway:

type User {
  id: ID
  username: String
  posts: [Post]
}

type Post {
  id: ID
  title: String
  user: User
}

@mac2000
There are some new integration test examples here that helped me conceptually wireup the final pieces
https://github.com/preply/graphene-federation/tree/master/integration_tests