From 29a4893e4e3c2bc043d981b1b27e0b6236eca7e0 Mon Sep 17 00:00:00 2001 From: Timon Date: Wed, 3 May 2023 00:49:39 +0200 Subject: [PATCH] feat(api): graphql subscriptions (#148) --- .../actions/build-and-deploy-api/action.yml | 3 - .github/workflows/next-deployment.yml | 1 - .github/workflows/preview-deployment.yml | 3 +- apps/api/src/app/app.module.ts | 7 +- .../graphql-subscriptions.controller.spec.ts | 63 +++++++++++ .../graphql-subscriptions.controller.ts | 42 +++++++ .../adr006-graphql-subscriptions-sse.md | 32 ++++++ libs/api/shared/jest.config.ts | 17 +++ libs/api/shared/project.json | 14 +++ libs/api/shared/src/index.ts | 2 + .../shared/src/lib/kernel/graphql/index.ts | 1 + .../graphql-subscription.service.spec.ts | 103 +++++++++++++++++ .../graphql-subscription.service.ts | 53 +++++++++ .../observable-to-asynciterabble.spec.ts | 64 +++++++++++ .../observable-to-asynciterable.helper.ts | 104 ++++++++++++++++++ .../src/lib/kernel/shared-kernel.module.ts | 10 ++ libs/api/shared/tsconfig.json | 3 + libs/api/shared/tsconfig.spec.json | 14 +++ package-lock.json | 79 ++++++++++--- package.json | 4 +- 20 files changed, 591 insertions(+), 28 deletions(-) create mode 100644 apps/api/src/app/controllers/graphql-subscriptions.controller.spec.ts create mode 100644 apps/api/src/app/controllers/graphql-subscriptions.controller.ts create mode 100644 docs/architecture-decisions/adr006-graphql-subscriptions-sse.md create mode 100644 libs/api/shared/jest.config.ts create mode 100644 libs/api/shared/src/lib/kernel/graphql/index.ts create mode 100644 libs/api/shared/src/lib/kernel/graphql/subscriptions/graphql-subscription.service.spec.ts create mode 100644 libs/api/shared/src/lib/kernel/graphql/subscriptions/graphql-subscription.service.ts create mode 100644 libs/api/shared/src/lib/kernel/graphql/subscriptions/observable-to-asynciterabble.spec.ts create mode 100644 libs/api/shared/src/lib/kernel/graphql/subscriptions/observable-to-asynciterable.helper.ts create mode 100644 libs/api/shared/src/lib/kernel/shared-kernel.module.ts create mode 100644 libs/api/shared/tsconfig.spec.json diff --git a/.github/actions/build-and-deploy-api/action.yml b/.github/actions/build-and-deploy-api/action.yml index 7dadeaae..cd8c645e 100644 --- a/.github/actions/build-and-deploy-api/action.yml +++ b/.github/actions/build-and-deploy-api/action.yml @@ -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" diff --git a/.github/workflows/next-deployment.yml b/.github/workflows/next-deployment.yml index c692d0aa..1a6350a5 100644 --- a/.github/workflows/next-deployment.yml +++ b/.github/workflows/next-deployment.yml @@ -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 diff --git a/.github/workflows/preview-deployment.yml b/.github/workflows/preview-deployment.yml index 5d021376..547cda69 100644 --- a/.github/workflows/preview-deployment.yml +++ b/.github/workflows/preview-deployment.yml @@ -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 @@ -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 }} diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 6b553b4e..ff654e6e 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -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: [ @@ -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({ @@ -35,8 +34,10 @@ import { AppService } from './app.service'; }), inject: [ConfigService], }), + SharedKernel, AuthModule, ], providers: [AppService, AppResolver], + controllers: [GraphqlSubscriptionsController], }) export class AppModule {} diff --git a/apps/api/src/app/controllers/graphql-subscriptions.controller.spec.ts b/apps/api/src/app/controllers/graphql-subscriptions.controller.spec.ts new file mode 100644 index 00000000..c29528ee --- /dev/null +++ b/apps/api/src/app/controllers/graphql-subscriptions.controller.spec.ts @@ -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(), + }, + ], + controllers: [GraphqlSubscriptionsController], + }).compile(); + + controller = module.get( + GraphqlSubscriptionsController, + ); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + it('should throw ServiceUnavailableException if handler not ready', () => { + const requestEmuFn = () => + controller.subscriptionHandler( + createMock(), + createMock(), + ); + 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(), + createMock(), + ), + ).resolves.toBeTruthy(); + expect(fn).toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/app/controllers/graphql-subscriptions.controller.ts b/apps/api/src/app/controllers/graphql-subscriptions.controller.ts new file mode 100644 index 00000000..90f69867 --- /dev/null +++ b/apps/api/src/app/controllers/graphql-subscriptions.controller.ts @@ -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; + + 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 { + if (!this.handler) { + throw new ServiceUnavailableException( + 'GraphQL Subscription handler not ready yet. Try again.', + ); + } + + return this.handler(req, res); + } +} diff --git a/docs/architecture-decisions/adr006-graphql-subscriptions-sse.md b/docs/architecture-decisions/adr006-graphql-subscriptions-sse.md new file mode 100644 index 00000000..405c75e6 --- /dev/null +++ b/docs/architecture-decisions/adr006-graphql-subscriptions-sse.md @@ -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. diff --git a/libs/api/shared/jest.config.ts b/libs/api/shared/jest.config.ts new file mode 100644 index 00000000..cc86af8d --- /dev/null +++ b/libs/api/shared/jest.config.ts @@ -0,0 +1,17 @@ +/* eslint-disable */ +export default { + displayName: 'api-shared', + preset: '../../../jest.preset.js', + globals: {}, + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': [ + 'ts-jest', + { + tsconfig: '/tsconfig.spec.json', + }, + ], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../../coverage/libs/api/shared', +}; diff --git a/libs/api/shared/project.json b/libs/api/shared/project.json index a69c8f7e..081f0ff3 100644 --- a/libs/api/shared/project.json +++ b/libs/api/shared/project.json @@ -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": [] diff --git a/libs/api/shared/src/index.ts b/libs/api/shared/src/index.ts index e7cbfcc1..dda210e7 100644 --- a/libs/api/shared/src/index.ts +++ b/libs/api/shared/src/index.ts @@ -1 +1,3 @@ export * from './lib/models/request.model'; +export * from './lib/kernel/graphql'; +export * from './lib/kernel/shared-kernel.module'; diff --git a/libs/api/shared/src/lib/kernel/graphql/index.ts b/libs/api/shared/src/lib/kernel/graphql/index.ts new file mode 100644 index 00000000..0367ea5f --- /dev/null +++ b/libs/api/shared/src/lib/kernel/graphql/index.ts @@ -0,0 +1 @@ +export * from './subscriptions/graphql-subscription.service'; diff --git a/libs/api/shared/src/lib/kernel/graphql/subscriptions/graphql-subscription.service.spec.ts b/libs/api/shared/src/lib/kernel/graphql/subscriptions/graphql-subscription.service.spec.ts new file mode 100644 index 00000000..13f6eff9 --- /dev/null +++ b/libs/api/shared/src/lib/kernel/graphql/subscriptions/graphql-subscription.service.spec.ts @@ -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, + ); + eventBus = module.get(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 }); + }); +}); diff --git a/libs/api/shared/src/lib/kernel/graphql/subscriptions/graphql-subscription.service.ts b/libs/api/shared/src/lib/kernel/graphql/subscriptions/graphql-subscription.service.ts new file mode 100644 index 00000000..ffe6a8eb --- /dev/null +++ b/libs/api/shared/src/lib/kernel/graphql/subscriptions/graphql-subscription.service.ts @@ -0,0 +1,53 @@ +import { Injectable, OnModuleDestroy, Type } from '@nestjs/common'; +import { EventBus, IEvent, ofType } from '@nestjs/cqrs'; +import { Observable, Subject, filter, map, share, takeUntil } from 'rxjs'; + +import { observableToAsyncIterable } from './observable-to-asynciterable.helper'; + +export type SubscriptionOperators = Partial<{ + map: (payload: TInitial) => TReturn; + filter: (payload: TInitial) => boolean; +}>; + +@Injectable() +export class GraphQLSubscriptionService implements OnModuleDestroy { + private readonly onDestroySubject = new Subject(); + private readonly subscribedEvents = new Set(); + private readonly eventStream$: Observable; + + constructor(eventBus: EventBus) { + this.eventStream$ = eventBus.pipe( + share(), + takeUntil(this.onDestroySubject), + ); + } + + onModuleDestroy(): void { + this.onDestroySubject.next(); + this.onDestroySubject.complete(); + } + + /** + This method creates an AsyncIterator of the EventBus event stream filtered by the event type. + @template TEvent The event type. + @template TReturn The return type of the AsyncIterableIterator. This is the type passed to the subscription handler (potentially user facing). + @param {TEvent} event The event type to subscribe to. + @param {SubscriptionOperators} [operators] Optional operators to apply to the event stream. + @returns {AsyncIterableIterator} An AsyncIterableIterator of events for the specified event type with the operators applied where TReturn is the type of each emitted item. + **/ + getSubscriptionIteratorForEvent( + event: TEvent, + operators?: SubscriptionOperators, TReturn>, + ): AsyncIterableIterator { + let typeEventStream$ = this.eventStream$.pipe(ofType(event)); + + if (operators?.map) { + typeEventStream$ = typeEventStream$.pipe(map(operators.map)); + } + if (operators?.filter) { + typeEventStream$ = typeEventStream$.pipe(filter(operators.filter)); + } + + return observableToAsyncIterable(typeEventStream$); + } +} diff --git a/libs/api/shared/src/lib/kernel/graphql/subscriptions/observable-to-asynciterabble.spec.ts b/libs/api/shared/src/lib/kernel/graphql/subscriptions/observable-to-asynciterabble.spec.ts new file mode 100644 index 00000000..f3e9969b --- /dev/null +++ b/libs/api/shared/src/lib/kernel/graphql/subscriptions/observable-to-asynciterabble.spec.ts @@ -0,0 +1,64 @@ +import { of } from 'rxjs'; + +import { observableToAsyncIterable } from './observable-to-asynciterable.helper'; + +describe('observableToAsyncIterable', () => { + it('should finalize iterator when complete() is called on observer', () => { + const iterator = observableToAsyncIterable({ + subscribe: (observer) => { + observer.complete(); + return { unsubscribe: () => {} }; + }, + }); + + return iterator.next().then((result) => expect(result.done).toEqual(true)); + }); + + it('should iterate over stream', async () => { + const observable = { + subscribe: (observer: any) => { + observer.next(1); + observer.next(2); + observer.next(3); + observer.complete(); + return { unsubscribe: () => {} }; + }, + }; + const asyncIterable = observableToAsyncIterable(observable); + + const values = []; + for await (const value of asyncIterable) { + values.push(value); + } + + expect(values).toEqual([1, 2, 3]); + }); + + it('should work with rxjs observables', async () => { + // integration test for rxjs, as this is the main library we use for observables + const observable = of(1, 2, 3); + + const iterator = observableToAsyncIterable(observable); + + const values = []; + for await (const item of iterator) { + values.push(item); + } + + expect(values).toEqual([1, 2, 3]); + }); + + it('should reject on stream error', async () => { + const observable = { + subscribe: (observer: any) => { + observer.error(new Error('test error')); + return { unsubscribe: () => {} }; + }, + }; + + const iterator = observableToAsyncIterable(observable); + + await expect(iterator.next()).rejects.toThrow('test error'); + await expect(iterator.next()).resolves.toEqual({ done: true }); + }); +}); diff --git a/libs/api/shared/src/lib/kernel/graphql/subscriptions/observable-to-asynciterable.helper.ts b/libs/api/shared/src/lib/kernel/graphql/subscriptions/observable-to-asynciterable.helper.ts new file mode 100644 index 00000000..f127de5b --- /dev/null +++ b/libs/api/shared/src/lib/kernel/graphql/subscriptions/observable-to-asynciterable.helper.ts @@ -0,0 +1,104 @@ +export interface Observer { + next: (value: T) => void; + error: (error: Error) => void; + complete: () => void; +} + +export interface Observable { + subscribe(observer: Observer): { + unsubscribe: () => void; + }; +} + +export function observableToAsyncIterable( + observable: Observable, +): AsyncIterableIterator { + const callbackQueue: { + resolve: (value: IteratorResult) => void; + reject: (reason?: unknown) => void; + }[] = []; + const resultQueue: (IteratorResult | Error)[] = []; + + let listening = true; + + const pushValue = (value: T): void => { + if (callbackQueue.length > 0) { + /* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */ + callbackQueue.shift()!.resolve({ value, done: false }); + } else { + resultQueue.push({ value, done: false }); + } + }; + + const pushDone = (): void => { + if (callbackQueue.length > 0) { + /* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */ + callbackQueue.shift()!.resolve({ value: undefined, done: true }); + } else { + resultQueue.push({ value: undefined, done: true }); + } + }; + + const pullValue = (): Promise> => + new Promise((resolve, reject) => { + if (resultQueue.length > 0) { + /* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */ + const element = resultQueue.shift()!; + if (element instanceof Error) { + emptyQueue(); + reject(element); + } else { + resolve(element); + } + } else { + callbackQueue.push({ resolve, reject }); + } + }); + + const pushError = (error: Error): void => { + if (callbackQueue.length > 0) { + /* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */ + callbackQueue.shift()!.reject(error); + } else { + resultQueue.push(error); + } + }; + + const subscription = observable.subscribe({ + next(value: T) { + pushValue(value); + }, + error(err: Error) { + pushError(err); + }, + complete() { + pushDone(); + }, + }); + + const emptyQueue = (): void => { + if (listening) { + listening = false; + subscription.unsubscribe(); + for (const callbacks of callbackQueue) { + callbacks.resolve({ value: undefined, done: true }); + } + callbackQueue.length = 0; + resultQueue.length = 0; + } + }; + + return { + next() { + /* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */ + return listening ? pullValue() : this.return!(); + }, + return() { + emptyQueue(); + return Promise.resolve({ value: undefined, done: true }); + }, + [Symbol.asyncIterator]() { + return this; + }, + }; +} diff --git a/libs/api/shared/src/lib/kernel/shared-kernel.module.ts b/libs/api/shared/src/lib/kernel/shared-kernel.module.ts new file mode 100644 index 00000000..435dc774 --- /dev/null +++ b/libs/api/shared/src/lib/kernel/shared-kernel.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { CqrsModule } from '@nestjs/cqrs'; + +import { GraphQLSubscriptionService } from './graphql/subscriptions/graphql-subscription.service'; + +@Module({ + imports: [CqrsModule], + providers: [GraphQLSubscriptionService], +}) +export class SharedKernel {} diff --git a/libs/api/shared/tsconfig.json b/libs/api/shared/tsconfig.json index f2400abe..8122543a 100644 --- a/libs/api/shared/tsconfig.json +++ b/libs/api/shared/tsconfig.json @@ -14,6 +14,9 @@ "references": [ { "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" } ] } diff --git a/libs/api/shared/tsconfig.spec.json b/libs/api/shared/tsconfig.spec.json new file mode 100644 index 00000000..231650b3 --- /dev/null +++ b/libs/api/shared/tsconfig.spec.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/package-lock.json b/package-lock.json index e4547f93..7e6b7d18 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,13 +23,15 @@ "@nestjs/common": "9.3.9", "@nestjs/config": "^2.3.1", "@nestjs/core": "9.3.9", + "@nestjs/cqrs": "^9.0.3", "@nestjs/graphql": "^11.0.4", "@nestjs/mongoose": "^9.2.2", "@nestjs/platform-express": "9.3.9", "angular-oauth2-oidc": "^15.0.1", "axios": "^1.0.0", "graphql": "^16.6.0", - "graphql-ws": "^5.12.1", + "graphql-sse": "^2.1.1", + "graphql-subscriptions": "^2.0.0", "mongoose": "^7.0.3", "reflect-metadata": "^0.1.13", "rxjs": "~7.8.0", @@ -6748,6 +6750,20 @@ } } }, + "node_modules/@nestjs/cqrs": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/@nestjs/cqrs/-/cqrs-9.0.3.tgz", + "integrity": "sha512-hmbrqf51BVdgmnnxErnLVXfPNTEqr4Hz8DyLa9dKLIW3BuOyI5RDwJ/9sKbJ47UDBhumC5nQlNK9qk27mhqHfw==", + "dependencies": { + "uuid": "9.0.0" + }, + "peerDependencies": { + "@nestjs/common": "^9.0.0", + "@nestjs/core": "^9.0.0", + "reflect-metadata": "0.1.13", + "rxjs": "^7.2.0" + } + }, "node_modules/@nestjs/graphql": { "version": "11.0.4", "resolved": "https://registry.npmjs.org/@nestjs/graphql/-/graphql-11.0.4.tgz", @@ -14321,6 +14337,28 @@ "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } }, + "node_modules/graphql-sse": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/graphql-sse/-/graphql-sse-2.1.1.tgz", + "integrity": "sha512-lOBG4ScQe9Acv0PodlavTmIuB8kwD4LRvUuXrFm9Ahgi460R2QP+6GgBUOlrAglscIF1P+U+tHJNjO27c3gOOw==", + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "graphql": ">=0.11 <=16" + } + }, + "node_modules/graphql-subscriptions": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/graphql-subscriptions/-/graphql-subscriptions-2.0.0.tgz", + "integrity": "sha512-s6k2b8mmt9gF9pEfkxsaO1lTxaySfKoEJzEfmwguBbQ//Oq23hIXCfR1hm4kdh5hnR20RdwB+s3BCb+0duHSZA==", + "dependencies": { + "iterall": "^1.3.0" + }, + "peerDependencies": { + "graphql": "^15.7.2 || ^16.0.0" + } + }, "node_modules/graphql-tag": { "version": "2.12.6", "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.6.tgz", @@ -14335,17 +14373,6 @@ "graphql": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, - "node_modules/graphql-ws": { - "version": "5.12.1", - "resolved": "https://registry.npmjs.org/graphql-ws/-/graphql-ws-5.12.1.tgz", - "integrity": "sha512-umt4f5NnMK46ChM2coO36PTFhHouBrK9stWWBczERguwYrGnPNxJ9dimU6IyOBfOkC6Izhkg4H8+F51W/8CYDg==", - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "graphql": ">=0.11 <=16" - } - }, "node_modules/handle-thing": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", @@ -32005,6 +32032,14 @@ "uid": "2.0.1" } }, + "@nestjs/cqrs": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/@nestjs/cqrs/-/cqrs-9.0.3.tgz", + "integrity": "sha512-hmbrqf51BVdgmnnxErnLVXfPNTEqr4Hz8DyLa9dKLIW3BuOyI5RDwJ/9sKbJ47UDBhumC5nQlNK9qk27mhqHfw==", + "requires": { + "uuid": "9.0.0" + } + }, "@nestjs/graphql": { "version": "11.0.4", "resolved": "https://registry.npmjs.org/@nestjs/graphql/-/graphql-11.0.4.tgz", @@ -37693,6 +37728,20 @@ "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.6.0.tgz", "integrity": "sha512-KPIBPDlW7NxrbT/eh4qPXz5FiFdL5UbaA0XUNz2Rp3Z3hqBSkbj0GVjwFDztsWVauZUWsbKHgMg++sk8UX0bkw==" }, + "graphql-sse": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/graphql-sse/-/graphql-sse-2.1.1.tgz", + "integrity": "sha512-lOBG4ScQe9Acv0PodlavTmIuB8kwD4LRvUuXrFm9Ahgi460R2QP+6GgBUOlrAglscIF1P+U+tHJNjO27c3gOOw==", + "requires": {} + }, + "graphql-subscriptions": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/graphql-subscriptions/-/graphql-subscriptions-2.0.0.tgz", + "integrity": "sha512-s6k2b8mmt9gF9pEfkxsaO1lTxaySfKoEJzEfmwguBbQ//Oq23hIXCfR1hm4kdh5hnR20RdwB+s3BCb+0duHSZA==", + "requires": { + "iterall": "^1.3.0" + } + }, "graphql-tag": { "version": "2.12.6", "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.6.tgz", @@ -37701,12 +37750,6 @@ "tslib": "^2.1.0" } }, - "graphql-ws": { - "version": "5.12.1", - "resolved": "https://registry.npmjs.org/graphql-ws/-/graphql-ws-5.12.1.tgz", - "integrity": "sha512-umt4f5NnMK46ChM2coO36PTFhHouBrK9stWWBczERguwYrGnPNxJ9dimU6IyOBfOkC6Izhkg4H8+F51W/8CYDg==", - "requires": {} - }, "handle-thing": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", diff --git a/package.json b/package.json index 92a25112..83204abb 100644 --- a/package.json +++ b/package.json @@ -91,13 +91,15 @@ "@nestjs/common": "9.3.9", "@nestjs/config": "^2.3.1", "@nestjs/core": "9.3.9", + "@nestjs/cqrs": "^9.0.3", "@nestjs/graphql": "^11.0.4", "@nestjs/mongoose": "^9.2.2", "@nestjs/platform-express": "9.3.9", "angular-oauth2-oidc": "^15.0.1", "axios": "^1.0.0", "graphql": "^16.6.0", - "graphql-ws": "^5.12.1", + "graphql-sse": "^2.1.1", + "graphql-subscriptions": "^2.0.0", "mongoose": "^7.0.3", "reflect-metadata": "^0.1.13", "rxjs": "~7.8.0",