Skip to content
This repository has been archived by the owner on Sep 13, 2020. It is now read-only.

Commit

Permalink
initial
Browse files Browse the repository at this point in the history
  • Loading branch information
silentroach committed Sep 6, 2020
0 parents commit e9e3eb8
Show file tree
Hide file tree
Showing 22 changed files with 1,288 additions and 0 deletions.
1 change: 1 addition & 0 deletions .dockerignore
@@ -0,0 +1 @@
node_modules/
2 changes: 2 additions & 0 deletions .gitignore
@@ -0,0 +1,2 @@
dist/
node_modules/
14 changes: 14 additions & 0 deletions Dockerfile
@@ -0,0 +1,14 @@
FROM node:14

WORKDIR /app

COPY dist/ /app
COPY yarn.lock .
COPY package.json .
COPY package.json /

RUN cd /app && yarn install --production --frozen-lockfile

EXPOSE 3000

CMD ["node", "--enable-source-maps", "./"]
9 changes: 9 additions & 0 deletions README.md
@@ -0,0 +1,9 @@
# Как запустить

```bash
yarn
tsc
docker-compose up -d
```

В базе добавлены аккаунты 1, 2 и 3 (см. [init.sql](init.sql));
30 changes: 30 additions & 0 deletions docker-compose.yml
@@ -0,0 +1,30 @@
version: '3.3'

services:

db:
image: postgres
restart: on-failure
environment:
POSTGRES_PASSWORD: ryrZi(Mri2qMJia
ports:
- "5432:5432"
expose:
- "5432"
volumes:
- ./init.sql:/docker-entrypoint-initdb.d/init.sql

app:
build:
dockerfile: ./Dockerfile
context: ./
restart: on-failure
depends_on:
- db
ports:
- "3000:3000"
links:
- db
environment:
DB_HOST: db
DB_PASSWORD: ryrZi(Mri2qMJia
7 changes: 7 additions & 0 deletions init.sql
@@ -0,0 +1,7 @@
create table accounts (
account_id bigserial primary key,
balance decimal(12, 0) check (balance > 0)
);

insert into accounts (account_id, balance)
values (1, 5000000), (2, 2500000), (3, 10000000)
27 changes: 27 additions & 0 deletions package.json
@@ -0,0 +1,27 @@
{
"name": "raiffeisen-test",
"version": "1.0.0",
"main": "index.js",
"author": "Igor Kalashnikov <igor.kalashnikov@me.com>",
"license": "MIT",
"dependencies": {
"body-parser": "^1.19.0",
"express": "^4.17.1",
"express-async-errors": "^3.1.1",
"pg": "^8.3.3",
"pg-pool": "^3.2.1"
},
"devDependencies": {
"@types/express": "^4.17.8",
"@types/node": "^14.6.4",
"@types/pg": "^7.14.4",
"prettier": "^2.1.1",
"pretty-quick": "^3.0.0",
"typescript": "^4.0.2"
},
"husky": {
"hooks": {
"pre-commit": "pretty-quick --staged"
}
}
}
5 changes: 5 additions & 0 deletions prettier.config.js
@@ -0,0 +1,5 @@
module.exports = {
useTabs: true,
singleQuote: true,
printWidth: 100
};
19 changes: 19 additions & 0 deletions src/APIError.ts
@@ -0,0 +1,19 @@
import { HttpStatus } from './HttpStatus';

export class APIError extends Error {
public static badRequest(message: string = 'Некорректный запрос') {
return new APIError(HttpStatus.BadRequest, message);
}

public static notFound(message: string = 'Не найдено') {
return new APIError(HttpStatus.NotFound, message);
}

public static forbidden(message: string = 'Запрещено') {
return new APIError(HttpStatus.Forbidden, message);
}

private constructor(public readonly status: HttpStatus, message?: string) {
super(message);
}
}
13 changes: 13 additions & 0 deletions src/HttpStatus.ts
@@ -0,0 +1,13 @@
export enum HttpStatus {
Ok = 200,
Created = 201,
Accepted = 202,
Found = 302,
BadRequest = 400,
Unauthorized = 401,
Forbidden = 403,
NotFound = 404,
Conflict = 409,
ServerError = 500,
ServiceUnavailable = 503
}
4 changes: 4 additions & 0 deletions src/accounts/Account.ts
@@ -0,0 +1,4 @@
export interface Account {
id: number;
balance: number; // в копейках
}
11 changes: 11 additions & 0 deletions src/accounts/AccountError.ts
@@ -0,0 +1,11 @@
export enum AccountErrorType {
Invalid,
NotFound,
NotEnoughFunds,
}

export class AccountError extends Error {
constructor(public readonly type: AccountErrorType, message?: string) {
super(message);
}
}
93 changes: 93 additions & 0 deletions src/accounts/index.ts
@@ -0,0 +1,93 @@
import type { ClientBase } from 'pg';

import { pool } from '../db';

import { Account } from './Account';
import { AccountError, AccountErrorType } from './AccountError';

const getAccountWithinConnection = async (
connection: ClientBase,
id: number,
lock: boolean = false
): Promise<Account> => {
const { rows } = await connection.query(
[`select balance from accounts where account_id = $1`, lock ? `for update` : ''].join('\n'),
[id]
);

if (rows.length === 0) {
throw new AccountError(AccountErrorType.NotFound, `Счёт ${id} не найден`);
}

const [{ balance }] = rows;

return { id, balance: Number(balance) };
};

const increment = async (connection: ClientBase, id: number, diff: number): Promise<void> => {
await connection.query(`update accounts set balance = balance + $2 where account_id = $1`, [
id,
diff,
]);
};

const transferWithinConnection = async (
connection: ClientBase,
sourceId: number,
targetId: number,
amount: number
): Promise<Account> => {
await connection.query('begin');
try {
const source = await getAccountWithinConnection(connection, sourceId, true);
const target = await getAccountWithinConnection(connection, targetId, true);

if (source.balance < amount) {
throw new AccountError(AccountErrorType.NotEnoughFunds, 'Недостаточно средств для списания');
}

await increment(connection, source.id, -amount);
await increment(connection, target.id, amount);

await connection.query('commit');

return {
id: source.id,
balance: source.balance - amount,
};
} catch (e) {
await connection.query('rollback');
throw e;
}
};

export const getAccount = async (id: number): Promise<Account> => {
const connection = await pool.connect();
try {
return getAccountWithinConnection(connection, id);
} finally {
connection.release();
}
};

export const transfer = async (
sourceId: number,
targetId: number,
amount: number
): Promise<Account> => {
const connection = await pool.connect();
try {
return transferWithinConnection(connection, sourceId, targetId, amount);
} finally {
connection.release();
}
};

export const validateAccountId = (accountId: any): number => {
const normalized = Number(accountId);
if (!Number.isFinite(normalized) || normalized <= 0) {
throw new AccountError(AccountErrorType.Invalid, 'Некорректный номер счёта');
}

return normalized;
};
16 changes: 16 additions & 0 deletions src/config.ts
@@ -0,0 +1,16 @@
const { name, version } = require('../package.json');

export const config = {
name,
version,

port: process.env.PORT || 3000,

db: {
host: process.env.DB_HOST || 'localhost',
port: process.env.DB_PORT || 5432,
db: process.env.DB_DATABASE || 'postgres',
user: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD,
},
};
16 changes: 16 additions & 0 deletions src/db.ts
@@ -0,0 +1,16 @@
import { Pool, PoolConfig } from 'pg';

import { config } from './config';

export const pool = new Pool({
application_name: [config.name, config.version].join(' '),

host: config.db.host,
port: config.db.port,
database: config.db.db,
user: config.db.user,
password: config.db.password,
idleTimeoutMillis: 20000,
max: 5,
maxUses: 1280,
} as PoolConfig);
42 changes: 42 additions & 0 deletions src/index.ts
@@ -0,0 +1,42 @@
import { Server } from 'http';

import 'express-async-errors';
import express from 'express';
import bodyParser from 'body-parser';

import { config } from './config';
import { pool as db } from './db';
import { router } from './router';

const app = express();

app.use(bodyParser.json());
app.set('x-powered-by', false);

app.use('/', router);

const server = new Server(app);

process.on('uncaughtException', (err) => {
console.error('uncaught exception', err);
});

process.on('unhandledRejection', (err, promise) => {
console.error('unhandled rejection', promise, err);
});

server.listen(config.port, () => {
console.log(`Server listening on: ${config.port}`);
});

process.on('SIGTERM', async () => {
console.log('Got sigterm, trying to stop gracefully...');

server.close(async () => {
await db.end();

console.log('Server stopped');

process.exit(0);
});
});
11 changes: 11 additions & 0 deletions src/mappers/account.ts
@@ -0,0 +1,11 @@
import type { Account } from '../accounts/Account';

export interface AccountResponse {
balance: number;
}

export const mapAccount = (account: Account): AccountResponse => {
return {
balance: account.balance / 100,
};
};
53 changes: 53 additions & 0 deletions src/router/account.ts
@@ -0,0 +1,53 @@
import express, { ErrorRequestHandler, Request, Response } from 'express';

import { getAccount, transfer, validateAccountId } from '../accounts';
import { AccountResponse, mapAccount } from '../mappers/account';
import { AccountError, AccountErrorType } from '../accounts/AccountError';

import { APIError } from '../APIError';

const router = express.Router();

router.get('/account/:id', async (req: Request<{ id: number }>, res: Response<AccountResponse>) => {
const accountId = validateAccountId(req.params.id);

const account = await getAccount(accountId);

res.json(mapAccount(account));
});

router.post(
'/transfer/:sourceId/:targetId',
async (req: Request<{ sourceId: number; targetId: number }>, res: Response<AccountResponse>) => {
const sourceId = validateAccountId(req.params.sourceId);
const targetId = validateAccountId(req.params.targetId);

const amount = Number(req.body.amount);
if (!Number.isFinite(amount) || amount <= 0) {
throw APIError.badRequest('Некорректная сумма');
}

const account = await transfer(sourceId, targetId, Math.trunc(amount * 100));

res.json(mapAccount(account));
}
);

const errorHandler: ErrorRequestHandler = async (error, req, res, next) => {
if (error instanceof AccountError) {
switch (error.type) {
case AccountErrorType.NotFound:
throw APIError.notFound(error.message);
case AccountErrorType.NotEnoughFunds:
throw APIError.forbidden(error.message);
case AccountErrorType.Invalid:
throw APIError.badRequest(error.message);
}
}

throw error;
};

router.use(errorHandler);

export const accountRouter = router;

0 comments on commit e9e3eb8

Please sign in to comment.