diff --git a/packages/api-gateway/src/app.module.ts b/packages/api-gateway/src/app.module.ts index afd72ce7..035c0c7c 100644 --- a/packages/api-gateway/src/app.module.ts +++ b/packages/api-gateway/src/app.module.ts @@ -12,6 +12,7 @@ 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 { FileDeleteModule } from './modules/file_delete/file_delete.module'; +import { FeatureFlagModule } from './modules/feature_flag/feature_flag.module'; @Module({ imports: [ @@ -28,6 +29,7 @@ import { FileDeleteModule } from './modules/file_delete/file_delete.module'; FileDeleteModule, AnalyticsModule, AnalyticsConfigModule, + FeatureFlagModule, ], }) export class AppModule {} diff --git a/packages/api-gateway/src/models/feature_flag.dto.ts b/packages/api-gateway/src/models/feature_flag.dto.ts new file mode 100644 index 00000000..51fe976b --- /dev/null +++ b/packages/api-gateway/src/models/feature_flag.dto.ts @@ -0,0 +1,63 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsString, IsOptional, IsBoolean } from 'class-validator'; +import { FeatureFlagProto } from 'juno-proto'; + +export class CreateFeatureFlagModel { + @ApiProperty({ + type: 'string', + description: 'Unique identifier for the feature flag', + }) + @IsString() + id: string; + + @ApiPropertyOptional({ + type: 'boolean', + description: 'Whether the flag is enabled. Defaults to false.', + }) + @IsOptional() + @IsBoolean() + enabled?: boolean; + + @ApiPropertyOptional({ + type: 'string', + description: 'Human-readable description of the flag', + }) + @IsOptional() + @IsString() + description?: string; +} + +export class UpdateFeatureFlagModel { + @ApiPropertyOptional({ + type: 'boolean', + description: 'Whether the flag is enabled', + }) + @IsOptional() + @IsBoolean() + enabled?: boolean; + + @ApiPropertyOptional({ + type: 'string', + description: 'Human-readable description of the flag', + }) + @IsOptional() + @IsString() + description?: string; +} + +export class FeatureFlagResponse { + @ApiProperty({ type: 'string', description: 'Feature flag ID' }) + id: string; + + @ApiProperty({ type: 'boolean', description: 'Whether the flag is enabled' }) + enabled: boolean; + + @ApiPropertyOptional({ type: 'string', description: 'Flag description' }) + description?: string; + + constructor(flag: FeatureFlagProto.FeatureFlag) { + this.id = flag.id; + this.enabled = flag.enabled; + this.description = flag.description; + } +} diff --git a/packages/api-gateway/src/modules/feature_flag/feature_flag.controller.ts b/packages/api-gateway/src/modules/feature_flag/feature_flag.controller.ts new file mode 100644 index 00000000..35b4abf9 --- /dev/null +++ b/packages/api-gateway/src/modules/feature_flag/feature_flag.controller.ts @@ -0,0 +1,120 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Inject, + OnModuleInit, + Param, + Body, +} from '@nestjs/common'; +import { ClientGrpc } from '@nestjs/microservices'; +import { + ApiBearerAuth, + ApiBadRequestResponse, + ApiCreatedResponse, + ApiNotFoundResponse, + ApiOkResponse, + ApiOperation, + ApiTags, + ApiUnauthorizedResponse, +} from '@nestjs/swagger'; +import { FeatureFlagProto } from 'juno-proto'; +import { lastValueFrom } from 'rxjs'; +import { + CreateFeatureFlagModel, + UpdateFeatureFlagModel, + FeatureFlagResponse, +} from 'src/models/feature_flag.dto'; + +const { FEATURE_FLAG_SERVICE_NAME } = FeatureFlagProto; + +@ApiBearerAuth('API_Key') +@ApiTags('feature-flag') +@Controller('feature-flags') +export class FeatureFlagController implements OnModuleInit { + private featureFlagService: FeatureFlagProto.FeatureFlagServiceClient; + + constructor( + @Inject(FEATURE_FLAG_SERVICE_NAME) private featureFlagClient: ClientGrpc, + ) {} + + onModuleInit() { + this.featureFlagService = + this.featureFlagClient.getService( + FEATURE_FLAG_SERVICE_NAME, + ); + } + + @Post() + @ApiOperation({ summary: 'Create a feature flag' }) + @ApiCreatedResponse({ + description: 'Feature flag created successfully', + type: FeatureFlagResponse, + }) + @ApiUnauthorizedResponse({ description: 'Unauthorized' }) + @ApiBadRequestResponse({ description: 'Bad Request' }) + async createFlag( + @Body() request: CreateFeatureFlagModel, + ): Promise { + const response = await lastValueFrom( + this.featureFlagService.createFlag({ + id: request.id, + enabled: request.enabled, + description: request.description, + }), + ); + return new FeatureFlagResponse(response.flag); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a feature flag by ID' }) + @ApiOkResponse({ + description: 'Feature flag retrieved successfully', + type: FeatureFlagResponse, + }) + @ApiNotFoundResponse({ description: 'Feature flag not found' }) + @ApiUnauthorizedResponse({ description: 'Unauthorized' }) + async getFlag(@Param('id') id: string): Promise { + const response = await lastValueFrom( + this.featureFlagService.getFlag({ id }), + ); + return new FeatureFlagResponse(response.flag); + } + + @Put(':id') + @ApiOperation({ summary: 'Update a feature flag' }) + @ApiOkResponse({ + description: 'Feature flag updated successfully', + type: FeatureFlagResponse, + }) + @ApiNotFoundResponse({ description: 'Feature flag not found' }) + @ApiUnauthorizedResponse({ description: 'Unauthorized' }) + @ApiBadRequestResponse({ description: 'Bad Request' }) + async setFlag( + @Param('id') id: string, + @Body() request: UpdateFeatureFlagModel, + ): Promise { + const response = await lastValueFrom( + this.featureFlagService.setFlag({ + id, + enabled: request.enabled, + description: request.description, + }), + ); + return new FeatureFlagResponse(response.flag); + } + + @Delete(':id') + @ApiOperation({ summary: 'Delete a feature flag' }) + @ApiOkResponse({ description: 'Feature flag deleted successfully' }) + @ApiNotFoundResponse({ description: 'Feature flag not found' }) + @ApiUnauthorizedResponse({ description: 'Unauthorized' }) + async deleteFlag(@Param('id') id: string): Promise<{ success: boolean }> { + const response = await lastValueFrom( + this.featureFlagService.deleteFlag({ id }), + ); + return { success: response.success }; + } +} diff --git a/packages/api-gateway/src/modules/feature_flag/feature_flag.module.ts b/packages/api-gateway/src/modules/feature_flag/feature_flag.module.ts new file mode 100644 index 00000000..9abd6b58 --- /dev/null +++ b/packages/api-gateway/src/modules/feature_flag/feature_flag.module.ts @@ -0,0 +1,62 @@ +import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { ClientsModule, Transport } from '@nestjs/microservices'; +import { join } from 'path'; +import { FeatureFlagController } from './feature_flag.controller'; +import { + ApiKeyProto, + ApiKeyProtoFile, + FeatureFlagProto, + FeatureFlagProtoFile, + JwtProto, + JwtProtoFile, +} from 'juno-proto'; +import { ApiKeyMiddleware } from 'src/middleware/api_key.middleware'; + +const { JWT_SERVICE_NAME, JUNO_JWT_PACKAGE_NAME } = JwtProto; +const { API_KEY_SERVICE_NAME, JUNO_API_KEY_PACKAGE_NAME } = ApiKeyProto; +const { FEATURE_FLAG_SERVICE_NAME, JUNO_FEATURE_FLAG_PACKAGE_NAME } = + FeatureFlagProto; + +@Module({ + imports: [ + ConfigModule.forRoot({ + envFilePath: join(__dirname, '../../../../../.env.local'), + }), + ClientsModule.register([ + { + name: JWT_SERVICE_NAME, + transport: Transport.GRPC, + options: { + url: process.env.AUTH_SERVICE_ADDR, + package: JUNO_JWT_PACKAGE_NAME, + protoPath: JwtProtoFile, + }, + }, + { + name: API_KEY_SERVICE_NAME, + transport: Transport.GRPC, + options: { + url: process.env.AUTH_SERVICE_ADDR, + package: JUNO_API_KEY_PACKAGE_NAME, + protoPath: ApiKeyProtoFile, + }, + }, + { + name: FEATURE_FLAG_SERVICE_NAME, + transport: Transport.GRPC, + options: { + url: process.env.DB_SERVICE_ADDR, + package: JUNO_FEATURE_FLAG_PACKAGE_NAME, + protoPath: FeatureFlagProtoFile, + }, + }, + ]), + ], + controllers: [FeatureFlagController], +}) +export class FeatureFlagModule implements NestModule { + configure(consumer: MiddlewareConsumer) { + consumer.apply(ApiKeyMiddleware).forRoutes('feature-flags*'); + } +} diff --git a/packages/api-gateway/test/feature_flag.e2e-spec.ts b/packages/api-gateway/test/feature_flag.e2e-spec.ts new file mode 100644 index 00000000..48d29247 --- /dev/null +++ b/packages/api-gateway/test/feature_flag.e2e-spec.ts @@ -0,0 +1,193 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { + ClassSerializerInterceptor, + INestApplication, + ValidationPipe, +} from '@nestjs/common'; +import * as request from 'supertest'; +import { AppModule } from '../src/app.module'; +import { Reflector } from '@nestjs/core'; +import * as GRPC from '@grpc/grpc-js'; +import * as ProtoLoader from '@grpc/proto-loader'; +import { ResetProtoFile } from 'juno-proto'; +import { RpcExceptionFilter } from 'src/rpc_exception_filter'; + +let app: INestApplication; +const ADMIN_EMAIL = 'test-superadmin@test.com'; +const ADMIN_PASSWORD = 'test-password'; +let apiKey: string | undefined = undefined; + +jest.setTimeout(10000); + +async function createAPIKeyForProjectName( + projectName: string, +): Promise { + const key = await request(app.getHttpServer()) + .post('/auth/key') + .set('X-User-Email', ADMIN_EMAIL) + .set('X-User-Password', ADMIN_PASSWORD) + .send({ + environment: 'prod', + project: { + name: projectName, + }, + }); + + return key.body['apiKey']; +} + +beforeAll(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(); + + 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, reject) => { + resetClient.resetDb({}, (err: any) => { + if (err) return reject(err); + resolve(0); + }); + }); +}); + +afterAll(async () => { + await app.close(); +}); + +beforeEach(async () => { + if (!apiKey) { + apiKey = await createAPIKeyForProjectName('test-seed-project'); + } +}); + +describe('Feature Flag Routes (e2e)', () => { + describe('POST /feature-flags', () => { + it('Should successfully create a feature flag with valid credentials', async () => { + return await request(app.getHttpServer()) + .post('/feature-flags') + .set('Authorization', 'Bearer ' + apiKey) + .send({ + id: 'feature-flag-1', + enabled: true, + description: 'feature flag 1 description', + }) + .expect(201) + .expect((res) => { + expect(res.body).toHaveProperty('id', 'feature-flag-1'); + expect(res.body.enabled).toBe(true); + expect(res.body.description).toBe('feature flag 1 description'); + }); + }); + + it('Should throw a 400 status error when creating a flag with missing id', async () => { + return await request(app.getHttpServer()) + .post('/feature-flags') + .set('Authorization', 'Bearer ' + apiKey) + .send({ enabled: true }) + .expect(400); + }); + + it('Should throw a 401 status error when creating a flag without valid credentials', async () => { + return await request(app.getHttpServer()) + .post('/feature-flags') + .send({ id: 'feature-flag-2', enabled: true }) + .expect(401); + }); + }); + + describe('GET /feature-flags/:id', () => { + it('Should successfully get a feature flag with valid id and credentials', async () => { + return await request(app.getHttpServer()) + .get('/feature-flags/feature-flag-1') + .set('Authorization', 'Bearer ' + apiKey) + .expect(200) + .expect((res) => { + expect(res.body).toHaveProperty('id', 'feature-flag-1'); + expect(res.body.enabled).toBe(true); + }); + }); + + it('Should throw a 401 status error when fetching a flag without valid credentials', async () => { + return await request(app.getHttpServer()) + .get('/feature-flags/feature-flag-1') + .expect(401); + }); + + it('Should throw a 404 status error when fetching a nonexistent flag', async () => { + return await request(app.getHttpServer()) + .get('/feature-flags/feature-flag-100') + .set('Authorization', 'Bearer ' + apiKey) + .expect(404); + }); + }); + + describe('PUT /feature-flags/:id', () => { + it('Should successfully update a feature flag with valid data', async () => { + return await request(app.getHttpServer()) + .put('/feature-flags/feature-flag-1') + .set('Authorization', 'Bearer ' + apiKey) + .send({ enabled: false }) + .expect(200) + .expect((res) => { + expect(res.body).toHaveProperty('id', 'feature-flag-1'); + expect(res.body.enabled).toBe(false); + }); + }); + + it('Should throw a 401 status error when updating without valid credentials', async () => { + return await request(app.getHttpServer()) + .put('/feature-flags/feature-flag-1') + .send({ enabled: true }) + .expect(401); + }); + + it('Should throw a 404 status error when updating a nonexistent flag', async () => { + return await request(app.getHttpServer()) + .put('/feature-flags/feature-flag-100') + .set('Authorization', 'Bearer ' + apiKey) + .send({ enabled: true }) + .expect(404); + }); + }); + + describe('DELETE /feature-flags/:id', () => { + it('Should throw a 401 status error when deleting without valid credentials', async () => { + return await request(app.getHttpServer()) + .delete('/feature-flags/feature-flag-1') + .expect(401); + }); + + it('Should throw a 404 status error when deleting a nonexistent flag', async () => { + return await request(app.getHttpServer()) + .delete('/feature-flags/feature-flag-100') + .set('Authorization', 'Bearer ' + apiKey) + .expect(404); + }); + + it('Should successfully delete a feature flag with valid id and credentials', async () => { + return await request(app.getHttpServer()) + .delete('/feature-flags/feature-flag-1') + .set('Authorization', 'Bearer ' + apiKey) + .expect(200) + .expect((res) => { + expect(res.body).toHaveProperty('success', true); + }); + }); + }); +}); diff --git a/packages/db-service/prisma/migrations/20260906163254_add_feature_flag/migration.sql b/packages/db-service/prisma/migrations/20260906163254_add_feature_flag/migration.sql new file mode 100644 index 00000000..1106f368 --- /dev/null +++ b/packages/db-service/prisma/migrations/20260906163254_add_feature_flag/migration.sql @@ -0,0 +1,8 @@ +-- CreateTable +CREATE TABLE "FeatureFlag" ( + "id" TEXT NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT false, + "description" TEXT, + + CONSTRAINT "FeatureFlag_pkey" PRIMARY KEY ("id") +); diff --git a/packages/db-service/prisma/schema.prisma b/packages/db-service/prisma/schema.prisma index ada0428b..7a8a4a58 100644 --- a/packages/db-service/prisma/schema.prisma +++ b/packages/db-service/prisma/schema.prisma @@ -21,12 +21,12 @@ model User { } model Project { - id Int @id @default(autoincrement()) - name String @unique - apiKeys ApiKey[] - users User[] - emailService EmailServiceConfig[] - fileService FileServiceConfig[] + id Int @id @default(autoincrement()) + name String @unique + apiKeys ApiKey[] + users User[] + emailService EmailServiceConfig[] + fileService FileServiceConfig[] analyticsService AnalyticsServiceConfig[] } @@ -38,7 +38,7 @@ model ApiKey { projectId Int project Project @relation(fields: [projectId], references: [id]) environment String - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) } enum ApiScope { @@ -52,18 +52,19 @@ enum Role { } model AnalyticsServiceConfig { - id Int - environment String @default("test") - serverAnalyticsKey String @default("") - clientAnalyticsKey String @default("") - Project Project @relation(fields: [id], references: [id]) + id Int + environment String @default("test") + serverAnalyticsKey String @default("") + clientAnalyticsKey String @default("") + Project Project @relation(fields: [id], references: [id]) + @@id([id, environment]) } model EmailServiceConfig { id Int - environment String @default("test") - sendgridApiKey String @default("") + environment String @default("test") + sendgridApiKey String @default("") emails EmailServiceConfigAndSender[] domains EmailServiceConfigAndDomain[] Project Project @relation(fields: [id], references: [id]) @@ -111,16 +112,17 @@ model EmailDomain { } enum FileProviderType { - S3 - AZURE + S3 + AZURE } model FileServiceConfig { - id Int - environment String @default("test") + id Int + environment String @default("test") buckets FileServiceBucket[] Project Project @relation(fields: [id], references: [id]) FileServiceFile FileServiceFile[] + @@id([id, environment]) } @@ -165,3 +167,9 @@ model NewAccountRequest { projectName String? createdAt DateTime @default(now()) } + +model FeatureFlag { + id String @id + enabled Boolean @default(false) + description String? +} diff --git a/packages/db-service/src/app.module.ts b/packages/db-service/src/app.module.ts index 6ce22f40..55cb0939 100644 --- a/packages/db-service/src/app.module.ts +++ b/packages/db-service/src/app.module.ts @@ -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 { FeatureFlagModule } from './modules/feature_flag/feature_flag.module'; @Module({ imports: [ @@ -28,6 +29,7 @@ import { AnalyticsConfigModule } from './modules/analytics_config/analytics_conf FileConfigModule, FileProviderModule, AnalyticsConfigModule, + FeatureFlagModule, ], controllers: [AppController], providers: [AppService], diff --git a/packages/db-service/src/main.ts b/packages/db-service/src/main.ts index 3b2b0c5a..ad97f6c1 100644 --- a/packages/db-service/src/main.ts +++ b/packages/db-service/src/main.ts @@ -28,6 +28,8 @@ import { FileProviderProtoFile, AnalyticsConfigProto, AnalyticsConfigProtoFile, + FeatureFlagProto, + FeatureFlagProtoFile, } from 'juno-proto'; import { CustomRpcExceptionFilter } from './global-exception.filter'; @@ -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, + FeatureFlagProto.JUNO_FEATURE_FLAG_PACKAGE_NAME, ], protoPath: [ UserProtoFile, @@ -66,6 +69,7 @@ async function bootstrap() { FileConfigProtoFile, FileProviderProtoFile, AnalyticsConfigProtoFile, + FeatureFlagProtoFile, ], url: process.env.DB_SERVICE_ADDR, }, diff --git a/packages/db-service/src/modules/feature_flag/feature_flag.controller.ts b/packages/db-service/src/modules/feature_flag/feature_flag.controller.ts new file mode 100644 index 00000000..56aab858 --- /dev/null +++ b/packages/db-service/src/modules/feature_flag/feature_flag.controller.ts @@ -0,0 +1,83 @@ +import { status } from '@grpc/grpc-js'; +import { Controller } from '@nestjs/common'; +import { RpcException } from '@nestjs/microservices'; +import { FeatureFlagProto } from 'juno-proto'; +import { FeatureFlagServiceController } from 'juno-proto/dist/gen/feature_flag'; +import { FeatureFlagService } from './feature_flag.service'; + +@Controller() +@FeatureFlagProto.FeatureFlagServiceControllerMethods() +export class FeatureFlagController implements FeatureFlagServiceController { + constructor(private readonly featureFlagService: FeatureFlagService) {} + + async createFlag( + request: FeatureFlagProto.CreateFlagRequest, + ): Promise { + const flag = await this.featureFlagService.createFlag({ + id: request.id, + enabled: request.enabled, + description: request.description, + }); + + return { flag }; + } + + async getFlag( + request: FeatureFlagProto.GetFlagRequest, + ): Promise { + const flag = await this.featureFlagService.getFlag({ id: request.id }); + + if (!flag) { + throw new RpcException({ + code: status.NOT_FOUND, + message: `Feature flag "${request.id}" not found`, + }); + } + + return { flag }; + } + + async setFlag( + request: FeatureFlagProto.SetFlagRequest, + ): Promise { + const existing = await this.featureFlagService.getFlag({ + id: request.id, + }); + + if (!existing) { + throw new RpcException({ + code: status.NOT_FOUND, + message: `Feature flag "${request.id}" not found`, + }); + } + + const flag = await this.featureFlagService.setFlag( + { id: request.id }, + { + enabled: request.enabled, + description: request.description, + }, + ); + + return { flag }; + } + + async deleteFlag( + request: FeatureFlagProto.DeleteFlagRequest, + ): Promise { + const existing = await this.featureFlagService.getFlag({ + id: request.id, + }); + + if (!existing) { + throw new RpcException({ + code: status.NOT_FOUND, + message: `Feature flag "${request.id}" not found`, + }); + } + + await this.featureFlagService.deleteFlag({ id: request.id }); + + return { success: true }; + } +} diff --git a/packages/db-service/src/modules/feature_flag/feature_flag.module.ts b/packages/db-service/src/modules/feature_flag/feature_flag.module.ts new file mode 100644 index 00000000..323c44e0 --- /dev/null +++ b/packages/db-service/src/modules/feature_flag/feature_flag.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { FeatureFlagController } from './feature_flag.controller'; +import { FeatureFlagService } from './feature_flag.service'; +import { PrismaService } from 'src/prisma.service'; + +@Module({ + providers: [FeatureFlagService, PrismaService], + controllers: [FeatureFlagController], +}) +export class FeatureFlagModule {} diff --git a/packages/db-service/src/modules/feature_flag/feature_flag.service.ts b/packages/db-service/src/modules/feature_flag/feature_flag.service.ts new file mode 100644 index 00000000..270f31b3 --- /dev/null +++ b/packages/db-service/src/modules/feature_flag/feature_flag.service.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@nestjs/common'; +import { FeatureFlag, Prisma } from '@prisma/client'; +import { PrismaService } from 'src/prisma.service'; + +@Injectable() +export class FeatureFlagService { + constructor(private prisma: PrismaService) {} + + async createFlag(input: Prisma.FeatureFlagCreateInput): Promise { + return this.prisma.featureFlag.create({ data: input }); + } + + async getFlag( + where: Prisma.FeatureFlagWhereUniqueInput, + ): Promise { + return this.prisma.featureFlag.findUnique({ where }); + } + + async setFlag( + where: Prisma.FeatureFlagWhereUniqueInput, + update: Prisma.FeatureFlagUpdateInput, + ): Promise { + return this.prisma.featureFlag.update({ where, data: update }); + } + + async deleteFlag( + where: Prisma.FeatureFlagWhereUniqueInput, + ): Promise { + return this.prisma.featureFlag.delete({ where }); + } +} diff --git a/packages/db-service/test/feature_flag.e2e-spec.ts b/packages/db-service/test/feature_flag.e2e-spec.ts new file mode 100644 index 00000000..c56367e1 --- /dev/null +++ b/packages/db-service/test/feature_flag.e2e-spec.ts @@ -0,0 +1,203 @@ +import * as GRPC from '@grpc/grpc-js'; +import * as ProtoLoader from '@grpc/proto-loader'; +import { INestMicroservice } from '@nestjs/common'; +import { MicroserviceOptions, Transport } from '@nestjs/microservices'; +import { Test, TestingModule } from '@nestjs/testing'; +import { + FeatureFlagProto, + FeatureFlagProtoFile, + ResetProto, + ResetProtoFile, +} from 'juno-proto'; +import { AppModule } from '../src/app.module'; + +let app: INestMicroservice; + +jest.setTimeout(15000); + +async function initApp() { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + const app = moduleFixture.createNestMicroservice({ + transport: Transport.GRPC, + options: { + package: [ + FeatureFlagProto.JUNO_FEATURE_FLAG_PACKAGE_NAME, + ResetProto.JUNO_RESET_DB_PACKAGE_NAME, + ], + protoPath: [FeatureFlagProtoFile, ResetProtoFile], + url: process.env.DB_SERVICE_ADDR, + }, + }); + + await app.init(); + await app.listen(); + + return app; +} + +beforeAll(async () => { + app = await initApp(); + 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); + }); + }); + + app.close(); +}); + +beforeEach(async () => { + app = await initApp(); +}); + +afterEach(async () => { + await app.close(); +}); + +function tryCreateFlag(flagClient: any, id: string) { + return new Promise((resolve) => { + flagClient.createFlag( + { + id, + enabled: true, + description: 'seed flag', + }, + () => { + resolve(); + }, + ); + }); +} + +describe('Feature Flag Tests', () => { + let flagClient: any; + + beforeEach(() => { + const proto = ProtoLoader.loadSync([FeatureFlagProtoFile]) as any; + const protoGRPC = GRPC.loadPackageDefinition(proto) as any; + flagClient = new protoGRPC.juno.feature_flag.FeatureFlagService( + process.env.DB_SERVICE_ADDR, + GRPC.credentials.createInsecure(), + ); + }); + + it('creates a new feature flag', async () => { + const response: FeatureFlagProto.CreateFlagResponse = await new Promise( + (resolve, reject) => { + flagClient.createFlag( + { + id: 'feature-flag-1', + enabled: true, + description: 'toggles feature 1', + }, + (err, res) => { + if (err) { + reject(err); + } else { + resolve(res); + } + }, + ); + }, + ); + + expect(response.flag).toHaveProperty('id', 'feature-flag-1'); + expect(response.flag.enabled).toBe(true); + expect(response.flag.description).toBe('toggles feature 1'); + }); + + it('gets an existing feature flag', async () => { + await tryCreateFlag(flagClient, 'feature-flag-2'); + const response: FeatureFlagProto.GetFlagResponse = await new Promise( + (resolve, reject) => { + flagClient.getFlag({ id: 'feature-flag-2' }, (err, res) => { + if (err) { + reject(err); + } else { + resolve(res); + } + }); + }, + ); + + expect(response.flag).toHaveProperty('id', 'feature-flag-2'); + expect(response.flag.enabled).toBe(true); + }); + + it('getting a nonexistent feature flag', async () => { + await new Promise((resolve) => { + flagClient.getFlag({ id: 'feature-flag-100' }, (err) => { + expect(err.code).toBe(GRPC.status.NOT_FOUND); + resolve({}); + }); + }); + }); + + it('updates an existing feature flag', async () => { + await tryCreateFlag(flagClient, 'feature-flag-3'); + const response: FeatureFlagProto.SetFlagResponse = await new Promise( + (resolve, reject) => { + flagClient.setFlag( + { id: 'feature-flag-3', enabled: false }, + (err, res) => { + if (err) { + reject(err); + } else { + resolve(res); + } + }, + ); + }, + ); + + expect(response.flag).toHaveProperty('id', 'feature-flag-3'); + expect(response.flag.enabled).toBe(false); + }); + + it('updating a nonexistent feature flag', async () => { + await new Promise((resolve) => { + flagClient.setFlag({ id: 'feature-flag-1000', enabled: true }, (err) => { + expect(err.code).toBe(GRPC.status.NOT_FOUND); + resolve({}); + }); + }); + }); + + it('deletes an existing feature flag', async () => { + await tryCreateFlag(flagClient, 'feature-flag-4'); + const response: FeatureFlagProto.DeleteFlagResponse = await new Promise( + (resolve, reject) => { + flagClient.deleteFlag({ id: 'feature-flag-4' }, (err, res) => { + if (err) { + reject(err); + } else { + resolve(res); + } + }); + }, + ); + + expect(response.success).toBe(true); + }); + + it('deleting a nonexistent feature flag', async () => { + await new Promise((resolve) => { + flagClient.deleteFlag({ id: 'feature-flag-100' }, (err) => { + expect(err.code).toBe(GRPC.status.NOT_FOUND); + resolve({}); + }); + }); + }); +}); diff --git a/packages/proto/definitions/feature_flag.proto b/packages/proto/definitions/feature_flag.proto new file mode 100644 index 00000000..b4338e83 --- /dev/null +++ b/packages/proto/definitions/feature_flag.proto @@ -0,0 +1,53 @@ +syntax = "proto3"; + +package juno.feature_flag; + +service FeatureFlagService { + rpc createFlag(CreateFlagRequest) returns (CreateFlagResponse); + rpc getFlag(GetFlagRequest) returns (GetFlagResponse); + rpc setFlag(SetFlagRequest) returns (SetFlagResponse); + rpc deleteFlag(DeleteFlagRequest) returns (DeleteFlagResponse); +} + +message CreateFlagRequest { + string id = 1; + bool enabled = 2; + optional string description = 3; +} + +message GetFlagRequest { + string id = 1; +} + +message SetFlagRequest { + string id = 1; + bool enabled = 2; + optional string description = 3; + +} + +message DeleteFlagRequest { + string id = 1; +} + +message CreateFlagResponse { + FeatureFlag flag = 1; +} + +message GetFlagResponse { + FeatureFlag flag = 1; +} + +message SetFlagResponse { + FeatureFlag flag = 1; +} + +message DeleteFlagResponse { + bool success = 1; +} + +message FeatureFlag { + string id = 1; + bool enabled = 2; + optional string description = 3; +} \ No newline at end of file diff --git a/packages/proto/src/gen/analytics.ts b/packages/proto/src/gen/analytics.ts index e36db2f3..556a53e3 100644 --- a/packages/proto/src/gen/analytics.ts +++ b/packages/proto/src/gen/analytics.ts @@ -1,7 +1,7 @@ // Code generated by protoc-gen-ts_proto. DO NOT EDIT. // versions: // protoc-gen-ts_proto v1.181.2 -// protoc v5.29.3 +// protoc v4.25.3 // source: analytics.proto /* eslint-disable */ diff --git a/packages/proto/src/gen/analytics_config.ts b/packages/proto/src/gen/analytics_config.ts index 168d3ff4..77a98522 100644 --- a/packages/proto/src/gen/analytics_config.ts +++ b/packages/proto/src/gen/analytics_config.ts @@ -1,7 +1,7 @@ // Code generated by protoc-gen-ts_proto. DO NOT EDIT. // versions: // protoc-gen-ts_proto v1.181.2 -// protoc v5.29.3 +// protoc v4.25.3 // source: analytics_config.proto /* eslint-disable */ diff --git a/packages/proto/src/gen/api_key.ts b/packages/proto/src/gen/api_key.ts index e3d7e7a1..a3a6d3eb 100644 --- a/packages/proto/src/gen/api_key.ts +++ b/packages/proto/src/gen/api_key.ts @@ -1,7 +1,7 @@ // Code generated by protoc-gen-ts_proto. DO NOT EDIT. // versions: // protoc-gen-ts_proto v1.181.2 -// protoc v5.29.3 +// protoc v4.25.3 // source: api_key.proto /* eslint-disable */ diff --git a/packages/proto/src/gen/auth_common.ts b/packages/proto/src/gen/auth_common.ts index 412d9f81..fa399b04 100644 --- a/packages/proto/src/gen/auth_common.ts +++ b/packages/proto/src/gen/auth_common.ts @@ -1,7 +1,7 @@ // Code generated by protoc-gen-ts_proto. DO NOT EDIT. // versions: // protoc-gen-ts_proto v1.181.2 -// protoc v5.29.3 +// protoc v4.25.3 // source: auth_common.proto /* eslint-disable */ diff --git a/packages/proto/src/gen/common.ts b/packages/proto/src/gen/common.ts index d7b4aeff..9767bfff 100644 --- a/packages/proto/src/gen/common.ts +++ b/packages/proto/src/gen/common.ts @@ -1,7 +1,7 @@ // Code generated by protoc-gen-ts_proto. DO NOT EDIT. // versions: // protoc-gen-ts_proto v1.181.2 -// protoc v5.29.3 +// protoc v4.25.3 // source: common.proto /* eslint-disable */ diff --git a/packages/proto/src/gen/email.ts b/packages/proto/src/gen/email.ts index f7347925..69144c5b 100644 --- a/packages/proto/src/gen/email.ts +++ b/packages/proto/src/gen/email.ts @@ -1,7 +1,7 @@ // Code generated by protoc-gen-ts_proto. DO NOT EDIT. // versions: // protoc-gen-ts_proto v1.181.2 -// protoc v5.29.3 +// protoc v4.25.3 // source: email.proto /* eslint-disable */ diff --git a/packages/proto/src/gen/feature_flag.ts b/packages/proto/src/gen/feature_flag.ts new file mode 100644 index 00000000..98533367 --- /dev/null +++ b/packages/proto/src/gen/feature_flag.ts @@ -0,0 +1,125 @@ +// Code generated by protoc-gen-ts_proto. DO NOT EDIT. +// versions: +// protoc-gen-ts_proto v1.181.2 +// protoc v4.25.3 +// source: feature_flag.proto + +/* eslint-disable */ +import { GrpcMethod, GrpcStreamMethod } from '@nestjs/microservices'; +import { Observable } from 'rxjs'; + +export const protobufPackage = 'juno.feature_flag'; + +export interface CreateFlagRequest { + id: string; + enabled: boolean; + description?: string | undefined; +} + +export interface GetFlagRequest { + id: string; +} + +export interface SetFlagRequest { + id: string; + enabled: boolean; + description?: string | undefined; +} + +export interface DeleteFlagRequest { + id: string; +} + +export interface CreateFlagResponse { + flag: FeatureFlag | undefined; +} + +export interface GetFlagResponse { + flag: FeatureFlag | undefined; +} + +export interface SetFlagResponse { + flag: FeatureFlag | undefined; +} + +export interface DeleteFlagResponse { + success: boolean; +} + +export interface FeatureFlag { + id: string; + enabled: boolean; + description?: string | undefined; +} + +export const JUNO_FEATURE_FLAG_PACKAGE_NAME = 'juno.feature_flag'; + +export interface FeatureFlagServiceClient { + createFlag(request: CreateFlagRequest): Observable; + + getFlag(request: GetFlagRequest): Observable; + + setFlag(request: SetFlagRequest): Observable; + + deleteFlag(request: DeleteFlagRequest): Observable; +} + +export interface FeatureFlagServiceController { + createFlag( + request: CreateFlagRequest, + ): + | Promise + | Observable + | CreateFlagResponse; + + getFlag( + request: GetFlagRequest, + ): Promise | Observable | GetFlagResponse; + + setFlag( + request: SetFlagRequest, + ): Promise | Observable | SetFlagResponse; + + deleteFlag( + request: DeleteFlagRequest, + ): + | Promise + | Observable + | DeleteFlagResponse; +} + +export function FeatureFlagServiceControllerMethods() { + return function (constructor: Function) { + const grpcMethods: string[] = [ + 'createFlag', + 'getFlag', + 'setFlag', + 'deleteFlag', + ]; + for (const method of grpcMethods) { + const descriptor: any = Reflect.getOwnPropertyDescriptor( + constructor.prototype, + method, + ); + GrpcMethod('FeatureFlagService', method)( + constructor.prototype[method], + method, + descriptor, + ); + } + const grpcStreamMethods: string[] = []; + for (const method of grpcStreamMethods) { + const descriptor: any = Reflect.getOwnPropertyDescriptor( + constructor.prototype, + method, + ); + GrpcStreamMethod('FeatureFlagService', method)( + constructor.prototype[method], + method, + descriptor, + ); + } + }; +} + +export const FEATURE_FLAG_SERVICE_NAME = 'FeatureFlagService'; diff --git a/packages/proto/src/gen/file.ts b/packages/proto/src/gen/file.ts index ef7c1c52..94bc4b2c 100644 --- a/packages/proto/src/gen/file.ts +++ b/packages/proto/src/gen/file.ts @@ -1,7 +1,7 @@ // Code generated by protoc-gen-ts_proto. DO NOT EDIT. // versions: // protoc-gen-ts_proto v1.181.2 -// protoc v5.29.3 +// protoc v4.25.3 // source: file.proto /* eslint-disable */ diff --git a/packages/proto/src/gen/file_bucket.ts b/packages/proto/src/gen/file_bucket.ts index 8cda9c44..3bc2f315 100644 --- a/packages/proto/src/gen/file_bucket.ts +++ b/packages/proto/src/gen/file_bucket.ts @@ -1,7 +1,7 @@ // Code generated by protoc-gen-ts_proto. DO NOT EDIT. // versions: // protoc-gen-ts_proto v1.181.2 -// protoc v5.29.3 +// protoc v4.25.3 // source: file_bucket.proto /* eslint-disable */ diff --git a/packages/proto/src/gen/file_config.ts b/packages/proto/src/gen/file_config.ts index e277d32b..ed74c96a 100644 --- a/packages/proto/src/gen/file_config.ts +++ b/packages/proto/src/gen/file_config.ts @@ -1,7 +1,7 @@ // Code generated by protoc-gen-ts_proto. DO NOT EDIT. // versions: // protoc-gen-ts_proto v1.181.2 -// protoc v5.29.3 +// protoc v4.25.3 // source: file_config.proto /* eslint-disable */ diff --git a/packages/proto/src/gen/file_provider.ts b/packages/proto/src/gen/file_provider.ts index a7811cad..c21f810a 100644 --- a/packages/proto/src/gen/file_provider.ts +++ b/packages/proto/src/gen/file_provider.ts @@ -1,7 +1,7 @@ // Code generated by protoc-gen-ts_proto. DO NOT EDIT. // versions: // protoc-gen-ts_proto v1.181.2 -// protoc v5.29.3 +// protoc v4.25.3 // source: file_provider.proto /* eslint-disable */ diff --git a/packages/proto/src/gen/health.ts b/packages/proto/src/gen/health.ts index b9272ebc..aad68360 100644 --- a/packages/proto/src/gen/health.ts +++ b/packages/proto/src/gen/health.ts @@ -1,7 +1,7 @@ // Code generated by protoc-gen-ts_proto. DO NOT EDIT. // versions: // protoc-gen-ts_proto v1.181.2 -// protoc v5.29.3 +// protoc v4.25.3 // source: health.proto /* eslint-disable */ diff --git a/packages/proto/src/gen/identifiers.ts b/packages/proto/src/gen/identifiers.ts index 245265a0..19c8f247 100644 --- a/packages/proto/src/gen/identifiers.ts +++ b/packages/proto/src/gen/identifiers.ts @@ -1,7 +1,7 @@ // Code generated by protoc-gen-ts_proto. DO NOT EDIT. // versions: // protoc-gen-ts_proto v1.181.2 -// protoc v5.29.3 +// protoc v4.25.3 // source: identifiers.proto /* eslint-disable */ diff --git a/packages/proto/src/gen/jwt.ts b/packages/proto/src/gen/jwt.ts index 24e19bc6..019db9b4 100644 --- a/packages/proto/src/gen/jwt.ts +++ b/packages/proto/src/gen/jwt.ts @@ -1,7 +1,7 @@ // Code generated by protoc-gen-ts_proto. DO NOT EDIT. // versions: // protoc-gen-ts_proto v1.181.2 -// protoc v5.29.3 +// protoc v4.25.3 // source: jwt.proto /* eslint-disable */ diff --git a/packages/proto/src/gen/logging.ts b/packages/proto/src/gen/logging.ts index d659ae09..276a69c3 100644 --- a/packages/proto/src/gen/logging.ts +++ b/packages/proto/src/gen/logging.ts @@ -1,7 +1,7 @@ // Code generated by protoc-gen-ts_proto. DO NOT EDIT. // versions: // protoc-gen-ts_proto v1.181.2 -// protoc v5.29.3 +// protoc v4.25.3 // source: logging.proto /* eslint-disable */ diff --git a/packages/proto/src/gen/project.ts b/packages/proto/src/gen/project.ts index fd79b4ed..80d4916b 100644 --- a/packages/proto/src/gen/project.ts +++ b/packages/proto/src/gen/project.ts @@ -1,7 +1,7 @@ // Code generated by protoc-gen-ts_proto. DO NOT EDIT. // versions: // protoc-gen-ts_proto v1.181.2 -// protoc v5.29.3 +// protoc v4.25.3 // source: project.proto /* eslint-disable */ diff --git a/packages/proto/src/gen/reset_db.ts b/packages/proto/src/gen/reset_db.ts index 0b7b2359..a1f463ab 100644 --- a/packages/proto/src/gen/reset_db.ts +++ b/packages/proto/src/gen/reset_db.ts @@ -1,7 +1,7 @@ // Code generated by protoc-gen-ts_proto. DO NOT EDIT. // versions: // protoc-gen-ts_proto v1.181.2 -// protoc v5.29.3 +// protoc v4.25.3 // source: reset_db.proto /* eslint-disable */ diff --git a/packages/proto/src/gen/user.ts b/packages/proto/src/gen/user.ts index 49b4e907..399e9ac0 100644 --- a/packages/proto/src/gen/user.ts +++ b/packages/proto/src/gen/user.ts @@ -1,7 +1,7 @@ // Code generated by protoc-gen-ts_proto. DO NOT EDIT. // versions: // protoc-gen-ts_proto v1.181.2 -// protoc v5.29.3 +// protoc v4.25.3 // source: user.proto /* eslint-disable */ diff --git a/packages/proto/src/main.ts b/packages/proto/src/main.ts index cb6cf67f..40342a65 100644 --- a/packages/proto/src/main.ts +++ b/packages/proto/src/main.ts @@ -15,6 +15,7 @@ export * as FileProviderProto from './gen/file_provider'; export * as CommonProto from './gen/common'; export * as AnalyticsProto from './gen/analytics'; export * as AnalyticsConfigProto from './gen/analytics_config'; +export * as FeatureFlagProto from './gen/feature_flag'; import { join } from 'path'; @@ -41,3 +42,4 @@ export const AnalyticsProtoFile = getProtoFilePath('analytics.proto'); export const AnalyticsConfigProtoFile = getProtoFilePath( 'analytics_config.proto', ); +export const FeatureFlagProtoFile = getProtoFilePath('feature_flag.proto'); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a53801c8..6743c066 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3659,6 +3659,7 @@ packages: { integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==, } + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@webassemblyjs/ast@1.12.1': resolution: @@ -5440,7 +5441,7 @@ packages: { integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==, } - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me globals@11.12.0: resolution: @@ -7613,7 +7614,7 @@ packages: integrity: sha512-6WTxW1EB6yCxV5VFOIPQruWGHqc3yI7hEmZK6h+pyk69Lk/Ut7rLUY6W/ONF2MjBuGjvmMiIpsrVJ2vjrHlslA==, } engines: { node: '>=6.4.0 <13 || >=14' } - deprecated: Please upgrade to v9.0.0+ as we have fixed a public vulnerability with formidable dependency. Note that v9.0.0+ requires Node.js v14.18.0+. See https://github.com/ladjs/superagent/pull/1800 for insight. This project is supported and maintained by the team at Forward Email @ https://forwardemail.net + deprecated: Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net superagent@9.0.2: resolution: @@ -7707,7 +7708,7 @@ packages: integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==, } engines: { node: '>=10' } - deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me terser-webpack-plugin@5.3.10: resolution: @@ -8058,6 +8059,7 @@ packages: { integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==, } + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true v8-compile-cache-lib@3.0.1: