Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: #686 External API for FSP Tracker #688

Merged
merged 10 commits into from
Sep 6, 2024
12 changes: 7 additions & 5 deletions api/src/app/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,24 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ScheduleModule } from '@nestjs/schedule';
import { TypeOrmModule } from '@nestjs/typeorm';

// Core Modules
import { AttachmentModule } from './modules/attachment/attachment.module';
import { InteractionModule } from './modules/interaction/interaction.module';
import { DistrictModule } from './modules/district/district.module';
import { ForestClientModule } from './modules/forest-client/forest-client.module';
import { ProjectModule } from './modules/project/project.module';
import { InteractionModule } from './modules/interaction/interaction.module';
import { ProjectAuthModule } from './modules/project/project-auth.module';
import { ProjectModule } from './modules/project/project.module';
import { PublicCommentModule } from './modules/public-comment/public-comment.module';
import { SubmissionModule } from './modules/submission/submission.module';
import { SpatialFeatureModule } from './modules/spatial-feature/spatial-feature.module';
import { SubmissionModule } from './modules/submission/submission.module';

// Other Modules
import { ExternalModule } from '@src/app/modules/external/external.module';
import { LoggerModule } from 'nestjs-pino';
import { SecurityModule } from '../core/security/security.module';
import { AppConfigModule } from './modules/app-config/app-config.module';
import { AppConfigService } from './modules/app-config/app-config.provider';
import { SecurityModule } from '../core/security/security.module'

function getLogLevel():string {
return process.env.LOG_LEVEL || 'info';
Expand Down Expand Up @@ -66,6 +67,7 @@ function getLogLevel():string {
PublicCommentModule,
SubmissionModule,
SpatialFeatureModule,
ExternalModule
],
})

Expand Down
13 changes: 13 additions & 0 deletions api/src/app/modules/external/external.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { ProjectsByFspExternalModule } from '@src/app/modules/external/projects-by-fsp/projects-by-fsp.module';

/**
* "External" module provides external facing APIs for other system to
* interface with FOM.
*/
@Module({
imports: [
ProjectsByFspExternalModule
]
})
export class ExternalModule {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { ArgumentMetadata, BadRequestException, Controller, Get, HttpStatus, Injectable, PipeTransform, Query } from '@nestjs/common';
import { ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
import { ProjectByFspResponse as ProjectsByFspResponse } from '@src/app/modules/external/projects-by-fsp/projects-by-fsp.dto';
import { ProjectsByFspService } from '@src/app/modules/external/projects-by-fsp/projects-by-fsp.service';
import { PinoLogger } from 'nestjs-pino';

// Custom pipe transformer for controller parameter conversion.
@Injectable()
export class PositiveIntPipe implements PipeTransform<number, number> {
transform(value: any, metadata: ArgumentMetadata) {

if(/^\d+$/.test(value)) {
const intValue = parseInt(value);
if ( intValue > 0) {
return value;
}
}

throw new BadRequestException('Value must be positive integer.');
}
}

@ApiTags("external")
@Controller("external")
export class ProjectsByFspController {
constructor(
private readonly service: ProjectsByFspService,
private readonly _logger: PinoLogger) {
}

@Get("fom-by-fsp")
@ApiQuery({ name: 'fspId', required: true})
@ApiResponse({ status: HttpStatus.OK, type: [ProjectsByFspResponse] })
async findByFsp(
@Query('fspId', PositiveIntPipe) fspId: number
): Promise<ProjectsByFspResponse[]> {
return this.service.findByFspId(fspId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { ForestClientResponse } from "@src/app/modules/forest-client/forest-client.dto";

export class ProjectByFspResponse {

@ApiProperty()
fomId: number;

@ApiProperty()
name: string;

@ApiProperty()
fspId: number;

@ApiPropertyOptional()
forestClient: ForestClientResponse;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ProjectsByFspController } from '@src/app/modules/external/projects-by-fsp/projects-by-fsp.controller';
import { ProjectsByFspService } from '@src/app/modules/external/projects-by-fsp/projects-by-fsp.service';
import { ForestClientModule } from '@src/app/modules/forest-client/forest-client.module';
import { Project } from '@src/app/modules/project/project.entity';
import { ProjectModule } from '@src/app/modules/project/project.module';

@Module({
imports: [
TypeOrmModule.forFeature([Project]),
ProjectModule,
ForestClientModule
],
controllers: [ProjectsByFspController],
providers: [ProjectsByFspService]
})
export class ProjectsByFspExternalModule {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { Test, TestingModule } from "@nestjs/testing";
import { getRepositoryToken } from "@nestjs/typeorm";
import { ProjectByFspResponse } from "@src/app/modules/external/projects-by-fsp/projects-by-fsp.dto";
import { ProjectsByFspService } from "@src/app/modules/external/projects-by-fsp/projects-by-fsp.service";
import { ForestClientService } from "@src/app/modules/forest-client/forest-client.service";
import { Project } from "@src/app/modules/project/project.entity";
import { RecursivePartial } from "@src/core/utils";
import { PinoLogger } from "nestjs-pino";
import { DataSource, Repository } from "typeorm";

describe('ProjectsByFspService', () => {
let service: ProjectsByFspService;
let forestClientService: ForestClientService;
let repository: Repository<Project>;
let createQueryBuilderMock: any;

// service and dependencies setup.
beforeAll(async () => {
const moduleRef: TestingModule = await Test.createTestingModule({
providers: provideDependencyMock()
}).compile();

forestClientService = moduleRef.get<ForestClientService>(ForestClientService);
service = moduleRef.get<ProjectsByFspService>(ProjectsByFspService);
repository = moduleRef.get(getRepositoryToken(Project));

createQueryBuilderMock = {
leftJoinAndSelect: () => createQueryBuilderMock,
where: () => createQueryBuilderMock,
andWhere: () => createQueryBuilderMock,
orWhere: () => createQueryBuilderMock,
addOrderBy: () => createQueryBuilderMock,
limit: () => createQueryBuilderMock,
getMany: () => [], // default empty
};

});

// This is prerequisite to make sure services/repository are setup for testing.
it('Services/repository for testing should be defined', () => {
expect(service).toBeDefined();
expect(forestClientService).toBeDefined();
expect(repository).toBeDefined();
});

describe('findByFspId', () => {
it('returns empty array when no fspId param', async () => {
expect(await service.findByFspId(null)).toEqual([]);
});

it('returns empty array when query builder result is empty.', async () => {
// use default createQueryBuilderMock
const createQueryBuilderSpy = jest.spyOn(repository, 'createQueryBuilder').mockImplementation(() => createQueryBuilderMock);
const fspIdWithNoFom = 999;
expect(await service.findByFspId(fspIdWithNoFom)).toEqual([]);
expect(createQueryBuilderSpy).toHaveBeenCalled();
});

it('returns correct result when query builder finds records.', async () => {
const foundProjects = getSimpleProjectResponseData();
createQueryBuilderMock.getMany = jest.fn().mockReturnValue(foundProjects);
const createQueryBuilderSpy = jest.spyOn(repository, 'createQueryBuilder').mockImplementation(() => createQueryBuilderMock);
const forestClientServiceConvertEntitySpy = jest.spyOn(forestClientService, 'convertEntity');
const fspIdWithFom = 11;
const result = await service.findByFspId(fspIdWithFom);
expect(result.length).toEqual(getSimpleProjectResponseData().length);
expect(result[0]).toBeInstanceOf(ProjectByFspResponse)
expect(createQueryBuilderSpy).toHaveBeenCalled();
expect(forestClientServiceConvertEntitySpy).toHaveBeenCalled();
expect(result[0].fspId).toEqual(foundProjects[0].fspId)
});
});

});

export class ProjectRepositoryFake {
public createQueryBuilder(): void {
// This is intentional for empty body.
}
}

function provideDependencyMock(): Array<any> {
const dependencyMock =
[ ProjectsByFspService,
{
provide: getRepositoryToken(Project),
useClass: ProjectRepositoryFake
},
{
provide: PinoLogger,
useValue: {
info: jest.fn((x) => x),
debug: jest.fn((x) => x),
setContext: jest.fn((x) => x),
}
},
{
provide: ForestClientService,
useValue: {
convertEntity: jest.fn()
}
},
{
provide: DataSource,
useValue: {
getRepository: jest.fn()
}
}
];
return dependencyMock;
}

function getSimpleProjectResponseData(): RecursivePartial<Project>[] {
const data = [
{
"id": 1,
"name": "Project #1",
"fspId": 11,
"forestClient": {
"id": "00001012",
"name": "CLIENT #1"
}
},
{
"id": 2,
"name": "Project #2",
"fspId": 11,
"forestClient": {
"id": "00001015",
"name": "CLIENT #2"
}
}
]
return data;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { ProjectByFspResponse } from "@api-modules/external/projects-by-fsp/projects-by-fsp.dto";
import { Project } from "@api-modules/project/project.entity";
import { DataService } from "@core";
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { ForestClientService } from "@src/app/modules/forest-client/forest-client.service";
import { ProjectFindCriteria } from "@src/app/modules/project/project.service";
import { PinoLogger } from "nestjs-pino";
import { Repository } from "typeorm";
import _ = require('lodash');

@Injectable()
export class ProjectsByFspService extends DataService<Project, Repository<Project>, ProjectByFspResponse> {

constructor(
@InjectRepository(Project)
repository: Repository<Project>,
private forestClientService: ForestClientService,
logger: PinoLogger
) {
super(repository, new Project(), logger);
}

async findByFspId(fspId: number):Promise<ProjectByFspResponse[]> {
if (_.isNil(fspId)) {
return []
}
const findCriteria: ProjectFindCriteria = new ProjectFindCriteria();
findCriteria.fspId = fspId;
this.logger.debug('Find criteria: %o', findCriteria);

const query = this.repository.createQueryBuilder("p")
.leftJoinAndSelect("p.forestClient", "forestClient")
.addOrderBy('p.project_id', 'DESC');
findCriteria.applyFindCriteria(query);
query.limit(2500); // Can't use take(). Limit # of results to avoid system strain.

const queryResults:Project[] = await query.getMany();
if (queryResults && queryResults.length > 0) {
this.logger.debug(`${queryResults.length} project(s) found.`);
return queryResults.map(project => this.convertEntity(project));
}
this.logger.debug('No result found.');
return [];
}

convertEntity(entity: Project): ProjectByFspResponse {
const response = new ProjectByFspResponse();
response.fomId = entity.id
response.name = entity.name
response.fspId = entity.fspId;
if (entity.forestClient != null) {
response.forestClient = this.forestClientService.convertEntity(entity.forestClient);
}
return response;
}

}
5 changes: 5 additions & 0 deletions api/src/core/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,8 @@ export const flatDeep = (arr, d = 1) => {
return d > 0 ? arr.reduce((acc, val) => acc.concat(Array.isArray(val) ? flatDeep(val, d - 1) : val), [])
: arr.slice();
};

// Partial type with nested properties also as Partial.
export type RecursivePartial<T> = {
[P in keyof T]?: RecursivePartial<T[P]>;
};
basilv marked this conversation as resolved.
Show resolved Hide resolved