Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Graphql example #93

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
"awilix": "^4.3.4",
"dotenv": "^10.0.0",
"express": "^4.17.1",
"express-graphql": "^0.12.0",
"graphql": "^15.6.1",
"joi": "^17.4.1",
"lodash.template": "^4.5.0",
"mongodb": "^4.0.0",
Expand Down
46 changes: 46 additions & 0 deletions src/_boot/graphql.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { asValue } from 'awilix';
import { graphqlHTTP } from 'express-graphql';
import { GraphQLObjectType, GraphQLSchema } from 'graphql';

import { makeSchema, AddToSchema } from '@/_lib/graphql/schema';
import { makeModule } from '@/context';

const graphql = makeModule('graphql', async ({ app: { onBooted }, container: { build, register, cradle }, config }) => {
const { getSchema, addToSchema } = makeSchema();

onBooted(async () => {
const { queries } = getSchema();

build(({ server }) => {
server.use(
'/graphql',
graphqlHTTP({
schema: new GraphQLSchema({
query: new GraphQLObjectType({
name: 'Query',
description: 'The root of all queries',
fields: () => ({
...queries,
}),
}),
}),
graphiql: config.environment !== 'production',
context: {
registry: cradle,
},
})
);
});
}, 'prepend');

register({
addToSchema: asValue(addToSchema),
});
});

type GraphQLRegistry = {
addToSchema: AddToSchema;
};

export { graphql };
export type { GraphQLRegistry };
3 changes: 2 additions & 1 deletion src/_boot/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { Logger } from 'pino';
import { pubSub } from '@/_boot/pubSub';
import { MessageBundle } from '@/messages';
import { swagger } from '@/_boot/swagger';
import { graphql } from '@/_boot/graphql';

const main = withContext(async ({ app, container, config, bootstrap, logger, messageBundle }) => {
container.register({
Expand All @@ -19,7 +20,7 @@ const main = withContext(async ({ app, container, config, bootstrap, logger, mes
config: asValue(config),
});

await bootstrap(database, server, swagger, pubSub, repl, ...appModules);
await bootstrap(database, server, graphql, swagger, pubSub, repl, ...appModules);
});

type MainRegistry = {
Expand Down
32 changes: 32 additions & 0 deletions src/_lib/graphql/Graphql.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Registry } from '@/container';
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should avoid importing app-specific code into _lib code.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see another way to do this at the moment.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could move the type GraphQLContext to inside the module file since it's app-specific.

import { GraphQLFieldConfig, GraphQLFieldResolver } from 'graphql';

type GraphQLContext = {
registry: Registry;
};

type Sort = Readonly<{
field: string;
direction: 'asc' | 'desc';
}>;

type Pagination = Readonly<{
page: number;
pageSize: number;
}>;

type Filter = Record<string, any>;

type Args = {
pagination: Pagination;
filter: Filter;
sort: Sort[];
}

type GraphQLResolver = GraphQLFieldResolver<any, GraphQLContext, Args>

type GraphQLQueryMap<TSource, TContext> = {
[key: string]: GraphQLFieldConfig<TSource, TContext, Args>;
}

export { GraphQLContext, GraphQLQueryMap, GraphQLResolver };
37 changes: 37 additions & 0 deletions src/_lib/graphql/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
type Schema<Q = any, M = any> = {
queries?: Q;
mutations?: M;
};

type AddToSchema = (schemaData: Schema) => void
type GetSchema = () => Schema;

type MakeSchemaStorage = {
getSchema: GetSchema;
addToSchema: AddToSchema;
}

const makeSchema = (): MakeSchemaStorage => {
let queries = {};
let mutations = {};

return {
getSchema: () => ({ queries, mutations }),
addToSchema: (schemaData: Schema): void => {
queries = { ...queries, ...schemaData.queries };
mutations = { ...mutations, ...schemaData.mutations };
},
};
};

type Dependencies = {
addToSchema: AddToSchema;
};

const withRegisterSchema =
(schemaData: Schema) =>
({ addToSchema }: Dependencies): void =>
addToSchema(schemaData);

export { makeSchema, withRegisterSchema };
export type { AddToSchema }
41 changes: 41 additions & 0 deletions src/_sharedKernel/interface/graphql/TypeDefs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Which kind of typing are we gonna have in this file? I think we can improve this name 🤔

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All generic types shared across the domains would be placed here

GraphQLEnumType,
GraphQLInputObjectType,
GraphQLInt,
GraphQLList,
GraphQLString,
} from 'graphql';

const PaginationType = new GraphQLInputObjectType({
name: 'Pagination',
fields: () => ({
page: {
type: GraphQLInt,
defaultValue: 1
},
pageSize: {
type: GraphQLInt,
defaultValue: 10
}
})
})

const SortType = GraphQLList(new GraphQLInputObjectType({
name: 'Sort',
fields: () => ({
field: {
type: GraphQLString,
},
direction: {
type: new GraphQLEnumType({
name: 'Direction',
values: {
asc: { value: 'asc' },
desc: { value: 'desc' },
}
})
}
})
}))

export { PaginationType, SortType }
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { randomBytes } from 'crypto';
import { makeTestControls, TestControls } from '@/__tests__/TestControls';

describe('ArticleResolver', () => {
let controls: TestControls;

beforeAll(async () => {
controls = await makeTestControls();
});

beforeEach(async () => {
const { clearDatabase } = controls;

await clearDatabase();
});

afterAll(async () => {
const { cleanUp } = controls;

await cleanUp();
});

describe('QUERY Articles', () => {
it('gets the first page of articles', async () => {
const { request, registry } = controls;
const { createArticle, publishArticle } = registry;

const title = randomBytes(20).toString('hex');
const content = 'New Article content';

const articleId = await createArticle({ title, content })
await publishArticle(articleId);

const getArticlesQuery = `
query Articles($filter: ArticleFilter, $sort: [Sort], $pagination: Pagination) {
articles(filter: $filter, sort: $sort, pagination: $pagination) {
id
title
comments {
id
body
createdAt
}
}
}
`;

const variables = {
pagination: {
page: 1,
pageSize: 10,
},
};

return request()
.post('/graphql')
.send({
operationName: 'Articles',
query: getArticlesQuery,
variables,
})
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think about adding some abstraction to make it easier to send GraphQL calls in a test?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it could be interesting. Do you have something in mind?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't have the implementation per se in mind, but we could have something that would be used like this:

  return query('Articles', queryArticlesQuery, variables)
    .expect(async (res) => {
      // ...
    })

And something similar to mutations as well. What do you think?

.expect(async (res) => {
console.log(res);
expect(res.status).toBe(200);
const { data } = res.body;
expect(data).toHaveProperty('articles');
expect(data.articles).toHaveLength(1);
});
});
});
});
8 changes: 8 additions & 0 deletions src/article/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { makeMongoFindArticles } from '@/article/query/impl/MongoFindArticles';
import { makeModule } from '@/context';
import { makeArticleCreatedEmailListener } from '@/article/interface/email/ArticleCreatedEmailListener';
import { articleMessages } from '@/article/messages';
import { withRegisterSchema } from '@/_lib/graphql/schema';
import { articleQueries } from './interface/graphql';

const articleModule = makeModule(
'article',
Expand All @@ -34,6 +36,12 @@ const articleModule = makeModule(
findArticles: asFunction(makeMongoFindArticles),
});

build(
withRegisterSchema({
queries: articleQueries,
})
);

build(makeArticleController);
build(makeArticleCreatedEmailListener);
}
Expand Down
12 changes: 12 additions & 0 deletions src/article/interface/graphql/ArticleResolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { GraphQLResolver } from '@/_lib/graphql/Graphql';

const articleResolver: GraphQLResolver = async (_, args, context) => {
const { findArticles } = context.registry;
const { filter, pagination, sort } = args;

const { data: articles } = await findArticles({ filter, pagination, sort });
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think we should add some way to pass to the query which attributes we want according to the GraphQL query?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I understood your question correctly I think this is up to the client to deal with/decide

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is (and it's what is already happening), but we should also avoid fetching from the database the attributes the client does not want, right?


return articles;
};

export { articleResolver };
69 changes: 69 additions & 0 deletions src/article/interface/graphql/ArticleTypeDef.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import {
GraphQLList,
GraphQLNonNull,
GraphQLObjectType,
GraphQLObjectTypeConfig,
GraphQLString,
} from 'graphql';

import { GraphQLContext } from '@/_lib/graphql/Graphql';
import { ArticleListItemDTO } from '@/article/query/FindArticles';

type ArticlesConfigType = GraphQLObjectTypeConfig<ArticleListItemDTO, GraphQLContext>;

const CommentType = new GraphQLObjectType({
name: 'Comment',
fields: () => ({
id: {
type: GraphQLString,
description: 'The comment Id',
resolve: comment => comment.id,
},
body: {
type: GraphQLString,
description: 'The comment body',
resolve: comment => comment.body,
},
createdAt: {
type: GraphQLString,
description: 'The date the comment was wrote',
resolve: comment => comment.createAt
},
})
})

const ArticleTypeConfig: ArticlesConfigType = {
name: 'Article',
description: 'A blog post article',
fields: () => ({
id: {
type: GraphQLNonNull(GraphQLString),
description: 'The page Id',
resolve: article => article.id,
},
title: {
type: GraphQLString,
description: 'The article title',
resolve: article => article.title,
},
content: {
type: GraphQLString,
description: 'The article content',
resolve: article => article.content,
},
publishedAt: {
type: GraphQLString,
description: 'The date the article was published',
resolve: article => article.publishedAt
},
comments: {
type: GraphQLList(CommentType),
description: 'The article comments',
resolve: article => article.comments,
},
}),
};

const ArticleType = GraphQLList(new GraphQLObjectType(ArticleTypeConfig));

export { ArticleType };
Loading