Skip to content
Snippets Groups Projects
Verified Commit 09060493 authored by Konstantin Tsabolov's avatar Konstantin Tsabolov
Browse files

feat: implement schema manager base functionality

parent 1cfca288
No related branches found
No related tags found
No related merge requests found
Showing
with 679 additions and 5 deletions
......@@ -31,10 +31,9 @@ export default {
: ['text-summary', 'html'],
coveragePathIgnorePatterns: [
'<rootDir>/node_modules/',
'<rootDir>/test/',
'<rootDir>/coverage/',
'<rootDir>/dist/',
'<rootDir>/**/test',
'__tests__',
'@types',
'.dto.(t|j)s',
'.enum.ts',
......
......@@ -32,6 +32,7 @@
"@nestjs/platform-express": "^10.2.8",
"@nestjs/swagger": "^7.1.16",
"@nestjs/terminus": "^10.1.1",
"@ocm/shared": "workspace:*",
"axios": "^1.6.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
......
import type { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { Application } from '../application.js';
describe('Application', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleFixture = await Test.createTestingModule({
imports: [Application],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
afterAll(async () => {
await app.close();
});
it('should be defined', () => {
expect(app).toBeDefined();
});
});
import type { ConfigType } from '@nestjs/config';
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { NATS_CLIENT } from './common/constants.js';
import { httpConfig } from './config/http.config.js';
import { natsConfig } from './config/nats.config.js';
import { ssiConfig } from './config/ssi.config.js';
import { validationSchema } from './config/validation.js';
import { HealthModule } from './health/health.module.js';
import { SchemasModule } from './schemas/schemas.module.js';
@Module({
imports: [
......@@ -20,7 +25,25 @@ import { HealthModule } from './health/health.module.js';
abortEarly: true,
},
}),
ClientsModule.registerAsync({
isGlobal: true,
clients: [
{
name: NATS_CLIENT,
inject: [natsConfig.KEY],
useFactory: (config: ConfigType<typeof natsConfig>) => ({
transport: Transport.NATS,
options: {
url: config.url as string,
},
}),
},
],
}),
HealthModule,
SchemasModule,
],
})
export default class AppModule {}
export class Application {}
export const SERVICE_NAME = 'SCHEMA_MANAGER_SERVICE';
export const NATS_CLIENT = Symbol('NATS_CLIENT');
/* c8 ignore start */
import type { MicroserviceOptions } from '@nestjs/microservices';
import { VersioningType } from '@nestjs/common';
......@@ -6,9 +7,9 @@ import { NestFactory } from '@nestjs/core';
import { Transport } from '@nestjs/microservices';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import AppModule from './app.module.js';
import { Application } from './application.js';
const app = await NestFactory.create(AppModule);
const app = await NestFactory.create(Application);
const configService = app.get(ConfigService);
app.enableCors();
......@@ -36,3 +37,4 @@ SwaggerModule.setup('/swagger', app, document);
await app.startAllMicroservices();
await app.listen(configService.get('PORT') || 3000);
/* c8 ignore stop */
import type { RegisterSchemaPayload } from '../dto/register-schema.dto.js';
import type { TestingModule } from '@nestjs/testing';
import type {
EventAnonCredsSchemasGetAll,
EventAnonCredsSchemasGetById,
EventAnonCredsSchemasRegister,
} from '@ocm/shared';
import { Test } from '@nestjs/testing';
import { Subject, of, takeUntil } from 'rxjs';
import { NATS_CLIENT } from '../../common/constants.js';
import { SchemasController } from '../schemas.controller.js';
import { SchemasService } from '../schemas.service.js';
describe('SchemasController', () => {
const natsClientMock = {};
let controller: SchemasController;
let service: SchemasService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [SchemasController],
providers: [
{ provide: NATS_CLIENT, useValue: natsClientMock },
SchemasService,
],
}).compile();
controller = module.get<SchemasController>(SchemasController);
service = module.get<SchemasService>(SchemasService);
});
describe('getAll', () => {
it('should return a list of schemas', (done) => {
const unsubscribe$ = new Subject<void>();
const tenantId = 'exampleTenantId';
const expectedResult: EventAnonCredsSchemasGetAll['data'] = [];
jest.spyOn(service, 'getAll').mockReturnValue(of(expectedResult));
controller
.getAll({ tenantId })
.pipe(takeUntil(unsubscribe$))
.subscribe((result) => {
expect(result).toStrictEqual(expectedResult);
unsubscribe$.next();
unsubscribe$.complete();
done();
});
});
});
describe('getById', () => {
it('should return a schema by id', (done) => {
const unsubscribe$ = new Subject<void>();
const schemaId = 'exampleSchemaId';
const tenantId = 'exampleTenantId';
const expectedResult: EventAnonCredsSchemasGetById['data'] = {
attrNames: ['exampleAttributeName'],
issuerId: 'exampleIssuerDid',
name: 'exampleName',
version: '1.0.0',
};
jest.spyOn(service, 'getById').mockReturnValue(of(expectedResult));
controller
.getById({ schemaId }, { tenantId })
.pipe(takeUntil(unsubscribe$))
.subscribe((result) => {
expect(result).toStrictEqual(expectedResult);
unsubscribe$.next();
unsubscribe$.complete();
done();
});
});
it('should throw a NotFoundException if the service returned null', (done) => {
const unsubscribe$ = new Subject<void>();
const schemaId = 'exampleSchemaId';
const tenantId = 'exampleTenantId';
jest.spyOn(service, 'getById').mockReturnValue(of(null));
controller
.getById({ schemaId }, { tenantId })
.pipe(takeUntil(unsubscribe$))
.subscribe({
error: (error) => {
expect(error.status).toBe(404);
expect(error.message).toBe(`Schema with id ${schemaId} not found`);
unsubscribe$.next();
unsubscribe$.complete();
done();
},
});
});
});
describe('register', () => {
it('should register a new schema', (done) => {
const unsubscribe$ = new Subject<void>();
const tenantId = 'exampleTenantId';
const payload: RegisterSchemaPayload = {
attributeNames: ['exampleAttributeName'],
issuerDid: 'exampleIssuerDid',
name: 'exampleName',
version: '1.0.0',
};
const expectedResult: EventAnonCredsSchemasRegister['data'] = {
attrNames: payload.attributeNames,
issuerId: payload.issuerDid,
name: payload.name,
version: payload.version,
};
jest.spyOn(service, 'register').mockReturnValue(of(expectedResult));
controller
.register({ tenantId }, payload)
.pipe(takeUntil(unsubscribe$))
.subscribe((result) => {
expect(result).toStrictEqual(expectedResult);
unsubscribe$.next();
unsubscribe$.complete();
done();
});
});
});
});
import { ClientsModule } from '@nestjs/microservices';
import { Test } from '@nestjs/testing';
import { NATS_CLIENT } from '../../common/constants.js';
import { SchemasController } from '../schemas.controller.js';
import { SchemasModule } from '../schemas.module.js';
import { SchemasService } from '../schemas.service.js';
describe('Schemas Module', () => {
let schemasController: SchemasController;
let schemasService: SchemasService;
beforeEach(async () => {
const moduleRef = await Test.createTestingModule({
imports: [
ClientsModule.registerAsync({
isGlobal: true,
clients: [{ name: NATS_CLIENT, useFactory: () => ({}) }],
}),
SchemasModule,
],
}).compile();
schemasController = moduleRef.get<SchemasController>(SchemasController);
schemasService = moduleRef.get<SchemasService>(SchemasService);
});
it('should be defined', () => {
expect(schemasController).toBeDefined();
expect(schemasController).toBeInstanceOf(SchemasController);
expect(schemasService).toBeDefined();
expect(schemasService).toBeInstanceOf(SchemasService);
});
});
import type { TestingModule } from '@nestjs/testing';
import { Test } from '@nestjs/testing';
import {
EventAnonCredsSchemasGetAll,
EventAnonCredsSchemasGetById,
EventAnonCredsSchemasRegister,
} from '@ocm/shared';
import { Subject, of, takeUntil } from 'rxjs';
import { NATS_CLIENT } from '../../common/constants.js';
import { SchemasService } from '../schemas.service.js';
describe('SchemasService', () => {
let service: SchemasService;
const natsClientMock = { send: jest.fn() };
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{ provide: NATS_CLIENT, useValue: natsClientMock },
SchemasService,
],
}).compile();
service = module.get<SchemasService>(SchemasService);
jest.resetAllMocks();
});
describe('getAll', () => {
it('should return the data from NATS client', (done) => {
const unsubscribe$ = new Subject<void>();
const payload = {
tenantId: 'mocked tenantId',
endpoint: EventAnonCredsSchemasGetAll.token,
};
const expectedResult: EventAnonCredsSchemasGetAll['data'] = [];
natsClientMock.send.mockReturnValueOnce(
of(new EventAnonCredsSchemasGetAll([], payload.tenantId)),
);
service
.getAll(payload)
.pipe(takeUntil(unsubscribe$))
.subscribe((result) => {
expect(natsClientMock.send).toHaveBeenCalledWith(
{ endpoint: EventAnonCredsSchemasGetAll.token },
payload,
);
expect(result).toStrictEqual(expectedResult);
unsubscribe$.next();
unsubscribe$.complete();
done();
});
});
});
describe('getById', () => {
it('should return the data from NATS client', (done) => {
const unsubscribe$ = new Subject<void>();
const payload = {
tenantId: 'mocked tenantId',
schemaId: 'mocked id',
};
const expectedResult: EventAnonCredsSchemasGetById['data'] = {
issuerId: 'mocked issuerDid',
name: 'mocked name',
version: '1.0.0',
attrNames: ['mocked attribute1', 'mocked attribute2'],
};
natsClientMock.send.mockReturnValueOnce(
of(new EventAnonCredsSchemasGetById(expectedResult, payload.tenantId)),
);
service
.getById(payload)
.pipe(takeUntil(unsubscribe$))
.subscribe((result) => {
expect(natsClientMock.send).toHaveBeenCalledWith(
{ endpoint: EventAnonCredsSchemasGetById.token },
payload,
);
expect(result).toStrictEqual(expectedResult);
unsubscribe$.next();
unsubscribe$.complete();
done();
});
});
});
describe('register', () => {
it('should return the data from NATS client', (done) => {
const unsubscribe$ = new Subject<void>();
const payload = {
tenantId: 'mocked tenantId',
issuerDid: 'mocked issuerDid',
name: 'mocked name',
version: '1.0.0',
attributeNames: ['mocked attribute1', 'mocked attribute2'],
};
const expectedResult: EventAnonCredsSchemasRegister['data'] = {
issuerId: 'mocked issuerDid',
name: 'mocked name',
version: '1.0.0',
attrNames: ['mocked attribute1', 'mocked attribute2'],
};
natsClientMock.send.mockReturnValueOnce(
of(new EventAnonCredsSchemasRegister(expectedResult, payload.tenantId)),
);
service
.register(payload)
.pipe(takeUntil(unsubscribe$))
.subscribe((result) => {
expect(natsClientMock.send).toHaveBeenCalledWith(
{ endpoint: EventAnonCredsSchemasRegister.token },
payload,
);
expect(result).toStrictEqual(expectedResult);
unsubscribe$.next();
unsubscribe$.complete();
done();
});
});
});
});
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
export class GetByIdParams {
@IsString()
@IsNotEmpty()
@ApiProperty({ description: 'The schema ID to retrieve', format: 'string' })
public schemaId: string;
}
import { ApiProperty } from '@nestjs/swagger';
import { IsArray, IsNotEmpty, IsSemVer, IsString } from 'class-validator';
export class RegisterSchemaPayload {
@IsString()
@IsNotEmpty()
@ApiProperty()
public issuerDid: string;
@IsString()
@IsNotEmpty()
@ApiProperty()
public name: string;
@IsString()
@IsNotEmpty()
@IsSemVer()
@ApiProperty()
public version: string;
@IsArray()
@IsString({ each: true })
@IsNotEmpty({ each: true })
@ApiProperty()
public attributeNames: Array<string>;
}
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
export class TenantIdParam {
@IsString()
@IsNotEmpty()
@ApiProperty({
description: 'The tenant ID to use for the request',
format: 'string',
})
public tenantId: string;
}
import type {
EventAnonCredsSchemasGetAll,
EventAnonCredsSchemasGetById,
EventAnonCredsSchemasRegister,
} from '@ocm/shared';
import {
Body,
Controller,
Get,
HttpStatus,
NotFoundException,
Param,
Post,
Query,
UsePipes,
ValidationPipe,
Version,
} from '@nestjs/common';
import {
ApiBody,
ApiOperation,
ApiParam,
ApiQuery,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { Observable, of, switchMap } from 'rxjs';
import { GetByIdParams } from './dto/get-by-id.dto.js';
import { RegisterSchemaPayload } from './dto/register-schema.dto.js';
import { TenantIdParam } from './dto/tenant-id.dto.js';
import { SchemasService } from './schemas.service.js';
@Controller('schemas')
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
@ApiTags('Schemas')
export class SchemasController {
public constructor(private readonly schemasService: SchemasService) {}
@Version('1')
@Get()
@ApiOperation({
summary: 'Fetch a list of schemas',
description: 'This call provides a list of schemas for a given tenant',
})
@ApiQuery({ name: 'tenantId', required: true })
@ApiResponse({
status: HttpStatus.OK,
description: 'Schemas fetched successfully',
content: {
// TBD
},
})
@ApiResponse({
status: HttpStatus.NOT_FOUND,
description: 'Tenant not found',
content: {
// TBD
},
})
@ApiResponse({
status: HttpStatus.INTERNAL_SERVER_ERROR,
description: 'Internal server error',
content: {
// TBD
},
})
public getAll(
@Query() { tenantId }: TenantIdParam,
): Observable<EventAnonCredsSchemasGetAll['data']> {
return this.schemasService.getAll({
tenantId,
});
}
@Version('1')
@Get(':schemaId')
@ApiOperation({
summary: 'Fetch a schema by id',
description:
'This call allows you to retrieve schema data for a given tenant by specifying the `schemaId`.',
})
@ApiParam({ name: 'schemaId', required: true })
@ApiQuery({ name: 'tenantId', required: true })
@ApiResponse({
status: HttpStatus.OK,
description: 'Schema fetched successfully',
content: {
// TBD
},
})
@ApiResponse({
status: HttpStatus.NOT_FOUND,
description: 'Tenant not found',
content: {
// TBD
},
})
@ApiResponse({
status: HttpStatus.NOT_FOUND,
description: 'Schema not found',
content: {
// TBD
},
})
@ApiResponse({
status: HttpStatus.INTERNAL_SERVER_ERROR,
description: 'Internal server error',
content: {
// TBD
},
})
public getById(
@Param() { schemaId }: GetByIdParams,
@Query() { tenantId }: TenantIdParam,
): Observable<EventAnonCredsSchemasGetById['data']> {
return this.schemasService
.getById({
tenantId,
schemaId,
})
.pipe(
switchMap((schema) => {
if (schema === null) {
throw new NotFoundException(`Schema with id ${schemaId} not found`);
}
return of(schema);
}),
);
}
@Version('1')
@Post()
@ApiOperation({
summary: 'Register a new schema',
description:
'This call provides the capability to create new schema on ledger by name, author, version, schema attributes and type. Later this schema can be used to issue new credential definition. This call returns an information about created schema.',
})
@ApiQuery({ name: 'tenantId', required: true })
@ApiBody({ type: RegisterSchemaPayload })
@ApiResponse({
status: HttpStatus.CREATED,
description: 'Schema registered successfully',
content: {
'application/json': {},
},
})
@ApiResponse({
status: HttpStatus.NOT_FOUND,
description: 'Tenant not found',
content: {
'application/json': {},
},
})
@ApiResponse({
status: HttpStatus.BAD_REQUEST,
description: 'All fields are required for schema registration',
content: {
'application/json': {},
},
})
@ApiResponse({
status: HttpStatus.CONFLICT,
description: 'Schema already exists',
content: {
'application/json': {},
},
})
@ApiResponse({
status: HttpStatus.INTERNAL_SERVER_ERROR,
description: 'Internal server error',
content: {
'application/json': {},
},
})
public register(
@Query() { tenantId }: TenantIdParam,
@Body() payload: RegisterSchemaPayload,
): Observable<EventAnonCredsSchemasRegister['data']> {
return this.schemasService.register({
...payload,
tenantId,
});
}
}
import { Module } from '@nestjs/common';
import { SchemasController } from './schemas.controller.js';
import { SchemasService } from './schemas.service.js';
@Module({
controllers: [SchemasController],
providers: [SchemasService],
})
export class SchemasModule {}
import type {
EventAnonCredsSchemasGetAllInput,
EventAnonCredsSchemasGetByIdInput,
EventAnonCredsSchemasRegisterInput,
} from '@ocm/shared';
import { Inject, Injectable } from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices';
import {
EventAnonCredsSchemasGetAll,
EventAnonCredsSchemasGetById,
EventAnonCredsSchemasRegister,
} from '@ocm/shared';
import { map, type Observable } from 'rxjs';
import { NATS_CLIENT } from '../common/constants.js';
@Injectable()
export class SchemasService {
public constructor(
@Inject(NATS_CLIENT) private readonly natsClient: ClientProxy,
) {}
public getAll(
payload: EventAnonCredsSchemasGetAllInput,
): Observable<EventAnonCredsSchemasGetAll['data']> {
const pattern = { endpoint: EventAnonCredsSchemasGetAll.token };
return this.natsClient
.send<EventAnonCredsSchemasGetAll, EventAnonCredsSchemasGetAllInput>(
pattern,
payload,
)
.pipe(map((result) => result.data));
}
public getById(
payload: EventAnonCredsSchemasGetByIdInput,
): Observable<EventAnonCredsSchemasGetById['data']> {
const pattern = { endpoint: EventAnonCredsSchemasGetById.token };
return this.natsClient
.send<EventAnonCredsSchemasGetById, EventAnonCredsSchemasGetByIdInput>(
pattern,
payload,
)
.pipe(map((result) => result.data));
}
public register(
payload: EventAnonCredsSchemasRegisterInput,
): Observable<EventAnonCredsSchemasRegister['data']> {
const pattern = { endpoint: EventAnonCredsSchemasRegister.token };
return this.natsClient
.send<EventAnonCredsSchemasRegister, EventAnonCredsSchemasRegisterInput>(
pattern,
payload,
)
.pipe(map((result) => result.data));
}
}
......@@ -603,6 +603,9 @@ importers:
'@nestjs/terminus':
specifier: ^10.1.1
version: 10.1.1(@nestjs/axios@3.0.1)(@nestjs/common@10.2.10)(@nestjs/core@10.2.10)(@nestjs/microservices@10.2.10)(reflect-metadata@0.1.13)(rxjs@7.8.1)
'@ocm/shared':
specifier: workspace:*
version: link:../shared
axios:
specifier: ^1.6.2
version: 1.6.2
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment