Skip to content

Commit f704c80

Browse files
committed
Implemented /scenarios endpoint.
- List scenarios. - Add scenario. - Get scenario (as json or yaml). - Disable scenario. TODO: tests
1 parent daeb2ec commit f704c80

17 files changed

+473
-51
lines changed

.gitignore

+4-1
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,7 @@ lerna-debug.log*
3232
!.vscode/settings.json
3333
!.vscode/tasks.json
3434
!.vscode/launch.json
35-
!.vscode/extensions.json
35+
!.vscode/extensions.json
36+
37+
# Runtime data
38+
/storage

.prettierrc

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
{
22
"singleQuote": true,
3-
"trailingComma": "all"
4-
}
3+
"trailingComma": "all",
4+
"printWidth": 120
5+
}

package.json

+7-1
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,18 @@
2020
"test:e2e": "jest --config ./test/jest-e2e.json"
2121
},
2222
"dependencies": {
23+
"@letsflow/api": "../api",
2324
"@nestjs/common": "^9.0.0",
2425
"@nestjs/core": "^9.0.0",
26+
"@nestjs/mongoose": "^9.2.2",
2527
"@nestjs/platform-express": "^9.0.0",
2628
"@nestjs/swagger": "^6.3.0",
2729
"convict": "^6.2.4",
30+
"mongoose": "^7.2.2",
31+
"negotiator": "^0.6.3",
2832
"reflect-metadata": "^0.1.13",
29-
"rxjs": "^7.2.0"
33+
"rxjs": "^7.2.0",
34+
"uuid": "^9.0.0"
3035
},
3136
"devDependencies": {
3237
"@nestjs/cli": "^9.0.0",
@@ -35,6 +40,7 @@
3540
"@types/convict": "^6.1.2",
3641
"@types/express": "^4.17.13",
3742
"@types/jest": "29.5.1",
43+
"@types/negotiator": "^0.6.1",
3844
"@types/node": "18.16.12",
3945
"@types/supertest": "^2.0.11",
4046
"@typescript-eslint/eslint-plugin": "^5.0.0",

src/app.controller.spec.ts

-22
This file was deleted.

src/app.module.ts

+20-3
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,28 @@
1-
import { Module } from '@nestjs/common';
1+
import { MiddlewareConsumer, Module, RequestMethod } from '@nestjs/common';
22
import { AppController } from './app.controller';
33
import { AppService } from './app.service';
44
import { ConfigModule } from './common/config/config.module';
5+
import { ScenarioModule } from './scenario/scenario.module';
6+
import { YamlBodyParserMiddleware } from './middleware/yamlBodyParser.middleware';
57

68
@Module({
7-
imports: [ConfigModule],
9+
imports: [
10+
ConfigModule,
11+
ScenarioModule,
12+
/*MongooseModule.forRootAsync({
13+
imports: [ConfigModule],
14+
useFactory: async (config: ConfigService) => {
15+
await config.onModuleInit(); // Why is this necessary?
16+
return { uri: config.get('db') };
17+
},
18+
inject: [ConfigService],
19+
}),*/
20+
],
821
controllers: [AppController],
922
providers: [AppService],
1023
})
11-
export class AppModule {}
24+
export class AppModule {
25+
configure(consumer: MiddlewareConsumer) {
26+
consumer.apply(YamlBodyParserMiddleware).forRoutes({ path: '/scenarios', method: RequestMethod.POST });
27+
}
28+
}

src/config/schema.ts

+10
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,14 @@ export default {
1414
env: 'LOG_LEVEL',
1515
},
1616
},
17+
db: {
18+
default: 'mongodb://localhost:27017/letsflow',
19+
env: 'DB',
20+
},
21+
paths: {
22+
scenarios: {
23+
default: 'storage/scenarios',
24+
env: 'SCENARIO_PATH',
25+
},
26+
},
1727
};

src/main.ts

+3-5
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { INestApplication } from '@nestjs/common';
44
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
55
import { ConfigService } from './common/config/config.service';
66
import bodyParser from 'body-parser';
7+
import { NestExpressApplication } from '@nestjs/platform-express';
78

89
async function swagger(app: INestApplication, config: ConfigService) {
910
// eslint-disable-next-line @typescript-eslint/no-var-requires
@@ -21,15 +22,12 @@ async function swagger(app: INestApplication, config: ConfigService) {
2122
}
2223

2324
async function bootstrap() {
24-
const app = await NestFactory.create(AppModule, {
25-
bodyParser: false,
26-
});
25+
const app = await NestFactory.create<NestExpressApplication>(AppModule);
26+
app.disable('x-powered-by');
2727

2828
const config = await app.get<ConfigService>(ConfigService);
2929
await config.load();
3030

31-
app.use(bodyParser.json({}), bodyParser.urlencoded({ extended: false }));
32-
3331
app.enableShutdownHooks();
3432

3533
await swagger(app, config);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { Injectable, NestMiddleware } from '@nestjs/common';
2+
import { Request, Response, NextFunction } from 'express';
3+
import { yaml } from '@letsflow/api';
4+
5+
@Injectable()
6+
export class YamlBodyParserMiddleware implements NestMiddleware {
7+
use(req: Request, res: Response, next: NextFunction) {
8+
if (!req.is('application/yaml')) {
9+
next();
10+
return;
11+
}
12+
13+
let data = '';
14+
15+
req.setEncoding('utf8');
16+
req.on('data', (chunk) => {
17+
data += chunk;
18+
});
19+
20+
req.on('end', () => {
21+
try {
22+
req.body = yaml.parse(data);
23+
next();
24+
} catch (error) {
25+
next(error);
26+
}
27+
});
28+
}
29+
}

src/scenario/scenario.controller.ts

+77
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { Body, Controller, Delete, Get, Param, Post, Req, Res, UseInterceptors } from '@nestjs/common';
2+
import { ApiTags, ApiResponse, ApiOperation, ApiParam, ApiBody, ApiConsumes, ApiProduces } from '@nestjs/swagger';
3+
import { ScenarioService } from './scenario.service';
4+
import { ScenarioSummary } from './scenario.dto';
5+
import { Request, Response } from 'express';
6+
import { Scenario, validate, yaml } from '@letsflow/api';
7+
import Negotiator from 'negotiator';
8+
9+
//const scenarioSchema = 'https://schemas.letsflow.io/v1.0.0/scenario';
10+
11+
@ApiTags('Scenario')
12+
@Controller('scenarios')
13+
export class ScenarioController {
14+
constructor(private service: ScenarioService) {}
15+
16+
@ApiOperation({ summary: 'Get all scenarios' })
17+
@ApiResponse({ status: 200, description: 'Success', type: ScenarioSummary, isArray: true })
18+
@Get()
19+
list(): ScenarioSummary[] {
20+
return this.service.list();
21+
}
22+
23+
private contentNegotiation(req: Request, ext?: string): string | null {
24+
if (ext === 'json') return 'application/json';
25+
if (ext === 'yaml') return 'application/yaml';
26+
if (!ext) return new Negotiator(req).mediaType(['application/json', 'application/yaml']);
27+
28+
return null;
29+
}
30+
31+
@ApiOperation({ summary: 'Get scenario by ID' })
32+
@ApiParam({ name: 'id', description: 'Scenario ID', format: 'uuid' })
33+
@ApiResponse({ status: 200, description: 'Success' })
34+
@ApiProduces('application/json', 'application/x-yaml')
35+
@Get(':filename')
36+
get(@Param('filename') filename: string, @Req() req: Request, @Res() res: Response): void {
37+
const [id, ext] = filename.split('.');
38+
39+
if (!this.service.has(id)) {
40+
res.status(404).send('Scenario not found');
41+
return;
42+
}
43+
44+
const scenario = this.service.get(id);
45+
46+
if (this.contentNegotiation(req, ext) === 'application/yaml') {
47+
res.status(200).header('Content-Type', 'application/yaml').send(yaml.stringify(scenario));
48+
} else {
49+
res.status(200).json(scenario);
50+
}
51+
}
52+
53+
@ApiOperation({ summary: 'Store a scenario' })
54+
@ApiConsumes('application/json', 'application/yaml')
55+
@ApiBody({ required: true, schema: { type: 'object' } })
56+
@ApiResponse({ status: 201, description: 'Created' })
57+
@Post()
58+
async store(@Body() scenario: Scenario, @Req() req: Request, @Res() res: Response): Promise<void> {
59+
if (!validate(scenario)) {
60+
res.status(400).json(validate.errors);
61+
return;
62+
}
63+
64+
const id = await this.service.store(scenario);
65+
66+
res.status(201).setHeader('Location', `${req.url}/${id}`).send();
67+
}
68+
69+
@ApiOperation({ summary: 'Disable a scenario' })
70+
@ApiParam({ name: 'id', description: 'Scenario ID', format: 'uuid' })
71+
@ApiResponse({ status: 204, description: 'No Content' })
72+
@Delete()
73+
async disable(@Param('id') id: string, @Res() res: Response): Promise<void> {
74+
await this.service.disable(id);
75+
res.status(201).send();
76+
}
77+
}

src/scenario/scenario.dto.ts

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
3+
export class ScenarioSummary {
4+
@ApiProperty()
5+
id: string;
6+
7+
@ApiProperty()
8+
title: string;
9+
10+
@ApiProperty()
11+
description: string;
12+
13+
@ApiProperty()
14+
disabled: boolean;
15+
}

src/scenario/scenario.module.ts

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { Module } from '@nestjs/common';
2+
import { ScenarioService } from './scenario.service';
3+
import { ScenarioController } from './scenario.controller';
4+
import { ConfigModule } from '../common/config/config.module';
5+
6+
@Module({
7+
imports: [ConfigModule],
8+
providers: [ScenarioService],
9+
controllers: [ScenarioController],
10+
})
11+
export class ScenarioModule {}

src/scenario/scenario.service.spec.ts

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { Test, TestingModule } from '@nestjs/testing';
2+
import { ScenarioService } from './scenario.service';
3+
4+
describe('ScenarioService', () => {
5+
let service: ScenarioService;
6+
7+
beforeEach(async () => {
8+
const module: TestingModule = await Test.createTestingModule({
9+
providers: [ScenarioService],
10+
}).compile();
11+
12+
service = module.get<ScenarioService>(ScenarioService);
13+
});
14+
15+
it('should be defined', () => {
16+
expect(service).toBeDefined();
17+
});
18+
});

src/scenario/scenario.service.ts

+87
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { Injectable, OnModuleInit } from '@nestjs/common';
2+
import { normalize, Scenario, uuid } from '@letsflow/api';
3+
import { ConfigService } from '../common/config/config.service';
4+
import * as fs from 'fs/promises';
5+
import { ScenarioSummary } from './scenario.dto';
6+
7+
type NormalizedScenario = Required<Scenario>;
8+
9+
@Injectable()
10+
export class ScenarioService implements OnModuleInit {
11+
private path: string;
12+
private readonly scenarios: Map<string, NormalizedScenario> = new Map();
13+
private readonly disabled = new Set<string>();
14+
15+
constructor(private config: ConfigService) {}
16+
17+
private async load(): Promise<void> {
18+
const files = (await fs.readdir(this.path)).filter((file) => file.endsWith('.json'));
19+
20+
await Promise.all(
21+
files.map(async (file) => {
22+
const json = await fs.readFile(`${this.path}/${file}`, 'utf-8');
23+
const scenario = JSON.parse(json.toString());
24+
25+
const id = file.replace(/\.json$/, '');
26+
this.scenarios.set(id, scenario);
27+
}),
28+
);
29+
}
30+
31+
private async loadDisabled(): Promise<void> {
32+
try {
33+
const disabled = await fs.readFile(`${this.path}/.disabled`, 'utf-8');
34+
this.disabled.clear();
35+
disabled.split('\n').forEach((id) => this.disabled.add(id));
36+
} catch (e) {
37+
if (e.code !== 'ENOENT') throw e;
38+
this.disabled.clear();
39+
}
40+
}
41+
42+
async onModuleInit() {
43+
this.path = this.config.get('paths.scenarios');
44+
45+
await fs.mkdir(this.path, { recursive: true });
46+
await this.load();
47+
await this.loadDisabled();
48+
}
49+
50+
list(): ScenarioSummary[] {
51+
return Array.from(this.scenarios.entries()).map(([filename, { title, description }]) => {
52+
const id = filename.replace(/\.json$/, '');
53+
return { id, title, description, disabled: this.isDisabled(id) };
54+
});
55+
}
56+
57+
has(id: string): boolean {
58+
return this.scenarios.has(id);
59+
}
60+
61+
get(id: string): NormalizedScenario {
62+
return this.scenarios.get(id);
63+
}
64+
65+
isDisabled(id: string): boolean {
66+
return this.disabled.has(id);
67+
}
68+
69+
async store(scenario: Scenario): Promise<string> {
70+
const normalized = normalize(scenario) as Required<Scenario>;
71+
const id = uuid(normalized);
72+
73+
if (!this.has(id)) {
74+
await fs.writeFile(`${this.path}/${id}.json`, JSON.stringify(normalized, null, 2));
75+
this.scenarios.set(id, normalized);
76+
}
77+
78+
return id;
79+
}
80+
81+
async disable(id: string): Promise<void> {
82+
this.disabled.add(id);
83+
84+
const disabled = Array.from(this.disabled.values()).join('\n');
85+
await fs.writeFile(`${this.path}/.disabled`, disabled);
86+
}
87+
}

0 commit comments

Comments
 (0)