This repository has been archived by the owner on Sep 13, 2020. It is now read-only.
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
0 parents
commit e9e3eb8
Showing
22 changed files
with
1,288 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
node_modules/ |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
dist/ | ||
node_modules/ |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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", "./"] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
# Как запустить | ||
|
||
```bash | ||
yarn | ||
tsc | ||
docker-compose up -d | ||
``` | ||
|
||
В базе добавлены аккаунты 1, 2 и 3 (см. [init.sql](init.sql)); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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" | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
module.exports = { | ||
useTabs: true, | ||
singleQuote: true, | ||
printWidth: 100 | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
export interface Account { | ||
id: number; | ||
balance: number; // в копейках | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
export enum AccountErrorType { | ||
Invalid, | ||
NotFound, | ||
NotEnoughFunds, | ||
} | ||
|
||
export class AccountError extends Error { | ||
constructor(public readonly type: AccountErrorType, message?: string) { | ||
super(message); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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, | ||
}, | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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, | ||
}; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; |
Oops, something went wrong.