-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(api): graphql subscriptions (#148)
- Loading branch information
1 parent
53ba0e7
commit 29a4893
Showing
20 changed files
with
591 additions
and
28 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
63 changes: 63 additions & 0 deletions
63
apps/api/src/app/controllers/graphql-subscriptions.controller.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,63 @@ | ||
import { createMock } from '@golevelup/ts-jest'; | ||
import { ServiceUnavailableException } from '@nestjs/common'; | ||
import { GraphQLSchemaHost } from '@nestjs/graphql'; | ||
import { Test, TestingModule } from '@nestjs/testing'; | ||
import { Response } from 'express'; | ||
|
||
import { KordisRequest } from '@kordis/api/shared'; | ||
|
||
import { GraphqlSubscriptionsController } from './graphql-subscriptions.controller'; | ||
|
||
describe('GraphqlSubscriptionsController', () => { | ||
let controller: GraphqlSubscriptionsController; | ||
let module: TestingModule; | ||
|
||
beforeEach(async () => { | ||
module = await Test.createTestingModule({ | ||
providers: [ | ||
{ | ||
provide: GraphQLSchemaHost, | ||
useValue: createMock<GraphQLSchemaHost>(), | ||
}, | ||
], | ||
controllers: [GraphqlSubscriptionsController], | ||
}).compile(); | ||
|
||
controller = module.get<GraphqlSubscriptionsController>( | ||
GraphqlSubscriptionsController, | ||
); | ||
}); | ||
|
||
it('should be defined', () => { | ||
expect(controller).toBeDefined(); | ||
}); | ||
|
||
it('should throw ServiceUnavailableException if handler not ready', () => { | ||
const requestEmuFn = () => | ||
controller.subscriptionHandler( | ||
createMock<KordisRequest>(), | ||
createMock<Response>(), | ||
); | ||
expect(requestEmuFn).toThrow(ServiceUnavailableException); | ||
expect(requestEmuFn).toThrow( | ||
'GraphQL Subscription handler not ready yet. Try again.', | ||
); | ||
}); | ||
|
||
it('should allow subscriptions if handler ready', async () => { | ||
controller.onModuleInit(); | ||
|
||
const fn = jest.fn(); | ||
fn.mockReturnValue(Promise.resolve(true)); | ||
|
||
(controller as any).handler = fn; | ||
|
||
await expect( | ||
controller.subscriptionHandler( | ||
createMock<KordisRequest>(), | ||
createMock<Response>(), | ||
), | ||
).resolves.toBeTruthy(); | ||
expect(fn).toHaveBeenCalled(); | ||
}); | ||
}); |
42 changes: 42 additions & 0 deletions
42
apps/api/src/app/controllers/graphql-subscriptions.controller.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,42 @@ | ||
import { | ||
Controller, | ||
OnModuleInit, | ||
Post, | ||
Req, | ||
Res, | ||
ServiceUnavailableException, | ||
} from '@nestjs/common'; | ||
import { GraphQLSchemaHost } from '@nestjs/graphql'; | ||
import type { Response } from 'express'; | ||
import { createHandler } from 'graphql-sse/lib/use/express'; | ||
|
||
import type { KordisRequest } from '@kordis/api/shared'; | ||
|
||
@Controller('graphql-stream') | ||
export class GraphqlSubscriptionsController implements OnModuleInit { | ||
private handler?: (req: KordisRequest, res: Response) => Promise<void>; | ||
|
||
constructor(private readonly graphQLSchemaHost: GraphQLSchemaHost) {} | ||
|
||
onModuleInit(): void { | ||
const schema = this.graphQLSchemaHost.schema; | ||
|
||
this.handler = createHandler({ | ||
schema, | ||
}); | ||
} | ||
|
||
@Post() | ||
subscriptionHandler( | ||
@Req() req: KordisRequest, | ||
@Res() res: Response, | ||
): Promise<void> { | ||
if (!this.handler) { | ||
throw new ServiceUnavailableException( | ||
'GraphQL Subscription handler not ready yet. Try again.', | ||
); | ||
} | ||
|
||
return this.handler(req, res); | ||
} | ||
} |
32 changes: 32 additions & 0 deletions
32
docs/architecture-decisions/adr006-graphql-subscriptions-sse.md
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
# ADR006: GraphQL Subscriptions over SSE | ||
|
||
## Status | ||
|
||
accepted | ||
|
||
## Context | ||
|
||
To distribute events to the client, we want to use GraphQL subscriptions, as we | ||
already use GraphQL as our primary protocol. GraphQL subscriptions can be served | ||
over WebSockets with different implementations (`graphql-ws`and the deprecated | ||
but still widely used `subscriptions-transport-ws` package) or with Server-sent | ||
events (SSE). The implementation impacts both, the API and the SPA, as the SPA | ||
needs to link implement the same protocol layer to receive events. | ||
|
||
## Decision | ||
|
||
We implement GraphQL subscriptions over SSE. WebSockets are primarily used for | ||
bidirectional messaging, whereas GraphQL subscriptions are unidirectional. Even | ||
though the implementation with WebSocket seems more popular, SSE is the _better | ||
fitting_ choice in terms of covering just what's needed. Furthermore, SSE works | ||
over HTTP/1 or 2, meaning we can keep our interceptors and guards hooked into | ||
the request pipeline. E.g. there will be no need to have 2 authentication entry | ||
points. SSE is also more lightweight, since it is stateless compared to | ||
WebSockets. | ||
|
||
## Consequences | ||
|
||
SSE have some limitations, where browsers might only allow up to 6 open | ||
connections per domain. Currently, we weight this limitation as not significant, | ||
but as complexity grows, we might need to switch to HTTP/2 to allow more than 6 | ||
connections. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
/* eslint-disable */ | ||
export default { | ||
displayName: 'api-shared', | ||
preset: '../../../jest.preset.js', | ||
globals: {}, | ||
testEnvironment: 'node', | ||
transform: { | ||
'^.+\\.[tj]s$': [ | ||
'ts-jest', | ||
{ | ||
tsconfig: '<rootDir>/tsconfig.spec.json', | ||
}, | ||
], | ||
}, | ||
moduleFileExtensions: ['ts', 'js', 'html'], | ||
coverageDirectory: '../../../coverage/libs/api/shared', | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1,3 @@ | ||
export * from './lib/models/request.model'; | ||
export * from './lib/kernel/graphql'; | ||
export * from './lib/kernel/shared-kernel.module'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export * from './subscriptions/graphql-subscription.service'; |
103 changes: 103 additions & 0 deletions
103
libs/api/shared/src/lib/kernel/graphql/subscriptions/graphql-subscription.service.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,103 @@ | ||
import { CqrsModule, EventBus } from '@nestjs/cqrs'; | ||
import { Test, TestingModule } from '@nestjs/testing'; | ||
|
||
import { GraphQLSubscriptionService } from './graphql-subscription.service'; | ||
|
||
class TestEvent { | ||
constructor(public readonly someProperty: string) {} | ||
} | ||
|
||
class TestEvent2 { | ||
constructor(public readonly someProperty: string) {} | ||
} | ||
|
||
describe('GraphQLSubscriptionService', () => { | ||
let service: GraphQLSubscriptionService; | ||
let eventBus: EventBus; | ||
|
||
beforeEach(async () => { | ||
const module: TestingModule = await Test.createTestingModule({ | ||
imports: [CqrsModule], | ||
providers: [GraphQLSubscriptionService], | ||
}).compile(); | ||
|
||
service = module.get<GraphQLSubscriptionService>( | ||
GraphQLSubscriptionService, | ||
); | ||
eventBus = module.get<EventBus>(EventBus); | ||
}); | ||
|
||
describe('getSubscriptionIteratorForEvent', () => { | ||
it('should return requested event via AsyncIterator', async () => { | ||
const subscriptionIterator = | ||
service.getSubscriptionIteratorForEvent(TestEvent); | ||
|
||
eventBus.publish(new TestEvent('event of interest 1')); | ||
eventBus.publish(new TestEvent2('not interesting')); | ||
eventBus.publish(new TestEvent2('not interesting')); | ||
eventBus.publish(new TestEvent('event of interest 2')); | ||
|
||
await expect(subscriptionIterator.next()).resolves.toEqual({ | ||
done: false, | ||
value: { | ||
someProperty: 'event of interest 1', | ||
}, | ||
}); | ||
await expect(subscriptionIterator.next()).resolves.toEqual({ | ||
done: false, | ||
value: { | ||
someProperty: 'event of interest 2', | ||
}, | ||
}); | ||
}); | ||
|
||
it('should apply map and filter operators to the event stream', async () => { | ||
const map = jest.fn(() => ({ | ||
someProperty: 'bar', | ||
})); | ||
const filter = jest.fn((payload) => payload.someProperty.length >= 3); | ||
|
||
const subscriptionIterator = service.getSubscriptionIteratorForEvent( | ||
TestEvent, | ||
{ filter, map }, | ||
); | ||
|
||
eventBus.publish(new TestEvent('foo')); | ||
|
||
expect(filter).toHaveBeenCalled(); | ||
expect(map).toHaveBeenCalled(); | ||
await expect(subscriptionIterator.next()).resolves.toEqual({ | ||
done: false, | ||
value: { | ||
someProperty: 'bar', | ||
}, | ||
}); | ||
}); | ||
|
||
it('should filter event with filter operator', async () => { | ||
const filter = (payload: TestEvent) => payload.someProperty.length > 3; | ||
|
||
const subscriptionIterator = service.getSubscriptionIteratorForEvent( | ||
TestEvent, | ||
{ filter }, | ||
); | ||
|
||
eventBus.publish(new TestEvent('foo')); | ||
eventBus.publish(new TestEvent('foobar')); | ||
|
||
await expect(subscriptionIterator.next()).resolves.toEqual({ | ||
done: false, | ||
value: { | ||
someProperty: 'foobar', | ||
}, | ||
}); | ||
}); | ||
}); | ||
|
||
it('should complete iterator on module destroy', async () => { | ||
const subscriptionIterator = | ||
service.getSubscriptionIteratorForEvent(TestEvent); | ||
service.onModuleDestroy(); | ||
await expect(subscriptionIterator.next()).resolves.toEqual({ done: true }); | ||
}); | ||
}); |
Oops, something went wrong.