diff --git a/apps/credential-manager/src/application.ts b/apps/credential-manager/src/application.ts index fb1bd0ec1b2bcb2625aeebddd01820807c7c12ab..c0ad214c9e168df74c7ff3f31a15253f0cbafa39 100644 --- a/apps/credential-manager/src/application.ts +++ b/apps/credential-manager/src/application.ts @@ -3,12 +3,15 @@ import type { ConfigType } from '@nestjs/config'; import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { RouterModule } from '@nestjs/core'; +import { ClientsModule, Transport } from '@nestjs/microservices'; import { HealthModule } from '@ocm/shared'; +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 { CredentialOffersModule } from './credential-offers/credential-offers.module.js'; @Module({ imports: [ @@ -24,6 +27,22 @@ import { validationSchema } from './config/validation.js'; }, }), + 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.registerAsync({ inject: [natsConfig.KEY], useFactory: (config: ConfigType<typeof natsConfig>) => { @@ -39,7 +58,12 @@ import { validationSchema } from './config/validation.js'; }, }), - RouterModule.register([{ module: HealthModule, path: '/health' }]), + CredentialOffersModule, + + RouterModule.register([ + { module: HealthModule, path: '/health' }, + { module: CredentialOffersModule, path: '/credential-offers' }, + ]), ], }) export class Application {} diff --git a/apps/credential-manager/src/common/constants.ts b/apps/credential-manager/src/common/constants.ts index bad6a5fab788fa4a4f9e2533c6d0ec1ffe93b782..d8529df95a8d19e68b5760a932fc3da4ff75e5e7 100644 --- a/apps/credential-manager/src/common/constants.ts +++ b/apps/credential-manager/src/common/constants.ts @@ -1 +1,3 @@ export const SERVICE_NAME = 'CREDENTIAL_MANAGER_SERVICE'; + +export const NATS_CLIENT = Symbol('NATS_CLIENT'); diff --git a/apps/credential-manager/src/credential-offers/__tests__/credential-offers.controller.spec.ts b/apps/credential-manager/src/credential-offers/__tests__/credential-offers.controller.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..99f6dba70d9335978be3e0a53c9754e592ffb603 --- /dev/null +++ b/apps/credential-manager/src/credential-offers/__tests__/credential-offers.controller.spec.ts @@ -0,0 +1,88 @@ +import type { TestingModule } from '@nestjs/testing'; +import type { + EventAnonCredsCredentialOfferGetAll, + EventAnonCredsCredentialOfferGetById, +} from '@ocm/shared'; + +import { Test } from '@nestjs/testing'; +import { Subject, of, takeUntil } from 'rxjs'; + +import { NATS_CLIENT } from '../../common/constants.js'; +import { CredentialOffersController } from '../credential-offers.controller.js'; +import { CredentialOffersService } from '../credential-offers.service.js'; + +describe('CredentialOffersController', () => { + const natsClientMock = {}; + + let controller: CredentialOffersController; + let service: CredentialOffersService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [CredentialOffersController], + providers: [ + { provide: NATS_CLIENT, useValue: natsClientMock }, + CredentialOffersService, + ], + }).compile(); + + controller = module.get<CredentialOffersController>( + CredentialOffersController, + ); + service = module.get<CredentialOffersService>(CredentialOffersService); + }); + + describe('find', () => { + it('should return a list of credential offers', (done) => { + const unsubscribe$ = new Subject<void>(); + const tenantId = 'exampleTenantId'; + const expectedResult: EventAnonCredsCredentialOfferGetAll['data'] = []; + + jest + .spyOn(service, 'findCredentialOffers') + .mockReturnValueOnce(of(expectedResult)); + + controller + .find({ tenantId }) + .pipe(takeUntil(unsubscribe$)) + .subscribe((result) => { + expect(result).toStrictEqual(expectedResult); + + unsubscribe$.next(); + unsubscribe$.complete(); + + done(); + }); + }); + }); + + describe('getById', () => { + it('should return a credential offer', (done) => { + const unsubscribe$ = new Subject<void>(); + const tenantId = 'exampleTenantId'; + const credentialOfferId = 'exampleCredentialOfferId'; + const expectedResult: EventAnonCredsCredentialOfferGetById['data'] = { + cred_def_id: 'exampleCredDefId', + key_correctness_proof: {}, + nonce: 'exampleNonce', + schema_id: 'exampleSchemaId', + }; + + jest + .spyOn(service, 'getCredentialOfferById') + .mockReturnValueOnce(of(expectedResult)); + + controller + .getById({ credentialOfferId }, { tenantId }) + .pipe(takeUntil(unsubscribe$)) + .subscribe((result) => { + expect(result).toStrictEqual(expectedResult); + + unsubscribe$.next(); + unsubscribe$.complete(); + + done(); + }); + }); + }); +}); diff --git a/apps/credential-manager/src/credential-offers/__tests__/credential-offers.module.spec.ts b/apps/credential-manager/src/credential-offers/__tests__/credential-offers.module.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..470ed9e6dfe6f10c8d52ef350659803cf387af93 --- /dev/null +++ b/apps/credential-manager/src/credential-offers/__tests__/credential-offers.module.spec.ts @@ -0,0 +1,41 @@ +import { ClientsModule } from '@nestjs/microservices'; +import { Test } from '@nestjs/testing'; + +import { NATS_CLIENT } from '../../common/constants.js'; +import { CredentialOffersController } from '../credential-offers.controller.js'; +import { CredentialOffersModule } from '../credential-offers.module.js'; +import { CredentialOffersService } from '../credential-offers.service.js'; + +describe('CredentialOffersModule', () => { + let credentialOffersController: CredentialOffersController; + let credentialOffersService: CredentialOffersService; + + beforeEach(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [ + ClientsModule.registerAsync({ + isGlobal: true, + clients: [{ name: NATS_CLIENT, useFactory: () => ({}) }], + }), + CredentialOffersModule, + ], + }).compile(); + + credentialOffersController = moduleRef.get<CredentialOffersController>( + CredentialOffersController, + ); + credentialOffersService = moduleRef.get<CredentialOffersService>( + CredentialOffersService, + ); + }); + + it('should be defined', () => { + expect(credentialOffersController).toBeDefined(); + expect(credentialOffersController).toBeInstanceOf( + CredentialOffersController, + ); + + expect(credentialOffersService).toBeDefined(); + expect(credentialOffersService).toBeInstanceOf(CredentialOffersService); + }); +}); diff --git a/apps/credential-manager/src/credential-offers/__tests__/credential-offers.service.spec.ts b/apps/credential-manager/src/credential-offers/__tests__/credential-offers.service.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..d568df68c66fb269a33d79f80df960f2e5a3df0d --- /dev/null +++ b/apps/credential-manager/src/credential-offers/__tests__/credential-offers.service.spec.ts @@ -0,0 +1,91 @@ +import type { TestingModule } from '@nestjs/testing'; + +import { Test } from '@nestjs/testing'; +import { + EventAnonCredsCredentialOfferGetAll, + EventAnonCredsCredentialOfferGetById, +} from '@ocm/shared'; +import { Subject, of, takeUntil } from 'rxjs'; + +import { NATS_CLIENT } from '../../common/constants.js'; +import { CredentialOffersService } from '../credential-offers.service.js'; + +describe('CredentialOffersService', () => { + const natsClientMock = { send: jest.fn() }; + let service: CredentialOffersService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { provide: NATS_CLIENT, useValue: natsClientMock }, + CredentialOffersService, + ], + }).compile(); + + service = module.get<CredentialOffersService>(CredentialOffersService); + }); + + describe('findCredentialOffers', () => { + it('should call natsClient.send with the correct arguments', (done) => { + const unsubscribe$ = new Subject<void>(); + const tenantId = 'tenantId'; + const expectedResult: EventAnonCredsCredentialOfferGetAll['data'] = []; + + natsClientMock.send.mockReturnValueOnce( + of(new EventAnonCredsCredentialOfferGetAll(expectedResult, tenantId)), + ); + + service + .findCredentialOffers(tenantId) + .pipe(takeUntil(unsubscribe$)) + .subscribe((result) => { + expect(natsClientMock.send).toHaveBeenCalledWith( + EventAnonCredsCredentialOfferGetAll.token, + { tenantId }, + ); + + expect(result).toStrictEqual(expectedResult); + + unsubscribe$.next(); + unsubscribe$.complete(); + + done(); + }); + }); + }); + + describe('getCredentialOfferById', () => { + it('should call natsClient.send with the correct arguments', (done) => { + const unsubscribe$ = new Subject<void>(); + const tenantId = 'tenantId'; + const credentialOfferId = 'id'; + const expectedResult: EventAnonCredsCredentialOfferGetById['data'] = { + cred_def_id: 'exampleCredDefId', + key_correctness_proof: {}, + nonce: 'exampleNonce', + schema_id: 'exampleSchemaId', + }; + + natsClientMock.send.mockReturnValueOnce( + of(new EventAnonCredsCredentialOfferGetById(expectedResult, tenantId)), + ); + + service + .getCredentialOfferById(tenantId, credentialOfferId) + .pipe(takeUntil(unsubscribe$)) + .subscribe((result) => { + expect(natsClientMock.send).toHaveBeenCalledWith( + EventAnonCredsCredentialOfferGetById.token, + { tenantId, credentialOfferId }, + ); + + expect(result).toStrictEqual(expectedResult); + + unsubscribe$.next(); + unsubscribe$.complete(); + + done(); + }); + }); + }); +}); diff --git a/apps/credential-manager/src/credential-offers/credential-offers.controller.ts b/apps/credential-manager/src/credential-offers/credential-offers.controller.ts new file mode 100644 index 0000000000000000000000000000000000000000..882d08ad48ca87f1b624f51a2ebfe7f3f55269c0 --- /dev/null +++ b/apps/credential-manager/src/credential-offers/credential-offers.controller.ts @@ -0,0 +1,154 @@ +import { + Controller, + Get, + HttpStatus, + Param, + Query, + UseInterceptors, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { MultitenancyParams, ResponseFormatInterceptor } from '@ocm/shared'; + +import { CredentialOffersService } from './credential-offers.service.js'; +import { GetByIdParams } from './dto/get-by-id.dto.js'; + +@Controller() +@UsePipes(new ValidationPipe({ transform: true, whitelist: true })) +@UseInterceptors(ResponseFormatInterceptor) +@ApiTags('Credential Offers') +export class CredentialOffersController { + public constructor(private readonly service: CredentialOffersService) {} + + @Get() + @ApiOperation({ + summary: 'Fetch a list of credential offers', + description: + 'This call provides a list of credential offers for a given tenant', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Credential offers fetched successfully', + content: { + 'application/json': { + schema: {}, + examples: { + 'Credential offers fetched successfully': { + value: { + statusCode: 200, + message: 'Credential offers fetched successfully', + data: [ + { + id: '71b784a3', + }, + ], + }, + }, + 'Tenant not found': { + value: { + statusCode: 404, + message: 'Tenant not found', + data: null, + }, + }, + }, + }, + }, + }) + @ApiResponse({ + status: HttpStatus.INTERNAL_SERVER_ERROR, + description: 'Something went wrong', + content: { + 'application/json': { + schema: {}, + examples: { + 'Something went wrong': { + value: { + statusCode: 500, + message: 'Something went wrong', + error: 'Internal Server Error', + }, + }, + }, + }, + }, + }) + public find(@Query() { tenantId }: MultitenancyParams) { + return this.service.findCredentialOffers(tenantId); + } + + @Get(':credentialOfferId') + @ApiOperation({ + summary: 'Fetch a credential offer by ID', + description: 'This call provides a credential offer for a given ID', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Credential offer fetched successfully', + content: { + 'application/json': { + schema: {}, + examples: { + 'Credential offer fetched successfully': { + value: { + statusCode: 200, + message: 'Credential offer fetched successfully', + data: { + id: '71b784a3', + }, + }, + }, + }, + }, + }, + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + content: { + 'application/json': { + schema: {}, + examples: { + 'Credential offer not found': { + value: { + statusCode: 404, + message: 'Credential offer not found', + data: null, + }, + }, + 'Tenant not found': { + value: { + statusCode: 404, + message: 'Tenant not found', + data: null, + }, + }, + }, + }, + }, + }) + @ApiResponse({ + status: HttpStatus.INTERNAL_SERVER_ERROR, + description: 'Something went wrong', + content: { + 'application/json': { + schema: {}, + examples: { + 'Something went wrong': { + value: { + statusCode: 500, + message: 'Something went wrong', + error: 'Internal Server Error', + }, + }, + }, + }, + }, + }) + public getById( + @Param() { credentialOfferId }: GetByIdParams, + @Query() { tenantId }: MultitenancyParams, + ) { + return this.service.getCredentialOfferById(tenantId, credentialOfferId); + } +} diff --git a/apps/credential-manager/src/credential-offers/credential-offers.module.ts b/apps/credential-manager/src/credential-offers/credential-offers.module.ts new file mode 100644 index 0000000000000000000000000000000000000000..2b42e4cadc82f4e286fbc0d30cedfc11729e9d1f --- /dev/null +++ b/apps/credential-manager/src/credential-offers/credential-offers.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; + +import { CredentialOffersController } from './credential-offers.controller.js'; +import { CredentialOffersService } from './credential-offers.service.js'; + +@Module({ + providers: [CredentialOffersService], + controllers: [CredentialOffersController], +}) +export class CredentialOffersModule {} diff --git a/apps/credential-manager/src/credential-offers/credential-offers.service.ts b/apps/credential-manager/src/credential-offers/credential-offers.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..30c3ff6903b58a8134f532607515d3f7b9f094f2 --- /dev/null +++ b/apps/credential-manager/src/credential-offers/credential-offers.service.ts @@ -0,0 +1,42 @@ +import type { + EventAnonCredsCredentialOfferGetAllInput, + EventAnonCredsCredentialOfferGetByIdInput, +} from '@ocm/shared'; + +import { Inject, Injectable } from '@nestjs/common'; +import { ClientProxy } from '@nestjs/microservices'; +import { + EventAnonCredsCredentialOfferGetAll, + EventAnonCredsCredentialOfferGetById, +} from '@ocm/shared'; +import { map } from 'rxjs'; + +import { NATS_CLIENT } from '../common/constants.js'; + +@Injectable() +export class CredentialOffersService { + public constructor( + @Inject(NATS_CLIENT) private readonly natsClient: ClientProxy, + ) {} + + public findCredentialOffers(tenantId: string) { + return this.natsClient + .send< + EventAnonCredsCredentialOfferGetAll, + EventAnonCredsCredentialOfferGetAllInput + >(EventAnonCredsCredentialOfferGetAll.token, { tenantId }) + .pipe(map(({ data }) => data)); + } + + public getCredentialOfferById(tenantId: string, credentialOfferId: string) { + return this.natsClient + .send< + EventAnonCredsCredentialOfferGetById, + EventAnonCredsCredentialOfferGetByIdInput + >(EventAnonCredsCredentialOfferGetById.token, { + tenantId, + credentialOfferId, + }) + .pipe(map(({ data }) => data)); + } +} diff --git a/apps/credential-manager/src/credential-offers/dto/get-by-id.dto.ts b/apps/credential-manager/src/credential-offers/dto/get-by-id.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..26cdc1b010ed640619e70fdc347c9128d7d667fa --- /dev/null +++ b/apps/credential-manager/src/credential-offers/dto/get-by-id.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; + +export class GetByIdParams { + @IsString() + @IsNotEmpty() + @ApiProperty({ + description: 'The credential offer ID to retrieve', + format: 'string', + }) + public credentialOfferId: string; +}