Skip to content

Commit

Permalink
feat(api): bootstrap graphql (#124)
Browse files Browse the repository at this point in the history
  • Loading branch information
timonmasberg committed Apr 9, 2023
1 parent 7dc5f21 commit a4f6359
Show file tree
Hide file tree
Showing 27 changed files with 2,964 additions and 8,551 deletions.
11 changes: 9 additions & 2 deletions .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,13 @@
"rules": {}
},
{
"files": ["*.spec.ts", "*.spec.tsx", "*.spec.js", "*.spec.jsx"],
"files": [
"*.spec.ts",
"*.spec.tsx",
"*.spec.js",
"*.test-helper.ts",
"*.spec.jsx"
],
"env": {
"jest": true
},
Expand All @@ -49,7 +55,8 @@
"rules": {
"no-console": "off",
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/explicit-function-return-type": "off"
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/no-explicit-any": "off"
}
}
]
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
dist
tmp
/out-tsc
/apps/api/src/schema.gql

# dependencies
node_modules
Expand Down
22 changes: 0 additions & 22 deletions apps/api/src/app/app.controller.spec.ts

This file was deleted.

13 changes: 0 additions & 13 deletions apps/api/src/app/app.controller.ts

This file was deleted.

26 changes: 22 additions & 4 deletions apps/api/src/app/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,30 @@
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { GraphQLModule } from '@nestjs/graphql';
import * as path from 'path';

import { AppController } from './app.controller';
import { AuthModule } from '@kordis/api/auth';

import { AppResolver } from './app.resolver';
import { AppService } from './app.service';

@Module({
imports: [ConfigModule.forRoot({ isGlobal: true, cache: true })],
controllers: [AppController],
providers: [AppService],
imports: [
ConfigModule.forRoot({ isGlobal: true, cache: true }),
AuthModule,
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile:
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',
}),
],
providers: [AppService, AppResolver],
})
export class AppModule {}
23 changes: 23 additions & 0 deletions apps/api/src/app/app.resolver.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Test } from '@nestjs/testing';

import { AppResolver } from './app.resolver';
import { AppService } from './app.service';

describe('AppController', () => {
let resolver: AppResolver;

beforeEach(async () => {
const app = await Test.createTestingModule({
controllers: [AppResolver],
providers: [AppService],
}).compile();

resolver = app.get<AppResolver>(AppResolver);
});

describe('getData', () => {
it('should return "Welcome to api!"', () => {
expect(resolver.data()).toEqual({ message: 'Welcome to api!' });
});
});
});
19 changes: 19 additions & 0 deletions apps/api/src/app/app.resolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Field, ObjectType, Query, Resolver } from '@nestjs/graphql';

import { AppService } from './app.service';

@ObjectType()
export class AppData {
@Field(() => String)
message: string;
}

@Resolver(() => AppData)
export class AppResolver {
constructor(private appService: AppService) {}

@Query(() => AppData)
data(): AppData {
return this.appService.getData();
}
}
1 change: 0 additions & 1 deletion apps/api/webpack.config.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
const { composePlugins, withNx } = require('@nrwl/webpack');

// Nx plugins for webpack.
module.exports = composePlugins(withNx(), (config) => {
return config;
});
21 changes: 21 additions & 0 deletions docs/architecture-decisions/adr004-graphql-schema.ms
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# ADR004: GraphQL Schema Generation Strategy

## Status

accepted

## Context

Apollo GraphQL offers 2 approaches to create and maintain a GraphQL schema: Code First and Schema First. We have to choose one of them to start developing our Graph.

## Decision

We are using the Code First approach which generates a GraphQL schema from TypeScript classes and their annotations.
With the Code First approach, we have type safety out of the box and we can generate the schema automatically.
Furthermore, the knowledge about GraphQL schema specifications is rare in the team, which also contributed to this decision.

## Consequences

- It is easier to setup, maintain and refactor.
- We can think in TypeScript models and types while developing our schema, which should enable us to develop faster.
- We might hit a barrier which we currently can not see with the Code First Approach when our schema gets complex.
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,11 @@ import { Request } from 'express';

import { AuthUser } from '@kordis/shared/auth';

export interface AuthUserExtractorStrategy {
getUserFromRequest(req: Request): AuthUser | null;
export abstract class AuthUserExtractorStrategy {
abstract getUserFromRequest(req: Request): AuthUser | null;
}

export class ExtractUserFromMsPrincipleHeader
implements AuthUserExtractorStrategy
{
export class ExtractUserFromMsPrincipleHeader extends AuthUserExtractorStrategy {
getUserFromRequest(req: Request): AuthUser | null {
const headerValue = req.headers['authentication'];

Expand Down
30 changes: 7 additions & 23 deletions libs/api/auth/src/lib/decorators/user.decorator.spec.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,15 @@
import { createMock } from '@golevelup/ts-jest';
import { ExecutionContext } from '@nestjs/common';
import { ROUTE_ARGS_METADATA } from '@nestjs/common/constants';

import { KordisRequest } from '@kordis/api/shared';
import {
createContextForRequest,
createParamDecoratorFactory,
} from '@kordis/api/test-helpers';
import { AuthUser } from '@kordis/shared/auth';

import { User } from './user.decorator';

describe('User Decorator', () => {
function getParamDecoratorFactory(decorator: () => ParameterDecorator) {
class TestDecorator {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public test(@decorator() value): void {}
}

const args = Reflect.getMetadata(
ROUTE_ARGS_METADATA,
TestDecorator,
'test',
);
return args[Object.keys(args)[0]].factory;
}

it('should return user from request', () => {
const user: AuthUser = {
id: 'id123',
Expand All @@ -32,13 +20,9 @@ describe('User Decorator', () => {
const req = createMock<KordisRequest>({
user,
});
const context = createMock<ExecutionContext>({
switchToHttp: () => ({
getRequest: () => req,
}),
});
const factory = getParamDecoratorFactory(User);
const result = factory(context);
const context = createContextForRequest(req);
const factory = createParamDecoratorFactory(User);
const result = factory(null, context);
expect(result).toEqual(user);
});
});
10 changes: 6 additions & 4 deletions libs/api/auth/src/lib/decorators/user.decorator.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { ExecutionContext, createParamDecorator } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';

import { KordisRequest } from '@kordis/api/shared';
import { KordisGqlContext } from '@kordis/api/shared';

export const User = createParamDecorator((ctx: ExecutionContext) => {
const { user } = ctx.switchToHttp().getRequest<KordisRequest>();
return user;
export const User = createParamDecorator((_: never, ctx: ExecutionContext) => {
const req =
GqlExecutionContext.create(ctx).getContext<KordisGqlContext>().req;
return req.user;
});
17 changes: 7 additions & 10 deletions libs/api/auth/src/lib/interceptors/auth.interceptor.spec.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
import { createMock } from '@golevelup/ts-jest';
import {
CallHandler,
ExecutionContext,
UnauthorizedException,
} from '@nestjs/common';
import { CallHandler, UnauthorizedException } from '@nestjs/common';
import { Observable, firstValueFrom, of } from 'rxjs';

import { KordisRequest } from '@kordis/api/shared';
import { createContextForRequest } from '@kordis/api/test-helpers';
import { AuthUser } from '@kordis/shared/auth';

import { AuthUserExtractorStrategy } from '../auth-user-extractor-strategies/auth-user-extractor.strategy';
Expand All @@ -17,7 +14,7 @@ describe('AuthInterceptor', () => {
let service: AuthInterceptor;

beforeEach(() => {
mockAuthUserExtractor = new (class implements AuthUserExtractorStrategy {
mockAuthUserExtractor = new (class extends AuthUserExtractorStrategy {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
getUserFromRequest(req: KordisRequest): AuthUser | null {
return undefined;
Expand All @@ -38,7 +35,7 @@ describe('AuthInterceptor', () => {
await expect(
firstValueFrom(
service.intercept(
createMock<ExecutionContext>(),
createContextForRequest(createMock<KordisRequest>()),
createMock<CallHandler>(),
),
),
Expand All @@ -59,10 +56,10 @@ describe('AuthInterceptor', () => {
},
});

const ctxMock = createContextForRequest(createMock<KordisRequest>());

await expect(
firstValueFrom(
service.intercept(createMock<ExecutionContext>(), handler),
),
firstValueFrom(service.intercept(ctxMock, handler)),
).resolves.toBeTruthy();
});
});
6 changes: 4 additions & 2 deletions libs/api/auth/src/lib/interceptors/auth.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ import {
NestInterceptor,
UnauthorizedException,
} from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { Observable, throwError } from 'rxjs';

import { KordisRequest } from '@kordis/api/shared';
import { KordisGqlContext } from '@kordis/api/shared';

import { AuthUserExtractorStrategy } from '../auth-user-extractor-strategies/auth-user-extractor.strategy';

Expand All @@ -16,7 +17,8 @@ export class AuthInterceptor implements NestInterceptor {
constructor(private readonly authUserExtractor: AuthUserExtractorStrategy) {}

intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const req = context.switchToHttp().getRequest<KordisRequest>();
const ctx = GqlExecutionContext.create(context);
const req = ctx.getContext<KordisGqlContext>().req;

const possibleAuthUser = this.authUserExtractor.getUserFromRequest(req);

Expand Down
2 changes: 1 addition & 1 deletion libs/api/shared/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { default as KordisRequest } from './lib/models/request.model';
export * from './lib/models/request.model';
6 changes: 5 additions & 1 deletion libs/api/shared/src/lib/models/request.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import { Request } from 'express';

import { AuthUser } from '@kordis/shared/auth';

export default interface KordisRequest extends Request {
export interface KordisGqlContext {
req: KordisRequest;
}

export interface KordisRequest extends Request {
user: AuthUser;
}
4 changes: 4 additions & 0 deletions libs/api/test-helpers/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": ["../../../.eslintrc.json"],
"ignorePatterns": ["!**/*"]
}
16 changes: 16 additions & 0 deletions libs/api/test-helpers/project.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"name": "api-test-helpers",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/api/test-helpers/src",
"projectType": "library",
"targets": {
"lint": {
"executor": "@nrwl/linter:eslint",
"outputs": ["{options.outputFile}"],
"options": {
"lintFilePatterns": ["libs/api/test-helpers/**/*.ts"]
}
}
},
"tags": []
}
2 changes: 2 additions & 0 deletions libs/api/test-helpers/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { createContextForRequest } from './lib/execution-context.test-helper';
export { createParamDecoratorFactory } from './lib/decorator.test-helper';
12 changes: 12 additions & 0 deletions libs/api/test-helpers/src/lib/decorator.test-helper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { ROUTE_ARGS_METADATA } from '@nestjs/common/constants';

export function createParamDecoratorFactory(
decorator: () => ParameterDecorator,
) {
class TestDecorator {
public test(@decorator() value): void {}
}

const args = Reflect.getMetadata(ROUTE_ARGS_METADATA, TestDecorator, 'test');
return args[Object.keys(args)[0]].factory;
}
15 changes: 15 additions & 0 deletions libs/api/test-helpers/src/lib/execution-context.test-helper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { createMock } from '@golevelup/ts-jest';
import { ExecutionContext } from '@nestjs/common';

import { KordisRequest } from '@kordis/api/shared';

export function createContextForRequest(req: KordisRequest) {
return createMock<ExecutionContext>({
getArgs(): any[] {
return [null, null, { req }, null];
},
getType(): string {
return 'graphql';
},
});
}
Loading

0 comments on commit a4f6359

Please sign in to comment.