Commit 3660d9d0 authored by Vadym Gidulian's avatar Vadym Gidulian

Merge branch 'dev'

parents 7ae6163f 712d6b3d
/.data
.env
# Dependency directories
node_modules/
# Logs
logs/
*.log
npm-debug.log*
# Optional npm cache directory
.npm/
# Optional REPL history
.node_repl_history/
*
!/src/**
# release-server-middleware
An Express middleware for release server.
## Description
Release server only works with [Semantic Versioning (SemVer)](https://semver.org/).
Releases may be marked as private.
The latest release is a release with the greatest version among non-private releases.
## Usage
```js
app.use(releaseServerMiddleware(options))
```
### Options
- `filesDir` `[string]` Default: `/files`
Path to directory used to store releases.
- `tmpFilesDir` `[string]` Default: `<filesDir>/.tmp`
Path to directory used to store temp files.
### API
- Download the latest release
```
GET /latest
```
- Get the latest release's info
```
GET /latest/info
```
- Download specific release
```
GET /:version
```
- Get specific release's info
```
GET /:version/info
```
- Get all releases' info
```
GET /releases
```
- Upload a release
```
PUT /:version
Content-Type: multipart/form-data
[X-File-Name: file.ext]
[X-Private]
file=...
```
`X-File-Name` - an optional header to set new file name.
`X-Private` - an optional header to mark release as private.
- Delete a release
```
DELETE /:version
```
version: '3'
services:
server:
image: node:10-alpine
volumes:
- .:/app
- ./.data/files:/files
working_dir: /app
entrypoint: sh
command: -c "yarn install --production=false && npx nodemon --signal SIGHUP -x 'npm test'"
networks:
default:
aliases:
- api
{ {
"name": "@gviagroup/release-server-middleware", "name": "@gviagroup/release-server-middleware",
"version": "0.1.0" "version": "0.1.0",
"main": "src/apis/index.js",
"scripts": {
"test": "ava --color --fail-fast -s -v"
},
"dependencies": {
"del": "^5.1.0",
"express": "^4.17.1",
"lodash.pick": "^4.4.0",
"mkdirp": "^0.5.1",
"multer": "^1.4.2",
"semver": "^6.3.0",
"@gviagroup/jsvv": "~0.11.0"
},
"devDependencies": {
"ava": "^2.4.0",
"axios": "^0.19.0",
"form-data": "^2.5.1",
"nodemon": "^1.19.4"
},
"_projectTemplateVersion": "1.1.2"
} }
'use strict';
const path = require('path');
const express = require('express');
const multer = require('multer');
const jsvv = require('@gviagroup/jsvv');
const {handle, methodNotAllowed, SYMBOLS: {IS_EMPTY}} = require('../utils/api');
const {AppError} = require('../utils/errors');
const reqVersionSchema = require('../schemas/request-version');
const resReleaseInfoSchema = require('../schemas/response-release-info');
module.exports = function ({filesDir, tmpFilesDir} = {}) {
const releasesModel = require('../models/releases')({filesDir, tmpFilesDir});
const router = express.Router();
const methodNotAllowedHandler = methodNotAllowed(router);
const fileGetHandler = handle(req => {
req.params.version = jsvv(req.params.version, reqVersionSchema, {root: 'version'});
},
async (req, res, next) => {
const filePath = await releasesModel.getReleaseFilePath({version: req.params.version});
if (!filePath) return null;
req.url = path.relative(releasesModel.FILES_DIR, filePath);
next();
});
const latestHandler = (req, res, next) => {
req.params.version = 'latest';
next();
};
const staticHandler = express.static(releasesModel.FILES_DIR);
const upload = multer({
dest: releasesModel.TMP_FILES_DIR
});
router.route('/latest')
.head(latestHandler, fileGetHandler, staticHandler)
.get( latestHandler, fileGetHandler, staticHandler)
.all(methodNotAllowedHandler);
router.route('/latest/info')
.get(handle(undefined,
() => releasesModel.getReleaseInfo({version: 'latest'}),
response => jsvv(response, resReleaseInfoSchema, {root: 'releaseInfo'})))
.all(methodNotAllowedHandler);
router.route('/releases')
.get(handle(undefined,
() => releasesModel.getAllReleases(),
response => jsvv(response, {
type: Array,
required: true,
item: resReleaseInfoSchema
}, {root: 'releases'})))
.all(methodNotAllowedHandler);
router.route('/:version')
.head(fileGetHandler, staticHandler)
.get( fileGetHandler, staticHandler)
.put(upload.single('file'), handle(req => {
req.params.version = jsvv(req.params.version, reqVersionSchema, {root: 'version'});
jsvv(req.headers['content-type'], {type: String, required: true, pattern: /^multipart\/form-data/}, {root: 'headers.Content-Type'});
if (!req.file || !req.file.size || !req.file.path) throw new AppError('Body is empty', 400);
},
req => releasesModel.saveRelease({
version: req.params.version,
filePath: req.file.path,
fileName: req.headers['x-file-name'] || req.file.originalname,
isPrivate: req.headers.hasOwnProperty('x-private')
}),
response => jsvv(response, resReleaseInfoSchema, {root: 'releaseInfo'})))
.delete(handle(req => {
req.params.version = jsvv(req.params.version, reqVersionSchema, {root: 'version'});
},
async (req, res) => {
const info = await releasesModel.deleteRelease({version: req.params.version});
if (!info) res[IS_EMPTY] = true;
return info;
},
response => jsvv(response, resReleaseInfoSchema, {root: 'releaseInfo'})))
.all(methodNotAllowedHandler);
router.route('/:version/info')
.get(handle(req => {
req.params.version = jsvv(req.params.version, reqVersionSchema, {root: 'version'});
},
req => releasesModel.getReleaseInfo({version: req.params.version}),
response => jsvv(response, resReleaseInfoSchema, {root: 'releaseInfo'})))
.all(methodNotAllowedHandler);
return router;
};
'use strict';
const fs = require('fs');
const path = require('path');
const {promisify} = require('util');
const del = require('del');
const mkdirp = require('mkdirp');
const semver = require('semver');
const fsRename = promisify(fs.rename);
const fsStat = promisify(fs.stat);
const fsReadDir = promisify(fs.readdir);
const fsReadFile = promisify(fs.readFile);
const fsWriteFile = promisify(fs.writeFile);
const mkdirpAsync = promisify(mkdirp);
const RELEASE_INFO_FILENAME = 'info.json';
module.exports = function ({filesDir = '/files', tmpFilesDir = path.join(filesDir, '.tmp')} = {}) {
const FILES_DIR = filesDir;
const TMP_FILES_DIR = tmpFilesDir;
return {
FILES_DIR,
TMP_FILES_DIR,
deleteRelease,
getAllReleases,
getReleaseFilePath,
getReleaseInfo,
saveRelease
};
async function deleteRelease({version}) {
const info = await getReleaseInfo({version});
await del(getReleaseDir({version}), {force: true});
return info;
}
async function determineLatestVersion() {
const versions = await getAllVersions();
for (const version of versions) {
const info = await getReleaseInfo({version});
if (!info.private) return version;
}
return null;
}
async function isVersionExists({version}) {
if (!version) return false;
try {
await fsStat(getReleaseDir({version}));
return true;
} catch (e) {
if (e.code === 'ENOENT') return false;
else throw e;
}
}
async function getAllReleases() {
return Promise.all((await getAllVersions())
.map(version => getReleaseInfo({version})));
}
async function getAllVersions() {
return (await fsReadDir(FILES_DIR))
.filter(s => !s.startsWith('.'))
.sort(semver.rcompare);
}
function getReleaseDir({version}) {
return path.join(FILES_DIR, version);
}
async function getReleaseFilePath({version}) {
if (version === 'latest') version = await determineLatestVersion();
const info = await getReleaseInfo({version});
return info
? path.join(getReleaseDir({version}), info.fileName)
: null;
}
async function getReleaseInfo({version}) {
if (version === 'latest') version = await determineLatestVersion();
return (await isVersionExists({version}))
? JSON.parse(await fsReadFile(path.join(getReleaseDir({version}), RELEASE_INFO_FILENAME), {encoding: 'utf8'}))
: null;
}
async function saveRelease({version, filePath, fileName, isPrivate}) {
const releaseDir = getReleaseDir({version});
const newFilePath = path.join(releaseDir, fileName);
await deleteRelease({version});
await mkdirpAsync(releaseDir);
await fsRename(filePath, newFilePath);
await fsWriteFile(path.join(releaseDir, RELEASE_INFO_FILENAME), JSON.stringify({
version,
publishDate: new Date(),
fileName,
fileSize: (await fsStat(newFilePath)).size,
private: !!isPrivate || undefined
}));
return getReleaseInfo({version});
}
};
'use strict';
module.exports = {
type: Date,
required: true,
transformsBefore: [
value => (value && Number.isInteger(Date.parse(value))) ? new Date(value) : value
]
};
'use strict';
module.exports = {
type: String,
required: true,
minLength: 1,
transforms: [String.prototype.trim]
};
'use strict';
const semver = require('semver');
module.exports = {
type: String,
required: true,
minLength: 1,
transforms: [String.prototype.trim],
validator: version => !!semver.valid(version) || {expected: 'valid SemVer version', actual: version}
};
'use strict';
const versionSchema = require('./primitives/version');
module.exports = [
{
type: ['latest'],
required: true
},
versionSchema
];
'use strict';
const {pick} = require('../utils/util');
const dateSchema = require('./primitives/date');
const fileNameSchema = require('./primitives/file-name');
const versionSchema = require('./primitives/version');
module.exports = [
{
type: Object,
required: true,
properties: {
version: versionSchema,
publishDate: dateSchema,
fileName: fileNameSchema,
fileSize: {
type: Number,
required: true,
min: 0
},
private: {
type: [true]
}
},
transformsAfter: [
pick('version', 'publishDate', 'fileName', 'fileSize', 'private')
]
}, {
type: null,
required: true
}
];
'use strict';
const IS_EMPTY = Symbol('isEmpty');
module.exports = {
SYMBOLS: {
IS_EMPTY
},
handle(requestValidator, handler, responseValidator) {
return async function (req, res, next) {
const nextWatcher = new NextWatcher(next);
const _next = nextWatcher.watcher.bind(nextWatcher);
let response;
if (requestValidator) {
try {
await requestValidator(req, res, _next);
if (nextWatcher.wasCalled) return nextWatcher.doNext();
} catch (e) { // Probably bad request
return res.status(400)
.set('Content-Type', 'text/plain')
.send(e.message);
}
}
if (handler) {
try {
response = await handler(req, res, _next);
if (nextWatcher.wasCalled) return nextWatcher.doNext();
} catch (e) { // Operation error
const code = e.code || (e.response && e.response.code);
if (/^4\d\d/.test(code)) return res.status(code)
.set('Content-Type', 'text/plain')
.send(e.message);
return next(e);
}
}
if (responseValidator) {
try {
response = await responseValidator(response, req, res, _next);
if (nextWatcher.wasCalled) return nextWatcher.doNext();
} catch (e) { // Probably bad response
return next(e);
}
}
if (!response && !res[IS_EMPTY]) return res.status(404).send();
if (res[IS_EMPTY] || [204, 304].includes(res.statusCode)) response = null;
res.status(res.statusCode || 200)
.send((typeof response === 'number') ? String(response) : response);
}
},
methodNotAllowed(router) {
return function (req, res) {
const layer = router.stack.filter(layer => layer.path === req.path)[0];
if (!layer) return res.status(404).send();
const routeMethods = Object.keys(layer.route.methods);
const allowedMethods = routeMethods
.filter(method => method[0] !== '_')
.map(method => method.toUpperCase())
.join(', ');
return res.status(405).set('Allow', allowedMethods).send();
};
}
};
class NextWatcher {
constructor(next) {
this._next = next;
this.wasCalled = false;
}
doNext() {
return this._next(...this._passedArgs);
}
watcher(...args) {
this._passedArgs = args;
this.wasCalled = true;
}
}
'use strict';
class AppError extends Error {
constructor(message, code = 500, cause) {
super(message);
if (Error.captureStackTrace) {
Error.captureStackTrace(this, AppError);
}
this.name = this.constructor.name;
this.code = code;
this.cause = cause;
}
}
module.exports = {
AppError
};
'use strict';
const _pick = require('lodash.pick');
module.exports = {
pick(...props) {
return object => (object && typeof object === 'object')
? _pick(object, props)
: object;
}
};
'use strict';
const express = require('express');
const app = express()
.use(require('../../src/apis')())
.use((req, res) => {
console.warn(`${req.url} was not handled`);
res.status(404).set('Content-Type', 'text/plain').send(`${req.url} was not handled`);
})
.use((err, req, res, next) => {
console.error(err);
if (!res.finished) res.status(500).set('Content-Type', 'text/plain').send(err.stack);
});
let server;
module.exports = {
start() {
return new Promise(resolve => {
server = app.listen(80, () => {
console.info('Server is started.');
resolve();
});
});
},
stop() {
return new Promise((resolve, reject) => {
server.close(err => {
if (err) {
console.error('Can\'t stop the server');
return reject(err);
}
console.info('Server is stopped');
resolve();
});
});
}
};
'use strict';
const test = require('ava');
const axios = require('axios');
const FormData = require('form-data');
const server = require('./_server');
test.serial.before(async () => {
await server.start();
});
test.serial('There should be no releases', async t => {
const {data: releases} = await axios.get('http://api/releases');
t.deepEqual(releases, []);
});
test.serial('There should be no latest release', async t => {
try {
await axios.get('http://api/latest');
} catch (e) {
t.is(e.response.status, 404);
}
});
test.serial('There should be no latest release info', async t => {
try {
await axios.get('http://api/latest/info');
} catch (e) {
t.is(e.response.status, 404);
}
});
test.serial('Create release', async t => {
const formData = getFormData({fileName: 'file.txt'});
const {data: info} = await axios.put(`http://api/1.0.0`, formData, {
headers: formData.getHeaders()
});
testReleaseInfo(t, info, {
version: '1.0.0',
fileName: 'file.txt'
});
});
test.serial('Try to create release w/ invalid SemVer version', async t => {
const formData = getFormData({fileName: 'file.txt'});
try {
await axios.put(`http://api/1`, formData, {
headers: formData.getHeaders()
});
} catch (e) {
t.is(e.response.status, 400);
}
});
test.serial('Try to create release w/ empty body', async t => {
try {
await axios.put(`http://api/1`);
} catch (e) {
t.is(e.response.status, 400);
}
});
test.serial('Create release specifying different file name', async t => {
const formData = getFormData({fileName: 'file.txt'});
const {data: info} = await axios.put(`http://api/1.1.0`, formData, {
headers: {
...formData.getHeaders(),
'X-File-Name': 'release.txt'
}
});
testReleaseInfo(t, info, {
version: '1.1.0',
fileName: 'release.txt'
});
});
test.serial('Create private release', async t => {
const formData = getFormData({fileName: 'file.txt'});
const {data: info} = await axios.put(`http://api/2.0.0-beta`, formData, {
headers: {
...formData.getHeaders(),
'X-Private': true
}
});
testReleaseInfo(t, info, {
version: '2.0.0-beta',
fileName: 'file.txt'
});
});
test.serial('Update release', async t => {
const formData = getFormData({fileName: 'release.txt'});
const {data: info} = await axios.put(`http://api/1.0.0`, formData, {
headers: formData.getHeaders()
});
testReleaseInfo(t, info, {
version: '1.0.0',
fileName: 'release.txt'
});
});
test.serial('Get release info', async t => {
const {data: info} = await axios.get('http://api/1.0.0/info');
testReleaseInfo(t, info, {
version: '1.0.0',
fileName: 'release.txt'
});
});
test.serial('Download release', async t => {
const {data} = await axios.get('http://api/1.0.0');
t.is(data, 'Hello, World!');
});
test.serial('Get latest release info', async t => {
const {data: info} = await axios.get('http://api/latest/info');
testReleaseInfo(t, info, {
version: '1.1.0',
fileName: 'release.txt'
});
});
test.serial('Download latest release', async t => {
const {data} = await axios.get('http://api/latest');
t.is(data, 'Hello, World!');
});
test.serial('Get release info of invalid SemVer version', async t => {
try {
await axios.get('http://api/1/info');
} catch (e) {
t.is(e.response.status, 400);
}
});
test.serial('Download release of invalid SemVer version', async t => {
try {
await axios.get('http://api/1');
} catch (e) {
t.is(e.response.status, 400);
}
});
test.serial('Get all releases', async t => {
const {data: releases} = await axios.get('http://api/releases');
const expectedReleases = [
{
version: '2.0.0-beta',
fileName: 'file.txt',
private: true
}, {
version: '1.1.0',
fileName: 'release.txt'
}, {
version: '1.0.0',
fileName: 'release.txt'
}
];
t.is(releases.length, expectedReleases.length);
for (let i = 0; i < releases.length; i++) {
testReleaseInfo(t, releases[i], expectedReleases[i]);
}
});
test.serial('Delete release', async t => {
const {data: info} = await axios.delete('http://api/1.0.0');
testReleaseInfo(t, info, {
version: '1.0.0',
fileName: 'release.txt'
});
});
test.serial('Delete private release', async t => {
const {data: info} = await axios.delete('http://api/2.0.0-beta');
testReleaseInfo(t, info, {
version: '2.0.0-beta',
fileName: 'file.txt'
});
});
test.serial('Delete last release', async t => {
const {data: info} = await axios.delete('http://api/1.1.0');
testReleaseInfo(t, info, {
version: '1.1.0',
fileName: 'release.txt'
});
});
test.serial('There should be no releases after deletion of all releases', async t => {
const {data: releases} = await axios.get('http://api/releases');
t.deepEqual(releases, []);
});
test.serial('There should be no latest release after deletion of all releases', async t => {
try {
await axios.get('http://api/latest');
} catch (e) {
t.is(e.response.status, 404);
}
});
test.serial('There should be no latest release info after deletion of all releases', async t => {
try {
await axios.get('http://api/latest/info');
} catch (e) {
t.is(e.response.status, 404);
}
});
test.serial('Get release info of non-existing version', async t => {
try {
await axios.get('http://api/1.0.0/info');
} catch (e) {
t.is(e.response.status, 404);
}
});
test.serial('Download release of non-existing version', async t => {
try {
await axios.get('http://api/1.0.0');
} catch (e) {
t.is(e.response.status, 404);
}
});
test.serial('Delete non-existing release', async t => {
const {data: info} = await axios.delete('http://api/1.0.0');
t.falsy(info);
});
test.serial.after.always(async () => {
await server.stop();
});
function getFormData({fileName}) {
const formData = new FormData();
formData.append('file', 'Hello, World!', fileName);
return formData;
}
function testReleaseInfo(t, actual, expected) {
t.truthy(actual);
t.is(actual.version, expected.version);
t.is(actual.fileName, expected.fileName);
t.is(actual.fileSize, 13);
t.true(Date.now() - new Date(actual.publishDate) < 100);
}
'use strict';
const fs = require('fs');
const path = require('path');
const test = require('ava');
const del = require('del');
const mkdirp = require('mkdirp');
const releasesModel = require('../../src/models/releases')();
test.serial.before(t => {
del.sync('/files/**', {dot: true, force: true});
t.deepEqual(fs.readdirSync(releasesModel.FILES_DIR), []);
});
test.serial('There should be no releases', async t => {
const info = await releasesModel.getAllReleases();
t.deepEqual(info, []);
});
test.serial('There should be no latest release', async t => {
const info = await releasesModel.getReleaseInfo({version: 'latest'});
t.is(info, null);
t.is(await releasesModel.getReleaseFilePath({version: 'latest'}), null);
});
test.serial('Create release', async t => {
const filePath = createTestFile();
const info = await releasesModel.saveRelease({
version: '1.0.0',
filePath,
fileName: 'file.txt'
});
testReleaseInfo(t, info, {
version: '1.0.0',
fileName: 'file.txt'
});
});
test.serial('Create private release', async t => {
const filePath = createTestFile();
const info = await releasesModel.saveRelease({
version: '2.0.0-beta',
filePath,
fileName: 'file.txt',
isPrivate: true
});
testReleaseInfo(t, info, {
version: '2.0.0-beta',
fileName: 'file.txt',
private: true
});
});
test.serial('Update release', async t => {
const filePath = createTestFile();
const info = await releasesModel.saveRelease({
version: '1.0.0',
filePath,
fileName: 'release.txt'
});
testReleaseInfo(t, info, {
version: '1.0.0',
fileName: 'release.txt'
});
});
test.serial('Get release info', async t => {
const info = await releasesModel.getReleaseInfo({version: '1.0.0'});
testReleaseInfo(t, info, {
version: '1.0.0',
fileName: 'release.txt'
});
t.is(await releasesModel.getReleaseFilePath({version: '1.0.0'}),
path.join(releasesModel.FILES_DIR, '1.0.0', 'release.txt'));
});
test.serial('Temp folder should be empty', t => {
t.deepEqual(fs.readdirSync(releasesModel.TMP_FILES_DIR), []);
});
test.serial('Get all releases', async t => {
const releases = await releasesModel.getAllReleases();
const expectedReleases = [
{
version: '2.0.0-beta',
fileName: 'file.txt',
private: true
}, {
version: '1.0.0',
fileName: 'release.txt'
}
];
t.is(releases.length, expectedReleases.length);
for (let i = 0; i < releases.length; i++) {
testReleaseInfo(t, releases[i], expectedReleases[i]);
}
});
test.serial('Delete release', async t => {
const info = await releasesModel.deleteRelease({version: '1.0.0'});
testReleaseInfo(t, info, {
version: '1.0.0',
fileName: 'release.txt'
});
});
test.serial('Delete private release', async t => {
const info = await releasesModel.deleteRelease({version: '2.0.0-beta'});
testReleaseInfo(t, info, {
version: '2.0.0-beta',
fileName: 'file.txt',
private: true
});
});
test.serial('There should be no releases after deletion of all releases', async t => {
const info = await releasesModel.getAllReleases();
t.deepEqual(info, []);
});
test.serial('There should be no latest release after deletion of all releases', async t => {
const info = await releasesModel.getReleaseInfo({version: 'latest'});
t.is(info, null);
t.is(await releasesModel.getReleaseFilePath({version: 'latest'}), null);
});
test.serial('Releases folder should be empty', t => {
t.deepEqual(fs.readdirSync(releasesModel.FILES_DIR), ['.tmp']);
});
test.serial('Get non-existing release info', async t => {
const info = await releasesModel.getReleaseInfo({version: '1.0.0'});
t.is(info, null);
t.is(await releasesModel.getReleaseFilePath({version: '1.0.0'}), null);
});
test.serial('Delete non-existing release', async t => {
const info = await releasesModel.deleteRelease({version: '1.0.0'});
t.is(info, null);
});
test.serial.after(t => {
del.sync('/files/**', {dot: true, force: true});
t.deepEqual(fs.readdirSync(releasesModel.FILES_DIR), []);
});
function createTestFile() {
mkdirp.sync(releasesModel.TMP_FILES_DIR);
const randomFileName = `${Math.ceil(Math.random() * Number.MAX_SAFE_INTEGER).toString(36)}.txt`;
const filePath = path.join(releasesModel.TMP_FILES_DIR, randomFileName);
fs.writeFileSync(filePath, 'Hello, World!');
return filePath;
}
function testReleaseInfo(t, actual, expected) {
t.truthy(actual);
t.is(actual.version, expected.version);
t.is(actual.fileName, expected.fileName);
t.is(actual.fileSize, 13);
t.is(actual.private, expected.private);
t.true(Date.now() - new Date(actual.publishDate) < 100);
}
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment