Skip to content

Commit

Permalink
feat(api): bootstrap domain features with organization domain (#214)
Browse files Browse the repository at this point in the history
  • Loading branch information
timonmasberg committed Jul 16, 2023
1 parent 4851793 commit 2379854
Show file tree
Hide file tree
Showing 96 changed files with 4,283 additions and 1,305 deletions.
3 changes: 2 additions & 1 deletion .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@
"no-console": "off",
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/no-explicit-any": "off"
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-non-null-assertion": "off"
}
}
]
Expand Down
4 changes: 0 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,6 @@ jobs:
RELEASE_VERSION: ${{ github.sha }}
API_URL: http://localhost:3000/
OAUTH_CONFIG: undefined
- uses: actions/upload-artifact@v3
with:
name: env-spa-prod
path: apps/spa/src/environments/environment.prod.ts
- name: Create API Environment File
run: |
envsubst < apps/api/src/.env.template > apps/api/src/.env
Expand Down
6 changes: 3 additions & 3 deletions apps/api/dev-tokens.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ API with pre-defined claims. The test users are equivalent to the users used in
[E2Es](../spa-e2e/README.md) and the test users registered in the development
application of our AAD.

| **Username** | **ID** (`oid`) | **First name** (`first_name`) | **Last name** (`last_name`) | **Emails** (`emails`) | Token |
| ------------ | ------------------------------------ | ----------------------------- | --------------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| testuser | c0cc4404-7907-4480-86d3-ba4bfc513c6d | Test | User | testuser@kordis-leitstelle.de | `eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJvaWQiOiIxMjM0IiwiZW1haWxzIjpbInRlc3R1c2VyQHRlc3QuY29tIl0sImdpdmVuX25hbWUiOiJUZXN0IiwiZmFtaWx5X25hbWUiOiJVc2VyIDEifQ.` |
| **Username** | **ID** (`oid`) | **First name** (`first_name`) | **Last name** (`last_name`) | **Emails** (`emails`) | **Organization** (`organization`) | Token |
| ------------ | ------------------------------------ | ----------------------------- | --------------------------- | ----------------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| testuser | c0cc4404-7907-4480-86d3-ba4bfc513c6d | Test | User | testuser@kordis-leitstelle.de | testorganization (dff7584efe2c174eee8bae45) | `eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJvaWQiOiIxMjM0IiwiZW1haWxzIjpbInRlc3R1c2VyQHRlc3QuY29tIl0sImdpdmVuX25hbWUiOiJUZXN0IiwiZmFtaWx5X25hbWUiOiJVc2VyIDEifQ.` |

The claims will be mapped to the
[AuthUser](../../libs/shared/auth/src/lib/auth-user.model.ts) Model in the
Expand Down
32 changes: 25 additions & 7 deletions apps/api/src/app/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,33 @@
import { classes } from '@automapper/classes';
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { GraphQLModule } from '@nestjs/graphql';
import { MongooseModule } from '@nestjs/mongoose';
import { AutomapperModule } from '@timonmasberg/automapper-nestjs';
import * as path from 'path';

import { AuthModule } from '@kordis/api/auth';
import { SentryObservabilityModule } from '@kordis/api/observability';
import { SharedKernel } from '@kordis/api/shared';
import {
DevObservabilityModule,
SentryObservabilityModule,
} from '@kordis/api/observability';
import { OrganizationModule } from '@kordis/api/organization';
import { SharedKernel, errorFormatterFactory } from '@kordis/api/shared';

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

const FEATURE_MODULES = [OrganizationModule];
const UTILITY_MODULES = [
SharedKernel,
AuthModule,
...(process.env.NODE_ENV === 'production' && !process.env.GITHUB_ACTIONS
? [SentryObservabilityModule]
: [DevObservabilityModule]),
];

@Module({
imports: [
ConfigModule.forRoot({
Expand All @@ -32,6 +47,9 @@ import { GraphqlSubscriptionsController } from './controllers/graphql-subscripti
'graphql-ws': true,
},
playground: config.get('NODE_ENV') !== 'production',
formatError: errorFormatterFactory(
config.get('NODE_ENV') === 'production',
),
}),
inject: [ConfigService],
}),
Expand All @@ -42,11 +60,11 @@ import { GraphqlSubscriptionsController } from './controllers/graphql-subscripti
}),
inject: [ConfigService],
}),
SharedKernel,
AuthModule,
...(process.env.NODE_ENV === 'production' && !process.env.GITHUB_ACTIONS
? [SentryObservabilityModule]
: []),
AutomapperModule.forRoot({
strategyInitializer: classes(),
}),
...UTILITY_MODULES,
...FEATURE_MODULES,
],
providers: [AppService, AppResolver],
controllers: [GraphqlSubscriptionsController],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,6 @@ describe('GraphqlSubscriptionsController', () => {
);
});

it('should be defined', () => {
expect(controller).toBeDefined();
});

it('should throw ServiceUnavailableException if handler not ready', () => {
const requestEmuFn = () =>
controller.subscriptionHandler(
Expand Down
18 changes: 12 additions & 6 deletions apps/api/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
// @formatter:off otelSdk has to imported on the very top!
// until https://github.com/open-telemetry/opentelemetry-js/issues/3450 is fixed, we have to import the oTel sdk via a relative path
// eslint-disable-next-line @nx/enforce-module-boundaries
import '../../../libs/api/observability/src/lib/oTelSdk';
import "../../../libs/api/observability/src/lib/oTelSdk";

import {Logger} from '@nestjs/common';
import {ConfigService} from '@nestjs/config';
import {NestFactory} from '@nestjs/core';
import {Logger, ValidationPipe} from "@nestjs/common";
import {ConfigService} from "@nestjs/config";
import {NestFactory} from "@nestjs/core";

import {AppModule} from './app/app.module';
import {AppModule} from "./app/app.module";
import {PresentableValidationException} from "@kordis/api/shared";


async function bootstrap(): Promise<void> {
const app = await NestFactory.create(AppModule, { cors: true });
const app = await NestFactory.create(AppModule, { cors: true, bufferLogs: true });
app.useLogger(app.get(Logger));
app.useGlobalPipes(new ValidationPipe({
exceptionFactory: (errors) => PresentableValidationException.fromClassValidationErrors(errors),
})
);
const config = app.get(ConfigService);
const envPort = config.get('PORT');

Expand Down
67 changes: 67 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Architecture

This document introduces the general architecture of Kordis with a focus on the
API. In general, we want to follow the Clean Architecture Principle, which tries
to achieve a state where:

1. we are independent of frameworks,
2. we ensure testability of our business logic,
3. we are independent of UI logic,
4. we are independent of the database and its access,
5. we are independent of external dependencies.

Since architecture is a lot about trade-offs, not every point mentioned above
might be wise to follow in a project. The main goal is to have a solid
separation of concerns. Every change should only have an isolated impact, and
since Kordis is a System that will definitely grow over time with more features
and external dependencies to join as components, it is a requirement to allow
the system to be easily extendable and maintainable by multiple people.

We aggregate the layers of the clean architecture approach in 2 layers, the
**Core** and the **Infrastructure** layer, which tries to achieve a solid
separation of concerns and a clear dependency direction, but also maintains a
good balance between the effort of maintaining the architecture and the value it
provides.

**Core** (core)
Contains the business logic, entities, events and domain exceptions. Everything
in here is something business relevant, that should not be touched and
influenced by changes not related to the business logic, such as changes to the
database or the framework. Therefore, the layer should not have any dependency
on other layers or components from outside its scope. This ensures that we can
easily test the logic and that we can change the infrastructure without having
to change the business logic. Nevertheless, we made some tradeoffs in the core
layer. We allow the use of NestJS framework specific CQRS handler annotations,
class validator annotations and annotations in the entity model for GraphQL
input and argument models. This is due to the fact, that maintaining another
layer on top of these decorators would be pure overhead, that would
overcomplicate the codebase and does not outweigh the value of simpler changes
in the infrastructure layer. Also, maintaining Input Models for GraphQL that
share the same structure as the entity model would also not benefit the
complexity of the codebase. Therefore, we decided to allow these annotations in
the core layer, if they are unlikely to change on the Input/Argument side of the
GraphQL API and the structure is equal.

**Infrastructure** (infra)
The infrastructure layer defines every external component the system interacts
with. It provides concert implementations such as the repositories (data
access), logging, external APIs, etc. The idea behind this is that all
dependencies point towards the core layer. At no time should the core layer be
based on any concrete implementation, framework or other specification. This
ensures that we are flexible with changes in our infrastructure and we allow our
domain to define what interfaces it requires. This should also result in easier
testing. The best example is the repository, where the core layer defines which
data it needs and the infrastructure layer implements this interface and hides
all the concrete database logic.

## CQRS

tbd by @JSPRH

Some resources used for this architecture are:

- https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html
- Clean Architecture: A Craftsman's Guide to Software Structure and Design
(Robert C. Martin Series)
- https://github.com/jasontaylordev/CleanArchitecture
- https://learn.microsoft.com/en-us/dotnet/architecture/modern-web-apps-azure/common-web-application-architectures#clean-architecture
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,15 @@ export class ExtractUserFromMsPrincipleHeader extends AuthUserExtractorStrategy
emails: string[];
given_name: string;
family_name: string;
organization: string;
};

return {
id: decodedToken['oid'] || decodedToken['sub'],
email: decodedToken['emails'][0],
firstName: decodedToken['given_name'],
lastName: decodedToken['family_name'],
organization: decodedToken['organization'],
};
}
}
1 change: 1 addition & 0 deletions libs/api/auth/src/lib/decorators/user.decorator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ describe('User Decorator', () => {
email: 'someemail@gmail.com',
firstName: 'somefirstname',
lastName: 'somelastname',
organization: 'someorganization',
};
const req = createMock<KordisRequest>({
user,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ describe('AuthInterceptor', () => {
firstName: 'foo',
lastName: 'bar',
email: 'foo@bar.de',
organization: 'testorg',
});

const handler = createMock<CallHandler>({
Expand Down
9 changes: 9 additions & 0 deletions libs/api/auth/src/lib/interceptors/auth.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,22 @@ import {
CallHandler,
ExecutionContext,
Injectable,
Logger,
NestInterceptor,
UnauthorizedException,
} from '@nestjs/common';
import { GqlContextType, GqlExecutionContext } from '@nestjs/graphql';
import { Observable, throwError } from 'rxjs';

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

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

@Injectable()
export class AuthInterceptor implements NestInterceptor {
private readonly logger: KordisLogger = new Logger(AuthInterceptor.name);

constructor(private readonly authUserExtractor: AuthUserExtractorStrategy) {}

intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
Expand All @@ -28,6 +32,11 @@ export class AuthInterceptor implements NestInterceptor {
const possibleAuthUser = this.authUserExtractor.getUserFromRequest(req);

if (!possibleAuthUser) {
this.logger.warn('Request without any extractable auth user', {
headers: req.headers,
body: req.body,
params: req.params,
});
// This is just intended to be a fallback, as we currently only aim to support running the API behind an OAuth Proxy
// You could write a custom auth user strategy which handles your auth process and return null if unauthorized
return throwError(() => new UnauthorizedException());
Expand Down
2 changes: 2 additions & 0 deletions libs/api/observability/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
export * from './lib/sentry-observability.module';
export * from './lib/dev-observability.module';
export * from './lib/decorators/trace.decorator';
export * from './lib/services/kordis-logger.interface';
19 changes: 19 additions & 0 deletions libs/api/observability/src/lib/dev-observability.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Logger, Module } from '@nestjs/common';

import { KORDIS_LOGGER_SERVICE } from './services/kordis-logger-service.interface';
import { KordisLoggerImpl } from './services/kordis.logger';
import { PinoLogger } from './services/pino-logger.service';

@Module({
providers: [
{
provide: KORDIS_LOGGER_SERVICE,
useValue: new PinoLogger(true),
},
{
provide: Logger,
useClass: KordisLoggerImpl,
},
],
})
export class DevObservabilityModule {}
63 changes: 63 additions & 0 deletions libs/api/observability/src/lib/filters/exceptions.filter.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import * as Sentry from '@sentry/node';

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

import { SentryExceptionsFilter } from './sentry-exceptions.filter';

describe('ExceptionsFilter', () => {
let sentryExceptionsFilter: SentryExceptionsFilter;
let addBreadcrumbMock: jest.Mock;
let captureExceptionMock: jest.Mock;

beforeEach(() => {
addBreadcrumbMock = jest.fn();
captureExceptionMock = jest.fn();

(Sentry.addBreadcrumb as jest.Mock) = addBreadcrumbMock;
(Sentry.captureException as jest.Mock) = captureExceptionMock;

sentryExceptionsFilter = new SentryExceptionsFilter();
});

afterEach(() => {
jest.clearAllMocks();
});

it('should capture a presentable exception as an info message', () => {
class MockPresentableException extends PresentableException {
code = 'code';

constructor() {
super('message');
}
}

const presentableException = new MockPresentableException();

sentryExceptionsFilter.catch(presentableException);

expect(addBreadcrumbMock).toHaveBeenCalledTimes(1);
expect(addBreadcrumbMock).toHaveBeenCalledWith({
level: 'error',
message: 'message',
data: {
name: 'Error',
code: 'code',
stack: expect.any(String),
},
});
expect(captureExceptionMock).not.toHaveBeenCalled();
});

it('should capture a non-presentable exception as an error', () => {
const exception = new Error('Some error');

sentryExceptionsFilter.catch(exception);

expect(captureExceptionMock).toHaveBeenCalledTimes(1);
expect(captureExceptionMock).toHaveBeenCalledWith(exception, {
level: 'error',
});
expect(addBreadcrumbMock).not.toHaveBeenCalled();
});
});
26 changes: 26 additions & 0 deletions libs/api/observability/src/lib/filters/sentry-exceptions.filter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Catch, ExceptionFilter } from '@nestjs/common';
import * as Sentry from '@sentry/node';

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

@Catch()
export class SentryExceptionsFilter implements ExceptionFilter {
catch(exception: unknown): void {
if (exception instanceof PresentableException) {
// if this is a presentable error, such as a validation error, we don't want to log it as an error but rather as an information to have the context for possible future debugging
Sentry.addBreadcrumb({
level: 'error',
message: exception.message,
data: {
code: exception.code,
name: exception.name,
stack: exception.stack,
},
});
} else {
Sentry.captureException(exception, {
level: 'error',
});
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ describe('SentryOTelUserContextInterceptor', () => {
email: 'test@example.com',
firstName: 'John',
lastName: 'Doe',
organization: 'testorg',
};

const ctx = createGqlContextForRequest(
Expand Down

0 comments on commit 2379854

Please sign in to comment.