From 28b4f6ea334d204d1a0520a9e0d985c8c27c2585 Mon Sep 17 00:00:00 2001 From: Jonatan Nilsson Date: Thu, 17 Mar 2022 15:09:22 +0000 Subject: [PATCH] Flaska: Testing and Version 1.0.0 released Added JsonHandler Added FormidableHandler Fixed a bunch of request handling --- README.md | 180 +++++++++++++++++++++- appveyor.yml | 4 +- flaska.mjs | 237 +++++++++++++++++++++++++--- package.json | 10 +- test/client.mjs | 38 +++-- test/flaska.api.test.mjs | 112 +++++++++++++- test/flaska.in.test.mjs | 5 +- test/flaska.out.test.mjs | 62 +++++++- test/http.test.mjs | 198 +++++++++++++++++++++++- test/middlewares.test.mjs | 317 ++++++++++++++++++++++++++++++++++++-- test/upload/.gitkeep | 0 11 files changed, 1098 insertions(+), 65 deletions(-) create mode 100644 test/upload/.gitkeep diff --git a/README.md b/README.md index 5ef4e82..36c4d9d 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,178 @@ -# bottle-node -Bottle is a micro web-framework for node. It is designed to be fast, simple and lightweight, and is distributed as a single file module with no dependencies. +# Flaska +Flaska is a micro web-framework for node. It is designed to be fast, simple and lightweight, and is distributed as a single file module with no dependencies. + +Heavily inspired by koa and koa-router it takes liberties of implementing most of the common functionality required for most sites without sacrificing anything. And the fact that the footprint for this is much smaller than koa while also being more powerful and faster shows the testament that is flaska. + +## Installation + +``` +$ npm install flaska +``` + +## Hello Flaska + +```js +import { Flaska } from '../flaska.mjs' +const flaska = new Flaska() + +flaska.get('/', function(ctx) { + ctx.body = 'Hello Flaska'; +}) + +// flaska.listen(3000); +flaska.listenAsync(3000).then(function() { + console.log('listening on port 3000') +}, function(err) { + console.error('Error listening:', err) +}) +``` + +## Router/Handlers + +Flaska supports all the common verbs out of the box: + +* `flaska.get(url, [middlewares], handler)` +* `flaska.post(url, [middlewares], handler)` +* `flaska.put(url, [middlewares], handler)` +* `flaska.delete(url, [middlewares], handler)` +* `flaska.options(url, [middlewares], handler)` +* `flaska.patch(url, [middlewares], handler)` + +Example: + +``` +flaska.get('/path/to/url', async function(ctx) { + // Perform action +}) +``` + +In addition, each route can have none, one or many middlewares: + +``` +flaska.get('/handlequery', QueryHandler(), async function(ctx) { + // Perform action +}) + +flaska.get('/query/and/json', [QueryHandler(), JsonHandler()], async function(ctx) { + // Perform action +}) +``` + +You can also run global middlewares at the start or end of every request: + +``` +import { Flaska, QueryHandler, JsonHandler } from '../flaska.mjs' +const flaska = new Flaska() + +flaska.before(QueryHandler()) +flaska.before(JsonHandler()) +flaska.beforeAsync(MyLoadDatabase()) + +flaska.after(function(ctx) { + ctx.log.info('Request finished.') +}) +``` + +## Context, Request and Response + +Each handler receives a Flaska `Context` object that encapsulates an incoming +http message and the corresponding response to that message. For those familiar +with koa will be famililar with this concept. `ctx` is often used as the parameter +name for the context object. + +```js +app.before(function(ctx) { + ctx.log.info('Got request') +}) +app.beforeAsync(async function(ctx) { + // Do some database fetching maybe +}) +``` + +The context `ctx` that gets generated includes the following properties for the incoming request: + +* `log`: Log handler. +* `req`: The Request's [IncomingMessage](https://nodejs.org/api/http.html#http_class_http_incomingmessage). +* `res`: The Response's [ServerResponse](https://nodejs.org/api/http.html#http_class_http_serverresponse). +* `method`: The HTTP method ('GET', 'POST', etc.). +* `url`: The full URL path. +* `search`: The URL search. +* `state`: Anonymous object for user-defined data. +* `query`: Map containing the key/value for the url search. + +The following context specific variables can be specified by you the user to instruct Flaska on what to return in response: +* `status`: The status code to return in response (default 200). +* `body`: The body contents to return in response (default null). +* `type`: The content-type for the header (default null). +* `length`: The length of the body in bytes (default null). + +More on this next. + +## `ctx.body` + +At the end of each request, flaska will read `ctx.body` and determine the best course of action based on the type of content is being sent. + +### Javascript object + +In cases where the response body is a normal object, Flaska will automatically `JSON.stringify` it, +set the `Content-Type` to `application/json` and set the total length in `Content-Length`. This +is provided for you at no expense on your behalf so you don't have to worry about it: + +``` +flaska.get('/api/test', function(ctx) { + ctx.body = { + message: 'Hello world', + } +}) +``` + +### File stream/pipe + +In cases where the response body is a pipe object (detected from the existance of `.pipe` property), flaska will automatically pipe the file for you. In addition, if a file stream is used, it will read the extension of the file being streamed and automatically fill in the mime-type for you in the `Content-Type` header. + +``` +flaska.get('/test.png', function(ctx) { + ctx.body = fs.createReadStream('./test/test.png') +}) +``` + +Flaska will automatically close the file stream for you so you don't have to worry about that. + +### String + +In other instances, Flaska will `.toString()` the body and send it in response with the specified type or default to `text/plain` if unspecified. + +``` +flaska.get('/file.html', function(ctx) { + ctx.body = ` + + + +

Hello world

+ + +` + ctx.type = 'text/html; charset=utf-8' +}) +``` + +The `Context` object also provides shortcuts for methods on its `request` and `response`. In the prior +examples, `ctx.type` can be used instead of `ctx.response.type` and `ctx.accepts` can be used +instead of `ctx.request.accepts`. + + +## Built-in middlewares and handlers + +Flaska comes with a few middlewares out of the box. + +* `QueryHandler()` + +Parse the search query and create a map with key->value in `ctx.query`. + +* `JsonHandler()` + +Parse incoming request body as json and store it in `ctx.req.body`. + +* `FormidableHandler()` + +Provides a wrapper to handle an incoming file upload using `Formidable@1`. diff --git a/appveyor.yml b/appveyor.yml index eaa5293..fdba5e9 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -52,12 +52,12 @@ on_success: echo "Release already exists, nothing to do."; else echo "Creating release on gitea" - RELEASE_RESULT=$(curl \ + curl \ -X POST \ -H "Authorization: token $deploytoken" \ -H "Content-Type: application/json" \ https://git.nfp.is/api/v1/repos/$APPVEYOR_REPO_NAME/releases \ - -d "{\"tag_name\":\"v${CURR_VER}\",\"name\":\"v${CURR_VER}\",\"body\":\"Automatic release from Appveyor from ${APPVEYOR_REPO_COMMIT} :\n\n${APPVEYOR_REPO_COMMIT_MESSAGE}\"}") + -d "{\"tag_name\":\"v${CURR_VER}\",\"name\":\"v${CURR_VER}\",\"body\":\"Automatic release from Appveyor from ${APPVEYOR_REPO_COMMIT} :\n\n${APPVEYOR_REPO_COMMIT_MESSAGE}\"}" echo '//registry.npmjs.org/:_authToken=${npmtoken}' > ~/.npmrc echo "Publishing new version to npm" npm publish diff --git a/flaska.mjs b/flaska.mjs index aa0ac11..732d9a2 100644 --- a/flaska.mjs +++ b/flaska.mjs @@ -1,6 +1,35 @@ +import os from 'os' +import path from 'path' import http from 'http' -import { URL } from 'url' import stream from 'stream' +import fs from 'fs/promises' +import { URL } from 'url' +import { Buffer } from 'buffer' +import zlib from 'zlib' + +function getDb() { + // Take from @thi-ng/mime which is a reduced mime-db + let MimeTypeDbRaw = Buffer.from('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', 'base64') + let inbetween = JSON.parse(zlib.brotliDecompressSync(MimeTypeDbRaw).toString()) + + let res = {} + for (let groupID in inbetween) { + const group = inbetween[groupID]; + for (let type in group) { + const mime = groupID + "/" + type; + for (let e of group[type].split(",")) { + const isLowPri = e[0] === "*"; + const ext = isLowPri ? e.substr(1) : e; + let coll = res[ext]; + !coll && (coll = res[ext] = []); + isLowPri ? coll.push(mime) : coll.unshift(mime); + } + } + } + return res +} + +export const MimeTypeDb = getDb() /** * Router @@ -57,17 +86,132 @@ export function QueryHandler() { } } -export function JsonHandler() { - return async function(ctx) { +export function JsonHandler(org = {}) { + let opts = { + sizeLimit: org.sizeLimit || (10 * 1024) + } + return function(ctx) { const buffers = []; + let size = 0 - for await (const chunk of ctx.req) { - buffers.push(chunk); - } + return new Promise(function(res, rej) { + ctx.req.on('data', chunk => { + size += chunk.length + if (size > opts.sizeLimit) { + return rej(new HttpError(413, `Body limit of ${opts.sizeLimit} bytes reached with ${size} bytes`)) + } - const data = Buffer.concat(buffers).toString(); + buffers.push(chunk) + }) + ctx.req.on('end', () => { + res() + }) + }) + .then(function() { + if (!buffers.length) { + ctx.req.body = {} + return + } + + const data = Buffer.concat(buffers).toString(); + + try { + ctx.req.body = JSON.parse(data) + } catch (err) { + return Promise.reject(new HttpError(400, `Invalid JSON: ${err.message}`, { + status: 400, + message: `Invalid JSON: ${err.message}`, + request: data, + })) + } + }) + } +} - ctx.req.body = JSON.parse(data) +export function FormidableHandler(formidable, org = {}) { + let lastDateString = '' + let incrementor = 1 + + let opts = { + rename: true, + parseFields: org.parseFields || false, + uploadDir: org.uploadDir || os.tmpdir(), + filename: org.filename || function(file) { + let prefix = new Date() + .toISOString() + .replace(/-/g, '') + .replace('T', '_') + .replace(/:/g, '') + .replace(/\..+/, '_') + + // Prevent accidental overwriting if two file uploads with + // same name get uploaded at exact same second. + if (prefix === lastDateString) { + prefix += incrementor.toString().padStart('2', '0') + '_' + incrementor++ + } else { + lastDateString = prefix + incrementor + } + + return prefix + file.name + }, + maxFileSize: org.maxFileSize || 8 * 1024 * 1024, + maxFieldsSize: org.maxFieldsSize || 10 * 1024, + maxFields: org.maxFields || 50, + } + if (org.rename != null) { + opts.rename = org.rename + } + + // For testing/stubbing purposes + let rename = formidable.fsRename || fs.rename + + return function(ctx) { + let form = formidable.IncomingForm() + form.uploadDir = opts.uploadDir + form.maxFileSize = opts.maxFileSize + form.maxFieldsSize = opts.maxFieldsSize + form.maxFields = opts.maxFields + + return new Promise(function(res, rej) { + form.parse(ctx.req, function(err, fields, files) { + if (err) return rej(err) + + if (opts.parseFields) { + Object.keys(fields).forEach(function(key) { + try { + fields[key] = JSON.parse(fields[key]) + } catch { } + }) + } + + ctx.req.body = fields + ctx.req.file = files?.file || null + + if (!ctx.req.file) { + return res() + } + + let filename + let target + + try { + filename = opts.filename(ctx.req.file) || ctx.req.file.name + target = path.join(opts.uploadDir, filename) + } catch (err) { + return rej(err) + } + + rename(ctx.req.file.path, target) + .then(function() { + ctx.req.file.path = target + ctx.req.file.filename = filename + }) + .then(res, rej) + + }) + }) } } @@ -81,6 +225,7 @@ export class HttpError extends Error { proto.name = 'HttpError'; this.status = statusCode + this.body = body } } @@ -293,14 +438,26 @@ export class Flaska { } this._backuperror = this._onerror = function(err, ctx) { ctx.log.error(err) - ctx.status = 500 - ctx.body = { - status: 500, - message: statuses[500], + if (err instanceof HttpError) { + ctx.status = err.status + ctx.body = err.body || { + status: err.status, + message: statuses[err.status] || statuses[500], + } + } else { + ctx.status = 500 + ctx.body = { + status: 500, + message: statuses[500], + } } } this._onreqerror = function(err, ctx) { - ctx.log.error(err) + if (err.message === 'aborted') { + ctx.log.info(err) + } else { + ctx.log.error(err) + } ctx.res.statusCode = ctx.statusCode = 400 ctx.res.end() } @@ -310,7 +467,15 @@ export class Flaska { let options = opts || {} - this.log = options.log || console + this.log = options.log || { + fatal: console.error.bind(console), + error: console.error.bind(console), + warn: console.log.bind(console), + info: console.log.bind(console), + debug: console.debug.bind(console), + trace: console.debug.bind(console), + log: console.log.bind(console), + } this.http = orgHttp this.stream = orgStream this.server = null @@ -339,11 +504,20 @@ export class Flaska { devMode() { this._backuperror = this._onerror = function(err, ctx) { ctx.log.error(err) - ctx.status = 500 - ctx.body = { - status: 500, - message: `${statuses[500]}: ${err.message}`, - stack: err.stack || '', + if (err instanceof HttpError) { + ctx.status = err.status + ctx.body = err.body || { + status: err.status, + message: `${statuses[err.status] || statuses[500]}: ${err.message}`, + stack: err.stack || '', + } + } else { + ctx.status = 500 + ctx.body = { + status: 500, + message: `${statuses[500]}: ${err.message}`, + stack: err.stack || '', + } } } } @@ -414,6 +588,9 @@ export class Flaska { } req.on('error', (err) => { + if (err.message === 'aborted') { + ctx.aborted = true + } this._onreqerror(err, ctx) this.requestEnded(ctx) }) @@ -421,8 +598,8 @@ export class Flaska { this._onreserror(err, ctx) }) - req.on('aborted', () => { - ctx.aborted = true + req.on('close', () => { + ctx.closed = true this.requestEnded(ctx) }) @@ -507,6 +684,7 @@ export class Flaska { this._backuperror(err, ctx) } } + if (ctx.res.writableEnded) { return } @@ -519,8 +697,17 @@ export class Flaska { let body = ctx.body let length = 0 if (body && typeof(body.pipe) === 'function') { + // Be smart when handling file handles, auto detect mime-type + // based off of the extension of the file. + if (!ctx.type && body.path) { + let ext = path.extname(body.path).slice(1) + if (ext && MimeTypeDb[ext]) { + let found = MimeTypeDb[ext] + ctx.type = found[found.length - 1] + } + } ctx.res.setHeader('Content-Type', ctx.type || 'application/octet-stream') - return this.stream.pipeline(body, ctx.res, function() {}) + return this.stream.pipeline(body, ctx.res, function() { }) } if (typeof(body) === 'object') { body = JSON.stringify(body) @@ -531,6 +718,7 @@ export class Flaska { length = Buffer.byteLength(body) ctx.res.setHeader('Content-Type', ctx.type || 'text/plain; charset=utf-8') } + ctx.res.setHeader('Content-Length', length) ctx.res.end(body) } @@ -538,6 +726,11 @@ export class Flaska { requestEnded(ctx) { if (ctx.finished) return ctx.finished = true + + // Prevent accidental leaking + if (ctx.body && ctx.body.pipe && !ctx.body.closed) { + ctx.body.destroy() + } this._afterCompiled(ctx) if (this._afterAsyncCompiled) { return this._afterAsyncCompiled(ctx).then() diff --git a/package.json b/package.json index 503656d..443f7c9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flaska", - "version": "0.9.9", + "version": "1.0.0", "description": "Flaska is a micro web-framework for node. It is designed to be fast, simple and lightweight, and is distributed as a single file module with no dependencies.", "main": "flaska.mjs", "scripts": { @@ -21,7 +21,7 @@ "type": "module", "repository": { "type": "git", - "url": "git+https://github.com/nfp-projects/bottle-node.git" + "url": "git+https://git.nfp.is/TheThing/flaska.git" }, "keywords": [ "web", @@ -35,11 +35,11 @@ "author": "Jonatan Nilsson", "license": "WTFPL", "bugs": { - "url": "https://github.com/nfp-projects/bottle-node/issues" + "url": "https://git.nfp.is/TheThing/flaska/issues" }, - "homepage": "https://github.com/nfp-projects/bottle-node#readme", + "homepage": "https://git.nfp.is/TheThing/flaska/#readme", "devDependencies": { - "eltro": "^1.3.1", + "eltro": "^1.2.3", "formidable": "^1.2.2" }, "files": [ diff --git a/test/client.mjs b/test/client.mjs index 15b0858..742b479 100644 --- a/test/client.mjs +++ b/test/client.mjs @@ -1,4 +1,7 @@ +import fs from 'fs/promises' +import path from 'path' import http from 'http' +import stream from 'stream' import { URL } from 'url' import { defaults } from './helper.mjs' @@ -7,7 +10,7 @@ export default function Client(port, opts) { this.prefix = `http://localhost:${port}` } -Client.prototype.customRequest = function(method = 'GET', path, body, options) { +Client.prototype.customRequest = function(method = 'GET', path, body, options = {}) { if (path.slice(0, 4) !== 'http') { path = this.prefix + path } @@ -27,21 +30,30 @@ Client.prototype.customRequest = function(method = 'GET', path, body, options) { })) const req = http.request(opts) - if (body) { - req.write(body) - } - req.on('error', reject) + req.on('error', (err) => { + reject(err) + }) req.on('timeout', function() { + req.destroy() reject(new Error(`Request ${method} ${path} timed out`)) }) req.on('response', res => { - res.setEncoding('utf8') let output = '' + if (options.toPipe) { + return stream.pipeline(res, options.toPipe, function(err) { + if (err) { + return reject(err) + } + resolve() + }) + } else { + res.setEncoding('utf8') - res.on('data', function (chunk) { - output += chunk.toString() - }) + res.on('data', function (chunk) { + output += chunk.toString() + }) + } res.on('end', function () { if (!output) return resolve(null) @@ -58,6 +70,14 @@ Client.prototype.customRequest = function(method = 'GET', path, body, options) { resolve(output) }) }) + + if (opts.returnRequest) { + return resolve(req) + } + + if (body) { + req.write(body) + } req.end() return req }) diff --git a/test/flaska.api.test.mjs b/test/flaska.api.test.mjs index 50448b6..747dae2 100644 --- a/test/flaska.api.test.mjs +++ b/test/flaska.api.test.mjs @@ -1,5 +1,5 @@ import { Eltro as t, assert, stub, spy } from 'eltro' -import { Flaska } from '../flaska.mjs' +import { Flaska, HttpError } from '../flaska.mjs' import { createCtx, fakeHttp } from './helper.mjs' const faker = fakeHttp() @@ -135,6 +135,68 @@ t.describe('_onerror', function() { message: 'Internal Server Error', }) }) + + t.test('default valid handling of HttpError', function() { + const assertStatus = 431 + const assertBody = { a: 1 } + const assertError = new HttpError(assertStatus, 'should not be seen', assertBody) + + let ctx = createCtx() + let flaska = new Flaska({}, faker) + flaska._onerror(assertError, ctx) + assert.strictEqual(ctx.log.error.callCount, 1) + assert.strictEqual(ctx.log.error.firstCall[0], assertError) + assert.strictEqual(ctx.status, assertStatus) + assert.strictEqual(ctx.body, assertBody) + }) + + t.test('default valid handling of HttpError with no body', function() { + const assertStatus = 413 + const assertError = new HttpError(assertStatus, 'should not be seen') + + let ctx = createCtx() + let flaska = new Flaska({}, faker) + flaska._onerror(assertError, ctx) + assert.strictEqual(ctx.log.error.callCount, 1) + assert.strictEqual(ctx.log.error.firstCall[0], assertError) + assert.strictEqual(ctx.status, assertStatus) + assert.deepStrictEqual(ctx.body, { + status: assertStatus, + message: 'Payload Too Large', + }) + }) + + t.test('default valid handling of HttpError with missing status message', function() { + const assertStatus = 432 + const assertError = new HttpError(assertStatus, 'should not be seen') + + let ctx = createCtx() + let flaska = new Flaska({}, faker) + flaska._onerror(assertError, ctx) + assert.strictEqual(ctx.log.error.callCount, 1) + assert.strictEqual(ctx.log.error.firstCall[0], assertError) + assert.strictEqual(ctx.status, assertStatus) + assert.deepStrictEqual(ctx.body, { + status: assertStatus, + message: 'Internal Server Error', + }) + }) +}) + +t.describe('_onreqerror', function() { + t.test('a valid function', function() { + let flaska = new Flaska({}, faker) + assert.strictEqual(typeof(flaska._onreqerror), 'function') + }) + + t.test('default valid handling of aborted', function() { + const assertError = new Error('aborted') + let flaska = new Flaska({}, faker) + let ctx = createCtx() + flaska._onreqerror(assertError, ctx) + assert.strictEqual(ctx.log.info.callCount, 1) + assert.strictEqual(ctx.log.info.firstCall[0], assertError) + }) }) t.describe('#devMode()', function() { @@ -180,6 +242,54 @@ t.describe('#devMode()', function() { assert.match(ctx.body.message, new RegExp(assertErrorMessage)) assert.ok(ctx.body.stack) }) + + t.test('default valid handling of HttpError', function() { + const assertStatus = 431 + const assertBody = { a: 1 } + const assertError = new HttpError(assertStatus, 'should not be seen', assertBody) + + let ctx = createCtx() + let flaska = new Flaska({}, faker) + flaska.devMode() + flaska._onerror(assertError, ctx) + assert.strictEqual(ctx.log.error.callCount, 1) + assert.strictEqual(ctx.log.error.firstCall[0], assertError) + assert.strictEqual(ctx.status, assertStatus) + assert.strictEqual(ctx.body, assertBody) + }) + + t.test('default valid handling of HttpError with no body', function() { + const assertStatus = 413 + const assertErrorMessage = 'A day' + const assertError = new HttpError(assertStatus, assertErrorMessage) + + let ctx = createCtx() + let flaska = new Flaska({}, faker) + flaska.devMode() + flaska._onerror(assertError, ctx) + assert.strictEqual(ctx.log.error.callCount, 1) + assert.strictEqual(ctx.log.error.firstCall[0], assertError) + assert.strictEqual(ctx.status, assertStatus) + assert.strictEqual(ctx.body.status, assertStatus) + assert.match(ctx.body.message, /Payload Too Large/) + assert.match(ctx.body.message, new RegExp(assertErrorMessage)) + }) + + t.test('default valid handling of HttpError with missing status message', function() { + const assertStatus = 432 + const assertErrorMessage = 'Jet Coaster Ride' + const assertError = new HttpError(assertStatus, assertErrorMessage) + + let ctx = createCtx() + let flaska = new Flaska({}, faker) + flaska.devMode() + flaska._onerror(assertError, ctx) + assert.strictEqual(ctx.log.error.callCount, 1) + assert.strictEqual(ctx.log.error.firstCall[0], assertError) + assert.strictEqual(ctx.status, assertStatus) + assert.strictEqual(ctx.body.status, assertStatus) + assert.match(ctx.body.message, /Internal Server Error/) + }) }) t.describe('#before()', function() { diff --git a/test/flaska.in.test.mjs b/test/flaska.in.test.mjs index e270d2f..64a849f 100644 --- a/test/flaska.in.test.mjs +++ b/test/flaska.in.test.mjs @@ -47,11 +47,8 @@ t.describe('#requestStart()', function() { assert.strictEqual(onReqError.firstCall[0], assertErrorOne) assert.strictEqual(onReqError.firstCall[1], ctx) - assert.strictEqual(assertReq.on.secondCall[0], 'aborted') + assert.strictEqual(assertReq.on.secondCall[0], 'close') assert.strictEqual(typeof(assertReq.on.secondCall[1]), 'function') - assert.notStrictEqual(ctx.aborted, false) - assertReq.on.secondCall[1]() - assert.strictEqual(ctx.aborted, true) // Test abort and close diff --git a/test/flaska.out.test.mjs b/test/flaska.out.test.mjs index a07b5e7..3ae3fd1 100644 --- a/test/flaska.out.test.mjs +++ b/test/flaska.out.test.mjs @@ -1,4 +1,4 @@ -import { Eltro as t, spy, assert} from 'eltro' +import { Eltro as t, spy, assert, stub} from 'eltro' import { Flaska, FlaskaRouter } from '../flaska.mjs' import { fakeHttp, createCtx } from './helper.mjs' @@ -219,6 +219,34 @@ t.describe('#requestEnd()', function() { let flaska = new Flaska({}, fakerHttp, fakeStream) flaska.requestEnd(null, ctx) }) + + let tests = [ + ['png', 'image/png'], + ['jpg', 'image/jpeg'], + ['bmp', 'image/bmp'], + ['js', 'text/javascript'], + ] + + tests.forEach(function(test) { + t.test(`call pipe with file extension ${test[0]} should mimetype ${test[1]}`, function(cb) { + let onFinish = function(source, target) { + try { + assert.strictEqual(ctx.res.statusCode, 200) + assert.strictEqual(ctx.res.setHeader.firstCall[0], 'Content-Type') + assert.strictEqual(ctx.res.setHeader.firstCall[1], test[1]) + assert.strictEqual(source, ctx.body) + assert.strictEqual(target, ctx.res) + cb() + } catch (err) { cb(err) } + } + const ctx = createCtx({}) + ctx.body = { pipe: function() {}, path: './bla/test/temp.' + test[0] } + fakeStream.pipeline = onFinish + + let flaska = new Flaska({}, fakerHttp, fakeStream) + flaska.requestEnd(null, ctx) + }) + }) t.test('should return immediately if req is finished', function() { let onFinish = function() { @@ -260,6 +288,38 @@ t.describe('#requestEnd()', function() { }) t.describe('#requestEnded()', function() { + t.test('calls destroy if body is pipe and is not closed', function() { + const ctx = createCtx({ + body: { + pipe: {}, + closed: false, + destroy: spy(), + }, + }) + let flaska = new Flaska({}, fakerHttp) + flaska._afterCompiled = function() {} + + flaska.requestEnded(ctx) + + assert.ok(ctx.body.destroy.called) + }) + + t.test('not call destroy if body is pipe but is closed', function() { + const ctx = createCtx({ + body: { + pipe: {}, + closed: true, + destroy: spy(), + } + }) + let flaska = new Flaska({}, fakerHttp) + flaska._afterCompiled = function() {} + + flaska.requestEnded(ctx) + + assert.notOk(ctx.body.destroy.called) + }) + t.test('calls afterCompiled correctly', function() { const assertError = new Error('test') const assertCtx = createCtx() diff --git a/test/http.test.mjs b/test/http.test.mjs index c85c4fe..e6607b7 100644 --- a/test/http.test.mjs +++ b/test/http.test.mjs @@ -1,20 +1,64 @@ -import { Eltro as t, assert} from 'eltro' -import { Flaska } from '../flaska.mjs' +import fsSync from 'fs' +import fs from 'fs/promises' +import formidable from 'formidable' +import { Eltro as t, assert, stub } from 'eltro' +import { setTimeout } from 'timers/promises' +import { Flaska, JsonHandler, FormidableHandler } from '../flaska.mjs' import Client from './client.mjs' const port = 51024 -const flaska = new Flaska({}) +const log = { + fatal: stub(), + error: stub(), + warn: stub(), + info: stub(), + debug: stub(), + trace: stub(), + log: stub(), +} +const flaska = new Flaska({ log }) const client = new Client(port) let reqBody = null +let file = null +let uploaded = [] flaska.get('/', function(ctx) { ctx.body = { status: true } }) -flaska.post('/test', function(ctx) { +flaska.post('/json', JsonHandler(), function(ctx) { ctx.body = { success: true } - // console.log(ctx) + reqBody = ctx.req.body }) +flaska.get('/timeout', function(ctx) { + return new Promise(function() {}) +}) +flaska.get('/file', function(ctx) { + file = fsSync.createReadStream('./test/test.png') + ctx.body = file +}) +flaska.post('/file/upload', FormidableHandler(formidable, { + uploadDir: './test/upload' +}), function(ctx) { + uploaded.push(ctx.req.file) + ctx.body = ctx.req.file +}) +flaska.get('/file/leak', function(ctx) { + file = fsSync.createReadStream('./test/test.png') + ctx.body = file + + return new Promise(function() {}) +}) + +function reset() { + log.fatal.reset() + log.error.reset() + log.warn.reset() + log.info.reset() + log.debug.reset() + log.trace.reset() + log.log.reset() +} t.before(function() { return flaska.listenAsync(port) @@ -28,14 +72,152 @@ t.describe('/', function() { }) }) -t.describe('/test', function() { +t.describe('/json', function() { t.test('should return success and store body', async function() { + const assertBody = { a: '' } + for (let i = 0; i < 1010; i++) { + assertBody.a += 'aaaaaaaaaa' + } reqBody = null - let body = await client.post('/test') + let body = await client.post('/json', assertBody) assert.deepEqual(body, { success: true }) + assert.deepStrictEqual(reqBody, assertBody) + }) + + t.test('should fail if body is too big', async function() { + reset() + + const assertBody = { a: '' } + for (let i = 0; i < 10300; i++) { + assertBody.a += 'aaaaaaaaaa' + } + + let err = await assert.isRejected(client.post('/json', assertBody)) + + assert.strictEqual(err.body.status, 413) + assert.ok(log.error.called) + assert.match(log.error.firstCall[0].message, /10240/) + assert.strictEqual(log.error.firstCall[0].status, 413) + }) + + t.test('should fail if not a valid json', async function() { + reset() + + let err = await assert.isRejected(client.customRequest('POST', '/json', 'aaaa')) + + assert.strictEqual(err.body.status, 400) + assert.match(err.body.message, /invalid json/i) + assert.match(err.body.message, /token a/i) + assert.strictEqual(err.body.request, 'aaaa') + assert.strictEqual(log.error.callCount, 1) + assert.match(log.error.firstCall[0].message, /invalid json/i) + assert.match(log.error.firstCall[0].message, /token a/i) + }) + + t.test('should handle incomplete requests correctly', async function() { + reset() + + let req = await client.customRequest('POST', '/json', 'aaaa', { returnRequest: true }) + + req.write('part1') + + await setTimeout(20) + + req.destroy() + + await setTimeout(20) + + assert.strictEqual(log.error.callCount, 0) + assert.strictEqual(log.info.callCount, 1) + assert.strictEqual(log.info.firstCall[0].message, 'aborted') + }) +}) + +t.describe('/timeout', function() { + t.test('server should handle timeout', async function() { + reset() + + let err = await assert.isRejected(client.customRequest('GET', '/timeout', JSON.stringify({}), { timeout: 20 })) + + await setTimeout(20) + + assert.match(err.message, /timed out/) + assert.notOk(log.error.called) + assert.ok(log.info.called) + assert.strictEqual(log.info.firstCall[0].message, 'aborted') + }) +}) + +t.describe('/file', function() { + t.test('server should pipe', async function() { + log.error.reset() + + let target = fsSync.createWriteStream('./test_tmp.png') + + await client.customRequest('GET', '/file', null, { toPipe: target }) + + await setTimeout(20) + + assert.ok(target.closed) + assert.ok(file.closed) + + let [statSource, statTarget] = await Promise.all([ + fs.stat('./test/test.png'), + fs.stat('./test_tmp.png'), + ]) + + assert.strictEqual(statSource.size, statTarget.size) + }) + t.test('server should autoclose body file handles on errors', async function() { + reset() + + file = null + + let req = await client.customRequest('GET', '/file/leak', null, { returnRequest: true }) + + req.end() + + while (!file) { + await setTimeout(10) + } + + assert.ok(file) + assert.notOk(file.closed) + + req.destroy() + + await setTimeout(20) + + while (!file.closed) { + await setTimeout(10) + } + + assert.strictEqual(log.error.callCount, 0) + assert.strictEqual(log.info.callCount, 1) + assert.strictEqual(log.info.firstCall[0].message, 'aborted') + + assert.ok(file.closed) + }) +}) + +t.describe('/file/upload', function() { + t.test('server should upload file', async function() { + let res = await client.upload('/file/upload', './test/test.png') + + let [statSource, statTarget] = await Promise.all([ + fs.stat('./test/test.png'), + fs.stat(res.path), + ]) + + assert.strictEqual(statSource.size, statTarget.size) + assert.strictEqual(statSource.size, res.size) }) }) t.after(function() { - return flaska.closeAsync() + return Promise.all([ + fs.rm('./test_tmp.png', { force: true }), + Promise.all(uploaded.map(file => fs.rm(file.path, { force: true }))), + flaska.closeAsync(), + ]) }) diff --git a/test/middlewares.test.mjs b/test/middlewares.test.mjs index ea81b50..342bac6 100644 --- a/test/middlewares.test.mjs +++ b/test/middlewares.test.mjs @@ -1,5 +1,11 @@ -import { Eltro as t, assert} from 'eltro' -import { QueryHandler, JsonHandler } from '../flaska.mjs' +import os from 'os' +import path from 'path' +import { Buffer } from 'buffer' +import { Eltro as t, assert, stub} from 'eltro' +import { QueryHandler, JsonHandler, FormidableHandler, HttpError } from '../flaska.mjs' +import { createCtx } from './helper.mjs' +import { finished } from 'stream' +import { setTimeout } from 'timers/promises' t.describe('#QueryHandler()', function() { let queryHandler = QueryHandler() @@ -25,24 +31,313 @@ t.describe('#QueryHandler()', function() { t.describe('#JsonHandler()', function() { let jsonHandler = JsonHandler() + let ctx + + t.beforeEach(function() { + ctx = createCtx() + }) t.test('should return a handler', function() { assert.strictEqual(typeof(jsonHandler), 'function') }) - t.test('should support separating query from request url', async function() { + t.test('should support fetching body from request', async function() { const assertBody = { a: 1, temp: 'test', hello: 'world'} let parsed = JSON.stringify(assertBody) - - const ctx = { - req: [ - Promise.resolve(Buffer.from(parsed.slice(0, parsed.length / 2))), - Promise.resolve(Buffer.from(parsed.slice(parsed.length / 2))), - ] - } + let finished = false + let err = null + + jsonHandler(ctx).catch(function(error) { + err = error + }).then(function() { + finished = true + }) + + assert.ok(ctx.req.on.called) + assert.strictEqual(ctx.req.on.firstCall[0], 'data') + assert.strictEqual(ctx.req.on.secondCall[0], 'end') + + assert.strictEqual(finished, false) + + ctx.req.on.firstCall[1](Buffer.from(parsed.slice(0, parsed.length / 2))) + + assert.strictEqual(finished, false) + + ctx.req.on.firstCall[1](Buffer.from(parsed.slice(parsed.length / 2))) + + assert.strictEqual(finished, false) + + ctx.req.on.secondCall[1]() + + await setTimeout(10) + + assert.strictEqual(finished, true) + assert.strictEqual(err, null) - await jsonHandler(ctx) assert.notStrictEqual(ctx.req.body, assertBody) assert.deepStrictEqual(ctx.req.body, assertBody) }) + + t.test('should throw if buffer grows too large', async function() { + let defaultLimit = 10 * 1024 + let segmentSize = 100 + + let finished = false + let err = null + + jsonHandler(ctx).catch(function(error) { + err = error + }).then(function() { + finished = true + }) + + for (let i = 0; i < defaultLimit; i += segmentSize) { + ctx.req.on.firstCall[1](Buffer.alloc(segmentSize, 'a')) + } + + await setTimeout(10) + + assert.strictEqual(finished, true) + assert.notStrictEqual(err, null) + + assert.ok(err instanceof HttpError) + assert.strictEqual(err.status, 413) + assert.match(err.message, new RegExp(100 * 103)) + assert.match(err.message, new RegExp(defaultLimit)) + }) + + t.test('should throw if buffer is not valid json', async function() { + let finished = false + let err = null + + jsonHandler(ctx).catch(function(error) { + err = error + }).then(function() { + finished = true + }) + + ctx.req.on.firstCall[1](Buffer.alloc(10, 'a')) + ctx.req.on.firstCall[1](Buffer.alloc(10, 'a')) + ctx.req.on.secondCall[1]() + + await setTimeout(10) + + assert.strictEqual(finished, true) + assert.notStrictEqual(err, null) + + assert.ok(err instanceof HttpError) + assert.strictEqual(err.status, 400) + assert.match(err.message, /JSON/) + assert.match(err.message, /Unexpected token a in/i) + assert.strictEqual(err.body.status, 400) + assert.match(err.body.message, /Invalid JSON/i) + assert.match(err.body.message, /Unexpected token a in/i) + assert.strictEqual(err.body.request, 'aaaaaaaaaaaaaaaaaaaa') + }) + + t.test('should not throw if body is empty', async function() { + let finished = false + let err = null + + jsonHandler(ctx).catch(function(error) { + err = error + }).then(function() { + finished = true + }) + + assert.ok(ctx.req.on.called) + assert.strictEqual(ctx.req.on.firstCall[0], 'data') + assert.strictEqual(ctx.req.on.secondCall[0], 'end') + + assert.strictEqual(finished, false) + + ctx.req.on.secondCall[1]() + + await setTimeout(10) + + assert.strictEqual(finished, true) + assert.strictEqual(err, null) + + assert.deepStrictEqual(ctx.req.body, {}) + }) +}) + +t.describe('#FormidableHandler()', function() { + let formidable + let incomingForm + let ctx + + t.beforeEach(function() { + ctx = createCtx() + formidable = { + IncomingForm: stub(), + fsRename: stub().resolves(), + } + + incomingForm = { + parse: stub().returnWith(function(req, cb) { + cb(null, {}, { file: { name: 'asdf.png' } }) + }) + } + + formidable.IncomingForm.returns(incomingForm) + }) + + t.test('should call formidable with correct defaults', async function() { + let handler = FormidableHandler(formidable) + await handler(ctx) + + assert.strictEqual(incomingForm.uploadDir, os.tmpdir()) + assert.strictEqual(incomingForm.maxFileSize, 8 * 1024 * 1024) + assert.strictEqual(incomingForm.maxFieldsSize, 10 * 1024) + assert.strictEqual(incomingForm.maxFields, 50) + assert.strictEqual(incomingForm.parse.firstCall[0], ctx.req) + }) + + t.test('should apply fields and rename file before returning', async function() { + const assertFilename = 'Lets love.png' + const assertOriginalPath = 'Hitoshi/Fujima/Yuigi.png' + const assertFile = { a: 1, name: assertFilename, path: assertOriginalPath } + const assertFields = { b: 2, c: 3 } + let handler = FormidableHandler(formidable) + + incomingForm.parse.returnWith(function(req, cb) { + cb(null, assertFields, { file: assertFile }) + }) + + assert.notOk(ctx.req.body) + assert.notOk(ctx.req.file) + let prefix = new Date().toISOString().replace(/-/g, '').replace('T', '_').replace(/:/g, '').split('.')[0] + await handler(ctx) + + assert.strictEqual(ctx.req.body, assertFields) + assert.strictEqual(ctx.req.file, assertFile) + assert.strictEqual(ctx.req.file.path, path.join(os.tmpdir(), ctx.req.file.filename)) + assert.match(ctx.req.file.filename, new RegExp(prefix)) + assert.match(ctx.req.file.filename, new RegExp(assertFilename)) + + assert.ok(formidable.fsRename.called) + assert.strictEqual(formidable.fsRename.firstCall[0], assertOriginalPath) + assert.strictEqual(formidable.fsRename.firstCall[1], ctx.req.file.path) + }) + + t.test('should throw parse error if parse fails', async function() { + const assertError = new Error('Aozora') + + + let handler = FormidableHandler(formidable) + + incomingForm.parse.returnWith(function(req, cb) { + cb(assertError) + }) + + let err = await assert.isRejected(handler(ctx)) + + assert.strictEqual(err, assertError) + }) + + t.test('should throw rename error if rename fails', async function() { + const assertError = new Error('Aozora') + formidable.fsRename.rejects(assertError) + + let handler = FormidableHandler(formidable) + let err = await assert.isRejected(handler(ctx)) + + assert.strictEqual(err, assertError) + }) + + t.test('should not call rename if no file is present', async function() { + const assertNotError = new Error('Aozora') + const assertFields = { a: 1 } + formidable.fsRename.rejects(assertNotError) + + let handler = FormidableHandler(formidable) + + incomingForm.parse.returnWith(function(req, cb) { + cb(null, assertFields, null) + }) + + await handler(ctx) + + assert.strictEqual(ctx.req.body, assertFields) + assert.strictEqual(ctx.req.file, null) + + incomingForm.parse.returnWith(function(req, cb) { + cb(null, assertFields, { file: null }) + }) + + await handler(ctx) + + assert.strictEqual(ctx.req.body, assertFields) + assert.strictEqual(ctx.req.file, null) + }) + + t.test('should throw filename error if filename fails', async function() { + const assertError = new Error('Dallaglio Piano') + + let handler = FormidableHandler(formidable, { + filename: function() { + throw assertError + } + }) + + let err = await assert.isRejected(handler(ctx)) + + assert.strictEqual(err, assertError) + }) + + t.test('should default to original name of filename returns null', async function() { + const assertFilename = 'Herrscher.png' + + let handler = FormidableHandler(formidable, { + filename: function() { + return null + } + }) + + incomingForm.parse.returnWith(function(req, cb) { + cb(null, { }, { file: { name: assertFilename } }) + }) + + await handler(ctx) + + assert.strictEqual(ctx.req.file.filename, assertFilename) + }) + + t.test('should support multiple filename calls in same second', async function() { + const assertFilename = 'Herrscher.png' + + let handler = FormidableHandler(formidable) + + await handler(ctx) + + let file1 = ctx.req.file + + await handler(ctx) + + let file2 = ctx.req.file + + assert.notStrictEqual(file1, file2) + assert.notStrictEqual(file1.filename, file2.filename) + }) + + + t.test('should support parsing fields as json', async function() { + const assertFields = { b: '2', c: '3', e: 'asdf', f: '{"a": 1}' } + let handler = FormidableHandler(formidable, { + parseFields: true, + }) + + incomingForm.parse.returnWith(function(req, cb) { + cb(null, assertFields, { file: { name: 'test.png' } }) + }) + + await handler(ctx) + + assert.strictEqual(ctx.req.body, assertFields) + assert.strictEqual(ctx.req.body.b, 2) + assert.strictEqual(ctx.req.body.c, 3) + assert.strictEqual(ctx.req.body.e, 'asdf') + assert.deepStrictEqual(ctx.req.body.f, {a: 1}) + }) }) diff --git a/test/upload/.gitkeep b/test/upload/.gitkeep new file mode 100644 index 0000000..e69de29