feat: infra config module add with get-update-reset functionality
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "InfraConfig" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"value" TEXT,
|
||||
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedOn" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "InfraConfig_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "InfraConfig_name_key" ON "InfraConfig"("name");
|
||||
@@ -209,3 +209,12 @@ enum TeamMemberRole {
|
||||
VIEWER
|
||||
EDITOR
|
||||
}
|
||||
|
||||
model InfraConfig {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
value String?
|
||||
active Boolean @default(true) // Use case: Let's say, Admin wants to disable Google SSO, but doesn't want to delete the config
|
||||
createdOn DateTime @default(now()) @db.Timestamp(3)
|
||||
updatedOn DateTime @updatedAt @db.Timestamp(3)
|
||||
}
|
||||
|
||||
@@ -21,11 +21,14 @@ import { COOKIES_NOT_FOUND } from './errors';
|
||||
import { ThrottlerModule } from '@nestjs/throttler';
|
||||
import { AppController } from './app.controller';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { InfraConfigModule } from './infra-config/infra-config.module';
|
||||
import { loadInfraConfiguration } from './infra-config/helper';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: [async () => loadInfraConfiguration()],
|
||||
}),
|
||||
GraphQLModule.forRootAsync<ApolloDriverConfig>({
|
||||
driver: ApolloDriver,
|
||||
@@ -90,6 +93,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
TeamInvitationModule,
|
||||
UserCollectionModule,
|
||||
ShortcodeModule,
|
||||
InfraConfigModule,
|
||||
],
|
||||
providers: [GQLComplexityPlugin],
|
||||
controllers: [AppController],
|
||||
|
||||
@@ -644,3 +644,23 @@ export const SHORTCODE_INVALID_PROPERTIES_JSON =
|
||||
*/
|
||||
export const SHORTCODE_PROPERTIES_NOT_FOUND =
|
||||
'shortcode/properties_not_found' as const;
|
||||
|
||||
/**
|
||||
* Infra Config not found
|
||||
* (InfraConfigService)
|
||||
*/
|
||||
export const INFRA_CONFIG_NOT_FOUND = 'infra_config/not_found' as const;
|
||||
|
||||
/**
|
||||
* Infra Config not listed for onModuleInit creation
|
||||
* (InfraConfigService)
|
||||
*/
|
||||
export const INFRA_CONFIG_NOT_LISTED =
|
||||
'infra_config/properly_not_listed' as const;
|
||||
|
||||
/**
|
||||
* Error message for when the database table does not exist
|
||||
* (InfraConfigService)
|
||||
*/
|
||||
export const DATABASE_TABLE_NOT_EXIST =
|
||||
'Database migration not performed. Please check the FAQ for assistance: https://docs.hoppscotch.io/support/faq';
|
||||
|
||||
30
packages/hoppscotch-backend/src/infra-config/helper.ts
Normal file
30
packages/hoppscotch-backend/src/infra-config/helper.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { PrismaService } from 'src/prisma/prisma.service';
|
||||
|
||||
/**
|
||||
* Load environment variables from the database and set them in the process
|
||||
*
|
||||
* @Description Fetch the 'infra_config' table from the database and return it as an object
|
||||
* (ConfigModule will set the environment variables in the process)
|
||||
*/
|
||||
export async function loadInfraConfiguration() {
|
||||
const prisma = new PrismaService();
|
||||
|
||||
const infraConfigs = await prisma.infraConfig.findMany();
|
||||
|
||||
let environmentObject: Record<string, any> = {};
|
||||
infraConfigs.forEach((infraConfig) => {
|
||||
environmentObject[infraConfig.name] = infraConfig.value;
|
||||
});
|
||||
|
||||
return environmentObject;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the app after 5 seconds
|
||||
* (Docker will re-start the app)
|
||||
*/
|
||||
export function stopApp() {
|
||||
setTimeout(() => {
|
||||
process.exit();
|
||||
}, 5000);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType()
|
||||
export class InfraConfig {
|
||||
@Field({
|
||||
description: 'Infra Config Name',
|
||||
})
|
||||
name: string;
|
||||
|
||||
@Field({
|
||||
description: 'Infra Config Value',
|
||||
})
|
||||
value: string;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { InfraConfigService } from './infra-config.service';
|
||||
import { PrismaModule } from 'src/prisma/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
providers: [InfraConfigService],
|
||||
exports: [InfraConfigService],
|
||||
})
|
||||
export class InfraConfigModule {}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { mockDeep, mockReset } from 'jest-mock-extended';
|
||||
import { PrismaService } from 'src/prisma/prisma.service';
|
||||
import { InfraConfigService } from './infra-config.service';
|
||||
import { InfraConfigEnum } from 'src/types/InfraConfig';
|
||||
import { INFRA_CONFIG_NOT_FOUND } from 'src/errors';
|
||||
|
||||
const mockPrisma = mockDeep<PrismaService>();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const infraConfigService = new InfraConfigService(mockPrisma);
|
||||
|
||||
beforeEach(() => {
|
||||
mockReset(mockPrisma);
|
||||
});
|
||||
|
||||
describe('InfraConfigService', () => {
|
||||
describe('update', () => {
|
||||
it('should update the infra config', async () => {
|
||||
const name = InfraConfigEnum.EULAConfig;
|
||||
const value = 'true';
|
||||
|
||||
mockPrisma.infraConfig.update.mockResolvedValueOnce({
|
||||
id: '',
|
||||
name,
|
||||
value,
|
||||
active: true,
|
||||
createdOn: new Date(),
|
||||
updatedOn: new Date(),
|
||||
});
|
||||
const result = await infraConfigService.update(name, value);
|
||||
expect(result).toEqualRight({ name, value });
|
||||
});
|
||||
|
||||
it('should pass correct params to prisma update', async () => {
|
||||
const name = InfraConfigEnum.EULAConfig;
|
||||
const value = 'true';
|
||||
|
||||
await infraConfigService.update(name, value);
|
||||
|
||||
expect(mockPrisma.infraConfig.update).toHaveBeenCalledWith({
|
||||
where: { name },
|
||||
data: { value },
|
||||
});
|
||||
expect(mockPrisma.infraConfig.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should throw an error if the infra config does not exist', async () => {
|
||||
const name = InfraConfigEnum.EULAConfig;
|
||||
const value = 'true';
|
||||
|
||||
mockPrisma.infraConfig.update.mockRejectedValueOnce('null');
|
||||
|
||||
const result = await infraConfigService.update(name, value);
|
||||
expect(result).toEqualLeft(INFRA_CONFIG_NOT_FOUND);
|
||||
});
|
||||
});
|
||||
|
||||
describe('get', () => {
|
||||
it('should get the infra config', async () => {
|
||||
const name = InfraConfigEnum.EULAConfig;
|
||||
const value = 'true';
|
||||
|
||||
mockPrisma.infraConfig.findUniqueOrThrow.mockResolvedValueOnce({
|
||||
id: '',
|
||||
name,
|
||||
value,
|
||||
active: true,
|
||||
createdOn: new Date(),
|
||||
updatedOn: new Date(),
|
||||
});
|
||||
const result = await infraConfigService.get(name);
|
||||
expect(result).toEqualRight({ name, value });
|
||||
});
|
||||
|
||||
it('should pass correct params to prisma findUnique', async () => {
|
||||
const name = InfraConfigEnum.EULAConfig;
|
||||
|
||||
await infraConfigService.get(name);
|
||||
|
||||
expect(mockPrisma.infraConfig.findUniqueOrThrow).toHaveBeenCalledWith({
|
||||
where: { name },
|
||||
});
|
||||
expect(mockPrisma.infraConfig.findUniqueOrThrow).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should throw an error if the infra config does not exist', async () => {
|
||||
const name = InfraConfigEnum.EULAConfig;
|
||||
|
||||
mockPrisma.infraConfig.findUniqueOrThrow.mockRejectedValueOnce('null');
|
||||
|
||||
const result = await infraConfigService.get(name);
|
||||
expect(result).toEqualLeft(INFRA_CONFIG_NOT_FOUND);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { InfraConfig } from './infra-config.model';
|
||||
import { PrismaService } from 'src/prisma/prisma.service';
|
||||
import { InfraConfig as DBInfraConfig } from '@prisma/client';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import { InfraConfigEnum } from 'src/types/InfraConfig';
|
||||
import {
|
||||
DATABASE_TABLE_NOT_EXIST,
|
||||
INFRA_CONFIG_NOT_FOUND,
|
||||
INFRA_CONFIG_NOT_LISTED,
|
||||
} from 'src/errors';
|
||||
import { throwErr } from 'src/utils';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { stopApp } from './helper';
|
||||
|
||||
@Injectable()
|
||||
export class InfraConfigService implements OnModuleInit {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.initializeInfraConfigTable();
|
||||
}
|
||||
|
||||
getDefaultInfraConfigs(): InfraConfig[] {
|
||||
// Prepare rows for 'infra_config' table with default values for each 'name'
|
||||
const infraConfigDefaultObjs: InfraConfig[] = [
|
||||
{
|
||||
name: InfraConfigEnum.MAILER_SMTP_URL,
|
||||
value: process.env.MAILER_SMTP_URL,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.MAILER_ADDRESS_FROM,
|
||||
value: process.env.MAILER_ADDRESS_FROM,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.GOOGLE_CLIENT_ID,
|
||||
value: process.env.GOOGLE_CLIENT_ID,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.GOOGLE_CLIENT_SECRET,
|
||||
value: process.env.GOOGLE_CLIENT_SECRET,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.GITHUB_CLIENT_ID,
|
||||
value: process.env.GITHUB_CLIENT_ID,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.GITHUB_CLIENT_SECRET,
|
||||
value: process.env.GITHUB_CLIENT_SECRET,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.MICROSOFT_CLIENT_ID,
|
||||
value: process.env.MICROSOFT_CLIENT_ID,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.MICROSOFT_CLIENT_SECRET,
|
||||
value: process.env.MICROSOFT_CLIENT_SECRET,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.SAML_ISSUER,
|
||||
value: process.env.SAML_ISSUER,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.SAML_AUDIENCE,
|
||||
value: process.env.SAML_AUDIENCE,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.SAML_CALLBACK_URL,
|
||||
value: process.env.SAML_CALLBACK_URL,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.SAML_CERT,
|
||||
value: process.env.SAML_CERT,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.SAML_ENTRY_POINT,
|
||||
value: process.env.SAML_ENTRY_POINT,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS,
|
||||
value: process.env.VITE_ALLOWED_AUTH_PROVIDERS,
|
||||
},
|
||||
];
|
||||
|
||||
return infraConfigDefaultObjs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the 'infra_config' table with values from .env
|
||||
*/
|
||||
async initializeInfraConfigTable() {
|
||||
try {
|
||||
// Get all the 'names' for 'infra_config' table
|
||||
const enumValues = Object.values(InfraConfigEnum);
|
||||
|
||||
// Prepare rows for 'infra_config' table with default values for each 'name'
|
||||
const infraConfigDefaultObjs = this.getDefaultInfraConfigs();
|
||||
|
||||
// Check if all the 'names' are listed in the default values
|
||||
if (enumValues.length !== infraConfigDefaultObjs.length) {
|
||||
throw new Error(INFRA_CONFIG_NOT_LISTED);
|
||||
}
|
||||
|
||||
// Eliminate the rows (from 'infraConfigDefaultObjs') that are already present in the database table
|
||||
const dbInfraConfigs = await this.prisma.infraConfig.findMany();
|
||||
const propsToInsert = infraConfigDefaultObjs.filter(
|
||||
(p) => !dbInfraConfigs.find((e) => e.name === p.name),
|
||||
);
|
||||
|
||||
if (propsToInsert.length > 0) {
|
||||
await this.prisma.infraConfig.createMany({ data: propsToInsert });
|
||||
stopApp();
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code === 'P2021') {
|
||||
// Prisma error code for 'Table does not exist'
|
||||
throwErr(DATABASE_TABLE_NOT_EXIST);
|
||||
} else {
|
||||
throwErr(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Typecast a database InfraConfig to a InfraConfig model
|
||||
* @param dbInfraConfig database InfraConfig
|
||||
* @returns InfraConfig model
|
||||
*/
|
||||
cast(dbInfraConfig: DBInfraConfig) {
|
||||
return <InfraConfig>{
|
||||
name: dbInfraConfig.name,
|
||||
value: dbInfraConfig.value,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update InfraConfig by name
|
||||
* @param name Name of the InfraConfig
|
||||
* @param value Value of the InfraConfig
|
||||
* @returns InfraConfig model
|
||||
*/
|
||||
async update(name: InfraConfigEnum, value: string) {
|
||||
try {
|
||||
const infraConfig = await this.prisma.infraConfig.update({
|
||||
where: { name },
|
||||
data: { value },
|
||||
});
|
||||
|
||||
stopApp();
|
||||
|
||||
return E.right(this.cast(infraConfig));
|
||||
} catch (e) {
|
||||
return E.left(INFRA_CONFIG_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get InfraConfig by name
|
||||
* @param name Name of the InfraConfig
|
||||
* @returns InfraConfig model
|
||||
*/
|
||||
async get(name: InfraConfigEnum) {
|
||||
try {
|
||||
const infraConfig = await this.prisma.infraConfig.findUniqueOrThrow({
|
||||
where: { name },
|
||||
});
|
||||
|
||||
return E.right(this.cast(infraConfig));
|
||||
} catch (e) {
|
||||
return E.left(INFRA_CONFIG_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all the InfraConfigs to their default values (from .env)
|
||||
*/
|
||||
async reset() {
|
||||
const infraConfigDefaultObjs = this.getDefaultInfraConfigs();
|
||||
|
||||
await this.prisma.infraConfig.deleteMany({
|
||||
where: { name: { in: infraConfigDefaultObjs.map((p) => p.name) } },
|
||||
});
|
||||
|
||||
await this.prisma.infraConfig.createMany({ data: infraConfigDefaultObjs });
|
||||
|
||||
stopApp();
|
||||
}
|
||||
}
|
||||
21
packages/hoppscotch-backend/src/types/InfraConfig.ts
Normal file
21
packages/hoppscotch-backend/src/types/InfraConfig.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export enum InfraConfigEnum {
|
||||
MAILER_SMTP_URL = 'MAILER_SMTP_URL',
|
||||
MAILER_ADDRESS_FROM = 'MAILER_ADDRESS_FROM',
|
||||
|
||||
GOOGLE_CLIENT_ID = 'GOOGLE_CLIENT_ID',
|
||||
GOOGLE_CLIENT_SECRET = 'GOOGLE_CLIENT_SECRET',
|
||||
|
||||
GITHUB_CLIENT_ID = 'GITHUB_CLIENT_ID',
|
||||
GITHUB_CLIENT_SECRET = 'GITHUB_CLIENT_SECRET',
|
||||
|
||||
MICROSOFT_CLIENT_ID = 'MICROSOFT_CLIENT_ID',
|
||||
MICROSOFT_CLIENT_SECRET = 'MICROSOFT_CLIENT_SECRET',
|
||||
|
||||
SAML_ISSUER = 'SAML_ISSUER',
|
||||
SAML_AUDIENCE = 'SAML_AUDIENCE',
|
||||
SAML_CALLBACK_URL = 'SAML_CALLBACK_URL',
|
||||
SAML_CERT = 'SAML_CERT',
|
||||
SAML_ENTRY_POINT = 'SAML_ENTRY_POINT',
|
||||
|
||||
VITE_ALLOWED_AUTH_PROVIDERS = 'VITE_ALLOWED_AUTH_PROVIDERS',
|
||||
}
|
||||
Reference in New Issue
Block a user