Skip to content

Commit

Permalink
feat(api): graphql subscriptions (#148)
Browse files Browse the repository at this point in the history
  • Loading branch information
timonmasberg authored May 2, 2023
1 parent 53ba0e7 commit 29a4893
Show file tree
Hide file tree
Showing 20 changed files with 591 additions and 28 deletions.
3 changes: 0 additions & 3 deletions .github/actions/build-and-deploy-api/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,6 @@ inputs:
slot:
required: true
description: "Slot Identifier"
publishProfile:
required: true
description: "Azure Web App Publisher Profile"
mongoUri:
required: true
description: "Mongo Connection URI"
Expand Down
1 change: 0 additions & 1 deletion .github/workflows/next-deployment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ jobs:
uses: ./.github/actions/build-and-deploy-api
with:
slot: "next"
publishProfile: ${{ secrets.AZURE_WEBAPP_API_PUBLISH_PROFILE }}
mongoUri: ${{ secrets.DEV_MONGODB_URI }}
- name: Apply Database Migrations
run: ./tools/db/kordis-db.sh apply-pending-migrations
Expand Down
3 changes: 1 addition & 2 deletions .github/workflows/preview-deployment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,6 @@ jobs:
uses: ./.github/actions/build-and-deploy-api
with:
slot: "pr${{ github.event.issue.number }}"
publishProfile: ${{ secrets.AZURE_WEBAPP_API_PUBLISH_PROFILE }}
- name: Build and Deploy SPA
id: spa-deployment
uses: ./.github/actions/build-and-deploy-spa
Expand Down Expand Up @@ -170,12 +169,12 @@ jobs:
uses: ./.github/actions/build-and-deploy-api
with:
slot: "pr${{ github.event.pull_request.number }}"
publishProfile: ${{ secrets.AZURE_WEBAPP_API_PUBLISH_PROFILE }}
- name: Build and Deploy SPA
id: spa-deployment
uses: ./.github/actions/build-and-deploy-spa
with:
apiUrl: ${{ steps.api-deployment.outputs.url }}
oauthConfig: ${{ secrets.OAUTH_CONFIG }}
deploymentName: "PR-${{ github.event.pull_request.number }}.{{ github.sha }}"
deploymentEnv: "pr${{ github.event.pull_request.number }}"
publishToken: ${{ secrets.AZURE_STATIC_WEB_APP_TOKEN }}
Expand Down
7 changes: 4 additions & 3 deletions apps/api/src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ import { MongooseModule } from '@nestjs/mongoose';
import * as path from 'path';

import { AuthModule } from '@kordis/api/auth';
import { SharedKernel } from '@kordis/api/shared';

import { AppResolver } from './app.resolver';
import { AppService } from './app.service';
import { GraphqlSubscriptionsController } from './controllers/graphql-subscriptions.controller';

@Module({
imports: [
Expand All @@ -23,9 +25,6 @@ import { AppService } from './app.service';
process.env.NODE_ENV !== 'production'
? path.join(process.cwd(), 'apps/api/src/schema.gql')
: true,
subscriptions: {
'graphql-ws': true,
},
playground: process.env.NODE_ENV !== 'production',
}),
MongooseModule.forRootAsync({
Expand All @@ -35,8 +34,10 @@ import { AppService } from './app.service';
}),
inject: [ConfigService],
}),
SharedKernel,
AuthModule,
],
providers: [AppService, AppResolver],
controllers: [GraphqlSubscriptionsController],
})
export class AppModule {}
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 apps/api/src/app/controllers/graphql-subscriptions.controller.ts
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 docs/architecture-decisions/adr006-graphql-subscriptions-sse.md
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.
17 changes: 17 additions & 0 deletions libs/api/shared/jest.config.ts
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',
};
14 changes: 14 additions & 0 deletions libs/api/shared/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,20 @@
"options": {
"lintFilePatterns": ["libs/api/shared/**/*.ts"]
}
},
"test": {
"executor": "@nrwl/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/api/shared/jest.config.ts",
"passWithNoTests": true
},
"configurations": {
"ci": {
"ci": true,
"codeCoverage": true
}
}
}
},
"tags": []
Expand Down
2 changes: 2 additions & 0 deletions libs/api/shared/src/index.ts
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';
1 change: 1 addition & 0 deletions libs/api/shared/src/lib/kernel/graphql/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './subscriptions/graphql-subscription.service';
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 });
});
});
Loading

0 comments on commit 29a4893

Please sign in to comment.