Skip to content

Commit d1a92a0

Browse files
lchudinovdhmlau
authored andcommitted
feat(graphql): migrate to apollo server 4
Signed-off-by: Leonty Chudinov <leonty.chudinov@gmail.com>
1 parent b5545ff commit d1a92a0

File tree

14 files changed

+4015
-2986
lines changed

14 files changed

+4015
-2986
lines changed

examples/graphql/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
"@types/multer": "^1.4.12",
6565
"@types/node": "^16.18.126",
6666
"eslint": "^8.57.1",
67+
"graphql-ws": "^5.14.3",
6768
"rimraf": "^5.0.10",
6869
"source-map-support": "^0.5.21",
6970
"typescript": "~5.2.2"

examples/graphql/src/__tests__/acceptance/graphql-context.acceptance.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ describe('GraphQL context', () => {
3434
server.resolver(RecipeResolver);
3535

3636
// Customize the GraphQL context with additional information for test verification
37-
server.bind(GraphQLBindings.GRAPHQL_CONTEXT_RESOLVER).to(ctx => {
37+
server.bind(GraphQLBindings.GRAPHQL_CONTEXT_RESOLVER).to(async ctx => {
3838
return {...ctx, meta: 'loopback'};
3939
});
4040

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
// Copyright LoopBack contributors 2024
2+
// Node module: @loopback/example-graphql
3+
// This file is licensed under the MIT License.
4+
// License text available at https://opensource.org/licenses/MIT
5+
6+
import {Application, createBindingFromClass} from '@loopback/core';
7+
import {GraphQLServer} from '@loopback/graphql';
8+
import {
9+
createRestAppClient,
10+
expect,
11+
givenHttpServerConfig,
12+
supertest,
13+
} from '@loopback/testlab';
14+
import {createClient, Client as WsClient} from 'graphql-ws';
15+
import WebSocket from 'ws';
16+
import {GraphqlDemoApplication} from '../../application';
17+
import {RecipesDataSource} from '../../datasources';
18+
import {RecipeResolver} from '../../graphql-resolvers/recipe-resolver';
19+
import {RecipeRepository} from '../../repositories';
20+
import {sampleRecipes} from '../../sample-recipes';
21+
import {RecipeService} from '../../services/recipe.service';
22+
import {exampleQuery} from './graphql-tests';
23+
24+
describe('GraphQL server subscription', () => {
25+
let server: GraphQLServer;
26+
let repo: RecipeRepository;
27+
let client: supertest.SuperTest<supertest.Test>;
28+
let wsClient: WsClient;
29+
let notificationIter: AsyncIterableIterator<unknown>;
30+
31+
before('setup server and add recipe', async function () {
32+
await setupServerAndSubscribe();
33+
await addRecipe(client);
34+
});
35+
36+
after('unsubscribe and stop server', async function () {
37+
await unsubscribe(wsClient, notificationIter);
38+
await stopServer();
39+
});
40+
41+
it('should receive notification', async () =>
42+
receiveNotification(notificationIter));
43+
44+
async function setupServerAndSubscribe() {
45+
server = new GraphQLServer({
46+
host: '127.0.0.1',
47+
port: 0,
48+
});
49+
server.resolver(RecipeResolver);
50+
51+
server.bind('recipes').to([...sampleRecipes]);
52+
const repoBinding = createBindingFromClass(RecipeRepository);
53+
server.add(repoBinding);
54+
server.add(createBindingFromClass(RecipesDataSource));
55+
server.add(createBindingFromClass(RecipeService));
56+
await server.start();
57+
repo = await server.get<RecipeRepository>(repoBinding.key);
58+
await repo.start();
59+
60+
client = supertest(server.httpServer?.url);
61+
wsClient = await createWsClient(server.httpServer!.url);
62+
notificationIter = subscribe(wsClient);
63+
}
64+
65+
async function stopServer() {
66+
if (!server) return;
67+
await server.stop();
68+
repo.stop();
69+
}
70+
});
71+
72+
describe('GraphQL application subscription', () => {
73+
let server: GraphQLServer;
74+
let app: Application;
75+
let client: supertest.SuperTest<supertest.Test>;
76+
let wsClient: WsClient;
77+
let notificationIter: AsyncIterableIterator<unknown>;
78+
79+
before('setup app and subscribe', async function () {
80+
await setupAppAndSubscribe();
81+
await addRecipe(client);
82+
});
83+
84+
after('unsubscribe and stop server', async function () {
85+
await unsubscribe(wsClient, notificationIter);
86+
await stopApp();
87+
});
88+
89+
it('should receive notification', async () =>
90+
receiveNotification(notificationIter));
91+
92+
async function setupAppAndSubscribe() {
93+
app = new Application();
94+
const serverBinding = app.server(GraphQLServer);
95+
app.configure(serverBinding.key).to({host: '127.0.0.1', port: 0});
96+
server = await app.getServer(GraphQLServer);
97+
server.resolver(RecipeResolver);
98+
99+
app.bind('recipes').to([...sampleRecipes]);
100+
const repoBinding = createBindingFromClass(RecipeRepository);
101+
app.add(repoBinding);
102+
app.add(createBindingFromClass(RecipesDataSource));
103+
app.add(createBindingFromClass(RecipeService));
104+
await app.start();
105+
106+
client = supertest(server.httpServer?.url);
107+
wsClient = await createWsClient(server.httpServer!.url);
108+
notificationIter = subscribe(wsClient);
109+
}
110+
111+
async function stopApp() {
112+
if (!app) return;
113+
await app.stop();
114+
}
115+
});
116+
117+
describe('GraphQL as middleware subscription', () => {
118+
let app: GraphqlDemoApplication;
119+
let client: supertest.SuperTest<supertest.Test>;
120+
let wsClient: WsClient;
121+
let notificationIter: AsyncIterableIterator<unknown>;
122+
123+
before('setup app and subscribe', async function () {
124+
await setupAppWithMiddlewareAndSubscribe();
125+
await addRecipe(client);
126+
});
127+
128+
after('unsubscribe and stop server', async function () {
129+
await unsubscribe(wsClient, notificationIter);
130+
await stopApp();
131+
});
132+
133+
it('should receive notification', async () =>
134+
receiveNotification(notificationIter));
135+
136+
async function setupAppWithMiddlewareAndSubscribe() {
137+
app = new GraphqlDemoApplication({
138+
rest: givenHttpServerConfig(),
139+
graphql: {asMiddlewareOnly: true},
140+
});
141+
await app.boot();
142+
await app.start();
143+
144+
client = createRestAppClient(app);
145+
wsClient = await createWsClient(
146+
`${app.restServer.rootUrl ?? app.restServer.url!}`,
147+
);
148+
notificationIter = subscribe(wsClient);
149+
}
150+
151+
async function stopApp() {
152+
await app?.stop();
153+
}
154+
});
155+
156+
async function addRecipe(client: supertest.SuperTest<supertest.Test>) {
157+
await client
158+
.post('/graphql')
159+
.set('content-type', 'application/json')
160+
.accept('application/json')
161+
.send({operationName: 'AddRecipe', variables: {}, query: exampleQuery})
162+
.expect(200);
163+
}
164+
165+
async function createWsClient(serverUrl: string): Promise<WsClient> {
166+
const url = serverUrl.replace(/^http/, 'ws');
167+
return new Promise((resolve, reject) => {
168+
const webSocketsClient = createClient({
169+
webSocketImpl: WebSocket,
170+
url,
171+
lazy: false,
172+
});
173+
webSocketsClient.on('connected', () => resolve(webSocketsClient));
174+
webSocketsClient.on('error', err => {
175+
process.stderr.write(String(err));
176+
reject(new Error(`failed to create WS client: ${JSON.stringify(err)}`));
177+
});
178+
});
179+
}
180+
181+
function subscribe(wsClient: WsClient) {
182+
return wsClient.iterate({
183+
operationName: 'AllNotifications',
184+
query: exampleQuery,
185+
});
186+
}
187+
188+
async function unsubscribe(
189+
wsClient: WsClient,
190+
notificationIter: AsyncIterableIterator<unknown>,
191+
): Promise<void> {
192+
await notificationIter.return?.();
193+
await wsClient.dispose();
194+
}
195+
196+
async function receiveNotification(
197+
notificationIter: AsyncIterableIterator<unknown>,
198+
) {
199+
const {value: notification} = await notificationIter.next();
200+
expect(notification.data.recipeCreated).to.containEql({
201+
id: '4',
202+
numberInCollection: 4,
203+
});
204+
}

examples/graphql/src/application.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,17 @@ export class GraphqlDemoApplication extends BootMixin(
3131
this.expressMiddleware('middleware.express.GraphQL', server.expressApp);
3232

3333
// It's possible to register a graphql context resolver
34-
this.bind(GraphQLBindings.GRAPHQL_CONTEXT_RESOLVER).to(context => {
34+
this.bind(GraphQLBindings.GRAPHQL_CONTEXT_RESOLVER).to(async context => {
3535
return {...context};
3636
});
3737

38+
// It's possible to register a graphql Web Socket context resolver
39+
this.bind(GraphQLBindings.GRAPHQL_WS_CONTEXT_RESOLVER).to(
40+
async (context, msg, args) => {
41+
return {...context};
42+
},
43+
);
44+
3845
this.bind('recipes').to([...sampleRecipes]);
3946

4047
// Set up default home page

extensions/graphql/package.json

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,17 +40,27 @@
4040
],
4141
"peerDependencies": {
4242
"@loopback/boot": "^7.0.0",
43-
"@loopback/core": "^6.0.0"
43+
"@loopback/core": "^6.0.0",
44+
"@loopback/rest": "^14.0.0"
4445
},
4546
"dependencies": {
47+
"@apollo/server": "^4.9.3",
4648
"@graphql-tools/utils": "^10.8.6",
4749
"@loopback/http-server": "^6.0.14",
48-
"apollo-server-express": "^3.13.0",
50+
"body-parser": "^1.20.2",
51+
"cors": "^2.8.5",
4952
"debug": "^4.4.1",
5053
"express": "^4.21.2",
51-
"graphql": "^15.10.1",
54+
"graphql": "^16.8.0",
55+
"@graphql-tools/utils": "^10.8.6",
56+
"@loopback/http-server": "^6.0.14",
57+
"debug": "^4.4.1",
58+
"express": "^4.21.2",
59+
"graphql": "^16.8.0",
5260
"graphql-subscriptions": "^2.0.0",
53-
"type-graphql": "^1.1.1"
61+
"graphql-ws": "^5.14.3",
62+
"type-graphql": "^2.0.0-beta.2",
63+
"ws": "^8.13.0"
5464
},
5565
"devDependencies": {
5666
"@loopback/boot": "^7.0.14",
@@ -60,8 +70,10 @@
6070
"@loopback/repository": "^7.0.14",
6171
"@loopback/rest": "^14.0.14",
6272
"@loopback/testlab": "^7.0.13",
73+
"@types/cors": "^2.8.13",
6374
"@types/debug": "^4.1.12",
6475
"@types/node": "^16.18.126",
76+
"@types/ws": "^8.5.5",
6577
"class-transformer": "^0.5.1"
6678
}
6779
}

extensions/graphql/src/__tests__/integration/export-graphql-spec.integration.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,7 @@ input RecipeInput {
3939
description: String
4040
ingredients: [String!]!
4141
title: String!
42-
}
43-
`;
42+
}`;
4443

4544
describe('exportGraphQLSchema', () => {
4645
describe('standalone GraphQLServer', () => {

extensions/graphql/src/__tests__/unit/graphql.server.unit.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
// This file is licensed under the MIT License.
44
// License text available at https://opensource.org/licenses/MIT
55

6+
import {ExpressContextFunctionArgument} from '@apollo/server/dist/esm/express4';
67
import {expect} from '@loopback/testlab';
78
import {
8-
ExpressContext,
99
field,
1010
GraphQLBindings,
1111
GraphQLMiddleware,
@@ -32,7 +32,7 @@ describe('GraphQL server', () => {
3232
});
3333

3434
it('registers middleware', async () => {
35-
const middleware: GraphQLMiddleware<ExpressContext> = (
35+
const middleware: GraphQLMiddleware<ExpressContextFunctionArgument> = (
3636
resolverData,
3737
next,
3838
) => {

extensions/graphql/src/booters/resolver.booter.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,22 @@
33
// This file is licensed under the MIT License.
44
// License text available at https://opensource.org/licenses/MIT
55

6+
import {
7+
ArtifactOptions,
8+
BaseArtifactBooter,
9+
BootBindings,
10+
booter,
11+
} from '@loopback/boot';
612
import {
713
Application,
814
config,
915
Constructor,
1016
CoreBindings,
1117
inject,
1218
} from '@loopback/core';
13-
import {
14-
ArtifactOptions,
15-
BaseArtifactBooter,
16-
BootBindings,
17-
booter,
18-
} from '@loopback/boot';
19+
import debugFactory from 'debug';
1920
import {getMetadataStorage} from 'type-graphql';
2021
import {ResolverClassMetadata} from 'type-graphql/dist/metadata/definitions';
21-
import debugFactory from 'debug';
2222
import {registerResolver} from '../graphql.server';
2323

2424
const debug = debugFactory('loopback:graphql:resolver-booter');
@@ -63,7 +63,7 @@ export class GraphQLResolverBooter extends BaseArtifactBooter {
6363
// eslint-disable-next-line @typescript-eslint/no-explicit-any
6464
(getMetadataStorage() as any).resolverClasses;
6565
this.resolvers = this.classes.filter(cls => {
66-
return resolverClasses.some(r => !r.isAbstract && r.target === cls);
66+
return resolverClasses.some(r => /*!r.isAbstract && */ r.target === cls);
6767
});
6868
for (const resolver of this.resolvers) {
6969
debug('Bind interceptor: %s', resolver.name);

extensions/graphql/src/graphql.container.ts

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// This file is licensed under the MIT License.
44
// License text available at https://opensource.org/licenses/MIT
55

6+
import {ExpressContextFunctionArgument} from '@apollo/server/dist/esm/express4';
67
import {
78
Binding,
89
BindingScope,
@@ -12,9 +13,8 @@ import {
1213
filterByKey,
1314
filterByServiceInterface,
1415
} from '@loopback/core';
15-
import {ExpressContext} from 'apollo-server-express';
16-
import {ContainerType, ResolverData} from 'type-graphql';
1716
import debugFactory from 'debug';
17+
import {ContainerType, ResolverData} from 'type-graphql';
1818
import {GraphQLBindings, GraphQLTags} from './keys';
1919

2020
const debug = debugFactory('loopback:graphql:container');
@@ -27,7 +27,7 @@ export class GraphQLResolutionContext extends Context {
2727
constructor(
2828
parent: Context,
2929
readonly resolverClass: Constructor<unknown>,
30-
readonly resolverData: ResolverData<unknown>,
30+
readonly resolverData: ResolverData<object>,
3131
) {
3232
super(parent);
3333
this.bind(GraphQLBindings.RESOLVER_DATA).to(resolverData);
@@ -41,14 +41,11 @@ export class GraphQLResolutionContext extends Context {
4141
*/
4242
export class LoopBackContainer implements ContainerType {
4343
constructor(readonly ctx: Context) {}
44-
get(
45-
resolverClass: Constructor<unknown>,
46-
resolverData: ResolverData<unknown>,
47-
) {
44+
get(resolverClass: Constructor<unknown>, resolverData: ResolverData<object>) {
4845
debug('Resolving a resolver %s', resolverClass.name, resolverData);
4946

5047
// Check if the resolverData has the LoopBack RequestContext
51-
const graphQLCtx = resolverData.context as ExpressContext;
48+
const graphQLCtx = resolverData.context as ExpressContextFunctionArgument;
5249
// eslint-disable-next-line @typescript-eslint/no-explicit-any
5350
const reqCtx = (graphQLCtx?.req as any)?.[MIDDLEWARE_CONTEXT];
5451
const parent = reqCtx ?? this.ctx;

0 commit comments

Comments
 (0)