GCSBOSS/nodecaf

View on GitHub
lib/response.js

Summary

Maintainability
A
35 mins
Test Coverage
const { sign } = require('cookie-signature');
const cookie = require('./cookie');

const { HTTPError, handleError } = require('./error');
const { getContentTypeFromDataType } = require('./types');

const SHORT_CONTENT_TYPES = {
    'text': 'text/plain',
    'json': 'application/json'
};

const ASSERTS = {
    badRequest: 400,
    unauthorized: 401,
    forbidden: 403,
    notFound: 404,
    conflict: 409,
    gone: 410,
    badType: 415
};

function format(str, ...args){
    let lastPos = 0;
    while(args.length > 0){
        const pos = str.indexOf('%s', lastPos);
        
        if(pos == -1)
            break;

        lastPos = pos;
        if(pos > 0 && str.charAt(pos - 1) == '%'){
            str = str.replace('%%s', '%s', lastPos);
            continue;
        }

        str = str.replace('%s', String(args.shift()));
    }

    if(args.length > 0)
        str += ' ' + args.join(' ');

    return str;
}

const decorator = {

    end(body){

        if(this.finished)
            this.input.log.warn({ err: new Error('Called `res.end()` after response was already finished') });

        body && this.write(body);
        this._realEnd();

        this.input.log.debug({
            ...this.reqInfo,
            status: this.statusCode,
            level: this.statusCode > 499 ? 'warn' : 'debug',
            type: 'response',
            msg: 'Sent ' + this.statusCode + ' response to ' + this.reqInfo.method + ' ' + this.reqInfo.path
        });

        this.finished = true;
        return this.resolve({
            status: this.statusCode,
            headers: this.headers,
            body: this
        });
    },

    // Not necessarily an APP error, but a client Error
    error(status, message, ...args){

        // If it's NOT a status, handle as an Error
        if(!Number.isInteger(status))
            return handleError(status, this.input);

        this.status(status);
        const type = getContentTypeFromDataType(message);

        if(typeof message == 'string')
            message = format(message, ...args);
        else if(type == 'application/json')
            message = JSON.stringify(message);
        else if(type == 'text/plain')
            message = String(message);

        type && this.type(type);
        this.end(message);

        return new HTTPError(status, message, type);
    },

    assert(status, cond, message, ...args){
        if(!cond)
            return true;
        throw this.error(status, message, ...args);
    },

    get(k){
        return this.headers[k.toLowerCase()];
    },

    set(k, v){
        this.setHeader?.(k, v);
        this.headers[k] = v;
        return this;
    },

    append(k, v){
        const prev = this.headers[k];
        prev && (v = Array.isArray(prev)
            ? prev.concat(v)
            : [ prev, v ]);
        return this.set(k, v);
    },

    status(s){
        this.statusCode = s;
        return this;
    },

    type(ct){
        this.set('Content-Type', SHORT_CONTENT_TYPES[ct] || ct);
        return this;
    },

    json(data){
        this.type('json');
        this.end(JSON.stringify(data));
        return this;
    },

    text(data){
        this.type('text');
        this.end(String(data));
        return this;
    },

    clearCookie(name, opts) {
        opts = { path: '/', ...opts, expires: new Date(1) };
        delete opts.maxAge;
        return this.cookie(name, '', opts);
    },

    cookie(name, value, opts = {}) {
        if(opts.signed)
            this.input.log.warn('Setting `signed` cookies is deprecated. This option will be dropped on `v0.14.0`. Cookie signing must be done manually instead.');

        if(value && opts.signed && !this.input.conf.cookie?.secret)
            throw new Error('Trying to sign cookies when secret is not defined');

        if(opts.signed && value)
            value = sign(value, this.input.conf.cookie.secret);
  
        this.append('Set-Cookie', cookie.serialize(name, value, opts));

        return this;
    }
};

module.exports.getDecoratedRes = function (input, reqInfo) {
    const res = input.res;

    res.reqInfo = reqInfo;
    res.input = input;
    res.ended = new Promise((resolve, reject) => {
        res.resolve = resolve;
        res.reject = reject;
    });
    res.headers = {};
    res.statusCode = 200;

    res._realEnd = res.end;

    Object.assign(res, decorator);

    for(const name in ASSERTS)
        res[name] = res.assert.bind(res, ASSERTS[name]);

    return res;
}

// TODO 406 notAcceptable:
// TODO 405 methodNotAllowed
// TODO 408 Request Timeout
// TODO 411 Length Required
// TODO 413 Payload Too Large
// TODO 414 Request-URI Too Long
// TODO for WS: 426 Upgrade Required
// TODO 429 Too Many Requests
// TODO 431 Request Header Fields Too Large