Skip to content

Commit

Permalink
answered the questions; add route to upload file; added docFactory; a…
Browse files Browse the repository at this point in the history
…dded dotenv (for local dev);
  • Loading branch information
geka-evk committed Aug 26, 2023
1 parent 2cb338d commit 544103d
Show file tree
Hide file tree
Showing 15 changed files with 172 additions and 75 deletions.
5 changes: 3 additions & 2 deletions .env
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
HTTP_PORT=3000

#DB_HOST=postgres # for running in docker-compose
DB_HOST=localhost
#DB_HOST=localhost # for running locally
DB_HOST=postgres
DB_PORT=5432
DB_USERNAME=curex
DB_PASSWORD=dbpassword
DB_NAME=curex-db
DB_SYNCHRONIZE=true # todo: add more controll on this env var (on prod)

UPLOAD_FILE_MAX_SIZE_BITES=123000
58 changes: 42 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@

## Description
A test tasks for AYA (parser)
`curex-app` is test task for AYA to parse file with indents, and import parsed data into PostgreSQL.

[File format example](imports/import.txt)

The logic for parsing a file is in `./src/db/file-importer`

## Installation

Expand All @@ -12,20 +16,20 @@ $ npm install

```bash
# import data from /imports/import.txt into Postgres DB
npm run start:import
$ npm run start:import

# watch mode
$ npm run start:dev
# run curex-app (with DB and adminer)
$ docker-compose up

# production mode
$ npm run start:prod
# run server locally
$ npm run start:dev
```

## Env vars (.env)

```bash
HTTP_PORT=3000
DB_HOST=localhost
DB_HOST=postgres
DB_PORT=5432
DB_USERNAME=curex
DB_PASSWORD=dbpassword
Expand All @@ -34,15 +38,37 @@ DB_SYNCHRONIZE=true
```


## Test
## Answers

1. _How to change the code to support different file format versions?_


For handling different file formats I'd suggest creating separate classes, which implement `IDocument`,
and `FileImporter` will use (see method _makeJsonFromText_) needed instances of `IDocument` through `documentFactory`.
Version of the format could be identified dynamically (based on content), or passed as param to the factory.
`DbImportService` is responsible for actual storing data in DB, and doesn't know anything about file format
---

2. _How will the import system change if in the future we need to get this data from a web API?_


I don't see a necessity to change import system in case getting the file content through API.
Reading file from disk OR receiving it from API are just "transport details" in scope of parsing.
`FileImporter` doesn't know where from content to parse is coming. It has 2 methods:

- _makeJsonFromText( content )_ - "Main" method for parsing raw file content, and convert it in JSON structure, which holds
information about domain entities to be stored in DB;

- _importFromFile( fileName? )_ - Which accepts fileName (if not passed, used default name), read file from disk, and call
the makeJsonFromText() method

Current implementation has 3 methods to import data to DB:

- using script: `npm run start:import`, which reads file from disk, parse it and store in DB (without spinning up http-server)
- `GET localhost:3000/db/import?filename=import.txt`, which accepts optional _filename_, and does the same as script above
- `POST localhost:3000/db/import-file`, which accepts fileContent as _form-data_

https://i.imgur.com/yKvgJcy.png

```bash
# unit tests
$ npm run test

# e2e tests
$ npm run test:e2e

# test coverage
$ npm run test:cov
```
4 changes: 2 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ services:
build:
context: .
dockerfile: Dockerfile
target: prod
target: dev
restart: always
# command: npm run start:dev
command: npm run start:dev
ports:
- "127.0.0.1:3000:${HTTP_PORT}"
env_file: .env
Expand Down
6 changes: 6 additions & 0 deletions imports/import.txt
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ exchange-offices
id = 2
name = Exchanger 2
country = UKR
exchanges
exchange
from = AUD
to = CAD
ask = 100
date = 2023-04-25 22:55:33
rates
rate
from = AUD
Expand Down
11 changes: 11 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 7 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
{
"name": "exchange-office",
"version": "0.0.1",
"description": "",
"author": "",
"description": "Test task for AYA",
"author": "[email protected]",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:dev": "NODE_OPTIONS='-r dotenv/config' nest start --watch",
"start:debug": "NODE_OPTIONS='-r dotenv/config' nest start --debug --watch",
"start:import": "NODE_OPTIONS='-r dotenv/config' ts-node src/main-import-file.ts",
"start:prod": "node dist/main",
"start:import": "ts-node src/main-import-file.ts",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
Expand All @@ -37,10 +37,12 @@
"@nestjs/testing": "^9.0.0",
"@types/express": "^4.17.13",
"@types/jest": "29.5.1",
"@types/multer": "^1.4.7",
"@types/node": "18.16.12",
"@types/supertest": "^2.0.11",
"@typescript-eslint/eslint-plugin": "^5.0.0",
"@typescript-eslint/parser": "^5.0.0",
"dotenv": "^16.3.1",
"eslint": "^8.0.1",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^4.0.0",
Expand Down
6 changes: 6 additions & 0 deletions src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,9 @@ export const pgDbConfig = (): TypeOrmModuleOptions => ({
database: env.DB_NAME,
synchronize: env.DB_SYNCHRONIZE === 'true',
});

export const fileUploadOptions = Object.freeze({
limits: {
fileSize: parseInt(env.UPLOAD_FILE_MAX_SIZE_BITES, 10) || 800,
},
});
31 changes: 24 additions & 7 deletions src/db/db-import.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,19 @@ export class DbImportService {
private readonly exchangeOfficeRepo: Repository<ExchangeOffice>,
) {}

async importFileContentToDb() {
const content: TJsonContent = await this.loadContentFromFile();
async importFileFromDiskToDb(fileName?: string) {
const content: TJsonContent = await this.loadContentFromFile(fileName);
return this.storeFileContentInDb(content);
}

async uploadFileToDb(file: string) {
const content = this.parseFileToJson(file);
return this.storeFileContentInDb(content);
}

private async storeFileContentInDb(content: TJsonContent) {
if (!content) {
this.logger.warn(`Failed to load content from import file`);
this.logger.warn(`No content for storing in DB`);
return {
countries: [],
offices: [],
Expand All @@ -40,15 +49,17 @@ export class DbImportService {
const offices = await this.importExchangeOffices(
content[exchangeOfficesField],
);
this.logger.log('importFromFile is done');
this.logger.log('storeFileContentInDb is done');

return {
countries,
offices,
};
}

async importCountries(countryList: TCountryList): Promise<Country[] | []> {
private async importCountries(
countryList: TCountryList,
): Promise<Country[] | []> {
const codes = countryList.map((c) => c.code);
const dbData = await this.countryRepo.findBy({ code: In(codes) });

Expand All @@ -63,7 +74,7 @@ export class DbImportService {
return this.countryRepo.save(countries);
}

async importExchangeOffices(
private async importExchangeOffices(
eoList: TExchangeOfficeList,
): Promise<ExchangeOffice[] | []> {
const ids = eoList.map((eo) => Number(eo.id));
Expand All @@ -81,7 +92,13 @@ export class DbImportService {
}
// todo: move common logic of filtering existing entities to one place

async loadContentFromFile(fileName?: string): Promise<TJsonContent | null> {
private async loadContentFromFile(
fileName?: string,
): Promise<TJsonContent | null> {
return this.fileImporter.importFromFile(fileName);
}

private parseFileToJson(file: string): TJsonContent | null {
return this.fileImporter.makeJsonFromText(file);
}
}
24 changes: 20 additions & 4 deletions src/db/db.controller.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,29 @@
import { Controller, Get } from '@nestjs/common';
import {
Controller,
Get,
Post,
Query,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { Express } from 'express';

import { fileUploadOptions } from '../config';
import { DbImportService } from './db-import.service';

@Controller('db')
export class DbController {
constructor(private readonly dbImportService: DbImportService) {}

@Get('import')
async import() {
// todo: think, which params to accept if we need to import from uploaded file
return this.dbImportService.importFileContentToDb();
async import(@Query('filename') fileName?: string) {
return this.dbImportService.importFileFromDiskToDb(fileName);
}

@Post('import-file')
@UseInterceptors(FileInterceptor('file', fileUploadOptions))
async handleFileImport(@UploadedFile() file: Express.Multer.File) {
return this.dbImportService.uploadFileToDb(file.buffer.toString());
}
}
48 changes: 25 additions & 23 deletions src/db/entities/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,36 @@ import {
JoinColumn,
} from 'typeorm';

export type Currencies = 'CAD' | 'AUD' | 'EUR' | 'UAH' | 'USD';

abstract class CommonRateExchangeInfo {
@PrimaryGeneratedColumn()
id: number;

@Column()
@Column({ length: 3 })
from: Currencies;

@Column()
@Column({ length: 3 })
to: Currencies;

@Column()
date: Date;
}

export type Currencies = 'EUR' | 'USD' | 'UAH';
@Entity()
export class Country {
@PrimaryGeneratedColumn()
id: number;

@Column({
unique: true,
length: 3,
})
code: string;

@Column({ length: 50 })
name: string;
}

@Entity()
export class ExchangeOffice {
Expand All @@ -32,9 +47,12 @@ export class ExchangeOffice {
@Column({ length: 100 })
name: string;

@ManyToOne(() => Country, (c) => c.code)
@JoinColumn({ referencedColumnName: 'code' })
country: Currencies;
@ManyToOne(() => Country, (c) => c.code, { nullable: false })
@JoinColumn({
name: 'country',
referencedColumnName: 'code',
})
country: Country;

@OneToMany(() => Exchange, (exchange) => exchange.exchangeOffice, {
cascade: true,
Expand All @@ -45,6 +63,7 @@ export class ExchangeOffice {
cascade: true,
})
rates: Rate[];
// think, if we need createdAt/updateAd-fields
}

@Entity()
Expand Down Expand Up @@ -76,20 +95,3 @@ export class Rate extends CommonRateExchangeInfo {
@JoinColumn()
exchangeOffice: ExchangeOffice;
}

@Entity()
export class Country {
@PrimaryGeneratedColumn()
id: number;

@Column({
unique: true,
length: 3,
})
code: Currencies;

@Column({ length: 50 })
name: string;

// think, if we need createdAt-field
}
7 changes: 7 additions & 0 deletions src/db/file-importer/document-factory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { IDocument } from './interfaces';
import { Document } from './document';

export const documentFactory = (): IDocument => {
// todo: define separate doc classes for any new file formats
return new Document();
};
Loading

0 comments on commit 544103d

Please sign in to comment.