Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/api-gateway/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { FileBucketModule } from './modules/file_bucket/file_bucket.module';
import { FileConfigModule } from './modules/file_config/file_config.module';
import { AnalyticsModule } from './modules/analytics/analytics.module';
import { AnalyticsConfigModule } from './modules/analytics_config/analytics_config.module';
import { CounterModule } from './modules/counter/counter.module';

@Module({
imports: [
Expand All @@ -26,6 +27,7 @@ import { AnalyticsConfigModule } from './modules/analytics_config/analytics_conf
FileConfigModule,
AnalyticsModule,
AnalyticsConfigModule,
CounterModule,
],
})
export class AppModule {}
81 changes: 81 additions & 0 deletions packages/api-gateway/src/modules/counter/counter.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import {
Body,
Controller,
Get,
Inject,
OnModuleInit,
Param,
Post,
} from '@nestjs/common';
import { ClientGrpc } from '@nestjs/microservices';
import {
ApiCreatedResponse,
ApiNotFoundResponse,
ApiOkResponse,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import { CounterProto } from 'juno-proto';
import { lastValueFrom } from 'rxjs';

const { COUNTER_SERVICE_NAME } = CounterProto;

@ApiTags('counter')
@Controller('counter')
export class CounterController implements OnModuleInit {
private counterService: CounterProto.CounterServiceClient;

constructor(
@Inject(COUNTER_SERVICE_NAME) private counterClient: ClientGrpc,
) {}

onModuleInit() {
this.counterService =
this.counterClient.getService<CounterProto.CounterServiceClient>(
COUNTER_SERVICE_NAME,
);
}

@Post()
@ApiOperation({ summary: 'Create a new counter' })
@ApiCreatedResponse({ description: 'Counter created' })
async createCounter(
@Body() body: { id: string; initialValue?: number },
): Promise<{ value: number }> {
return lastValueFrom(
this.counterService.createCounter({
id: body.id,
initialValue: body.initialValue,
}),
);
}

@Get(':id')
@ApiOperation({ summary: 'Get counter value' })
@ApiOkResponse({ description: 'Counter value retrieved' })
@ApiNotFoundResponse({ description: 'Counter not found' })
async getCounter(@Param('id') id: string): Promise<{ value: number }> {
return lastValueFrom(this.counterService.getCounter({ id }));
}

@Post(':id/increment')
@ApiOperation({ summary: 'Increment counter' })
@ApiOkResponse({ description: 'Counter incremented' })
async incrementCounter(@Param('id') id: string): Promise<{ value: number }> {
return lastValueFrom(this.counterService.incrementCounter({ id }));
}

@Post(':id/decrement')
@ApiOperation({ summary: 'Decrement counter' })
@ApiOkResponse({ description: 'Counter decremented' })
async decrementCounter(@Param('id') id: string): Promise<{ value: number }> {
return lastValueFrom(this.counterService.decrementCounter({ id }));
}

@Post(':id/reset')
@ApiOperation({ summary: 'Reset counter to zero' })
@ApiOkResponse({ description: 'Counter reset' })
async resetCounter(@Param('id') id: string): Promise<{ value: number }> {
return lastValueFrom(this.counterService.resetCounter({ id }));
}
}
29 changes: 29 additions & 0 deletions packages/api-gateway/src/modules/counter/counter.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { join } from 'path';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { CounterProto, CounterProtoFile } from 'juno-proto';
import { CounterController } from './counter.controller';

const { COUNTER_SERVICE_NAME, JUNO_COUNTER_PACKAGE_NAME } = CounterProto;

@Module({
imports: [
ConfigModule.forRoot({
envFilePath: join(__dirname, '../../../../../.env.local'),
}),
ClientsModule.register([
{
name: COUNTER_SERVICE_NAME,
transport: Transport.GRPC,
options: {
url: process.env.DB_SERVICE_ADDR,
package: JUNO_COUNTER_PACKAGE_NAME,
protoPath: CounterProtoFile,
},
},
]),
],
controllers: [CounterController],
})
export class CounterModule {}
155 changes: 155 additions & 0 deletions packages/api-gateway/test/counter.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { Test, TestingModule } from '@nestjs/testing';
import {
ClassSerializerInterceptor,
INestApplication,
ValidationPipe,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import * as request from 'supertest';
import * as GRPC from '@grpc/grpc-js';
import * as ProtoLoader from '@grpc/proto-loader';
import { ResetProtoFile } from 'juno-proto';
import { AppModule } from './../src/app.module';
import { RpcExceptionFilter } from 'src/rpc_exception_filter';

let app: INestApplication;

jest.setTimeout(15000);

beforeAll(async () => {
const proto = ProtoLoader.loadSync([ResetProtoFile]) as any;

const protoGRPC = GRPC.loadPackageDefinition(proto) as any;
const resetClient = new protoGRPC.juno.reset_db.DatabaseReset(
process.env.DB_SERVICE_ADDR,
GRPC.credentials.createInsecure(),
);
await new Promise((resolve) => {
resetClient.resetDb({}, () => {
resolve(0);
});
});
});

afterAll((done) => {
app.close();
done();
});

beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();

app = moduleFixture.createNestApplication();
app.useGlobalPipes(
new ValidationPipe({
transform: true,
}),
);
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
app.useGlobalFilters(new RpcExceptionFilter());

await app.init();
});

describe('Counter Routes (e2e)', () => {
describe('POST /counter', () => {
it('Should successfully create a counter', async () => {
return await request(app.getHttpServer())
.post('/counter')
.send({ id: 'test-counter', initialValue: 10 })
.expect(201)
.expect((res) => {
expect(res.body).toHaveProperty('value', 10);
});
});

it('Should create a counter with default value of 0', async () => {
return await request(app.getHttpServer())
.post('/counter')
.send({ id: 'test-counter-default' })
.expect(201)
.expect((res) => {
expect(res.body).toHaveProperty('value', 0);
});
});

it('Should return 409 when creating a duplicate counter', async () => {
await request(app.getHttpServer())
.post('/counter')
.send({ id: 'test-counter-dup', initialValue: 0 });

return await request(app.getHttpServer())
.post('/counter')
.send({ id: 'test-counter-dup', initialValue: 5 })
.expect(409);
});
});

describe('GET /counter/:id', () => {
it('Should successfully get a counter value', async () => {
await request(app.getHttpServer())
.post('/counter')
.send({ id: 'test-get-counter', initialValue: 42 });

return await request(app.getHttpServer())
.get('/counter/test-get-counter')
.expect(200)
.expect((res) => {
expect(res.body).toHaveProperty('value', 42);
});
});

it('Should return 404 for a nonexistent counter', async () => {
return await request(app.getHttpServer())
.get('/counter/nonexistent')
.expect(404);
});
});

describe('POST /counter/:id/increment', () => {
it('Should increment a counter', async () => {
await request(app.getHttpServer())
.post('/counter')
.send({ id: 'test-inc', initialValue: 0 });

return await request(app.getHttpServer())
.post('/counter/test-inc/increment')
.expect(201)
.expect((res) => {
expect(res.body).toHaveProperty('value', 1);
});
});
});

describe('POST /counter/:id/decrement', () => {
it('Should decrement a counter', async () => {
await request(app.getHttpServer())
.post('/counter')
.send({ id: 'test-dec', initialValue: 5 });

return await request(app.getHttpServer())
.post('/counter/test-dec/decrement')
.expect(201)
.expect((res) => {
expect(res.body).toHaveProperty('value', 4);
});
});
});

describe('POST /counter/:id/reset', () => {
it('Should reset a counter to zero', async () => {
await request(app.getHttpServer())
.post('/counter')
.send({ id: 'test-reset', initialValue: 99 });

return await request(app.getHttpServer())
.post('/counter/test-reset/reset')
.expect(201)
.expect((res) => {
expect(res.body).toHaveProperty('value', 0);
});
});
});
});
5 changes: 5 additions & 0 deletions packages/db-service/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -154,3 +154,8 @@ model FileServiceFile {

@@id([path, bucketName, configId, configEnv])
}

model Counter {
id String @id
value Int
}
2 changes: 2 additions & 0 deletions packages/db-service/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { FileBucketModule } from './modules/file_bucket/file_bucket.module';
import { FileConfigModule } from './modules/file_config/file_config.module';
import { FileProviderModule } from './modules/file_provider/file_provider.module';
import { AnalyticsConfigModule } from './modules/analytics_config/analytics_config.module';
import { CounterModule } from './modules/counter/counter.module';

@Module({
imports: [
Expand All @@ -28,6 +29,7 @@ import { AnalyticsConfigModule } from './modules/analytics_config/analytics_conf
FileConfigModule,
FileProviderModule,
AnalyticsConfigModule,
CounterModule,
],
controllers: [AppController],
providers: [AppService],
Expand Down
4 changes: 4 additions & 0 deletions packages/db-service/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ import {
FileProviderProtoFile,
AnalyticsConfigProto,
AnalyticsConfigProtoFile,
CounterProto,
CounterProtoFile,
} from 'juno-proto';
import { CustomRpcExceptionFilter } from './global-exception.filter';

Expand All @@ -52,6 +54,7 @@ async function bootstrap() {
FileConfigProto.JUNO_FILE_SERVICE_CONFIG_PACKAGE_NAME,
FileProviderProto.JUNO_FILE_SERVICE_PROVIDER_PACKAGE_NAME,
AnalyticsConfigProto.JUNO_ANALYTICS_SERVICE_ANALYTICS_CONFIG_PACKAGE_NAME,
CounterProto.JUNO_COUNTER_PACKAGE_NAME,
],
protoPath: [
UserProtoFile,
Expand All @@ -66,6 +69,7 @@ async function bootstrap() {
FileConfigProtoFile,
FileProviderProtoFile,
AnalyticsConfigProtoFile,
CounterProtoFile,
],
url: process.env.DB_SERVICE_ADDR,
},
Expand Down
Loading
Loading