cookies are now parsed and populated on ctx.cookie by deault
[squeep-api-dingus] / lib / dingus.js
1 /* eslint-disable security/detect-object-injection */
2 'use strict';
3
4 /**
5 * A very minimal API server framework.
6 * Just a self-contained router and some request glue.
7 */
8
9 require('./patches');
10 const { promises: fsPromises } = require('fs');
11 const path = require('path');
12 const querystring = require('querystring');
13 const common = require('./common');
14 const ContentNegotiation = require('./content-negotiation');
15 const Enum = require('./enum');
16 const { ResponseError, RouterNoPathError, RouterNoMethodError } = require('./errors');
17 const { extensionToMime } = require('./mime-helper');
18 const Router = require('./router');
19 const Template = require('./template');
20
21 // For logging.
22 const { fileScope } = require('@squeep/log-helper');
23 const _fileScope = fileScope(__filename);
24
25 const defaultOptions = {
26 ignoreTrailingSlash: true,
27 proxyPrefix: '',
28 strictAccept: true,
29 selfBaseUrl: '',
30 staticMetadata: true,
31 staticPath: undefined, // No reasonable default
32 trustProxy: true,
33 intrinsicHeadMethod: true,
34 intrinsicHeadPersistBody: false,
35 querystring,
36 };
37
38 const cookieSplitRE = /; */;
39
40 class Dingus {
41 /**
42 * @param {Object} logger object which implements logging methods
43 * @param {Object} options
44 * @param {Boolean} options.ignoreTrailingSlash requests for '/foo/' will match a '/foo' route
45 * @param {string} options.proxyPrefix leading part of url path to strip
46 * @param {Boolean} options.strictAccept whether to error on unsupported Accept type
47 * @param {string} options.selfBaseUrl for constructing links
48 * @param {Boolean} options.staticMetadata serve static headers with static files
49 * @param {Boolean} options.trustProxy trust some header data to be provided by proxy
50 * @param {Boolean} options.intrinsicHeadMethod handle HEAD requests automatically if not specified as a route method
51 * @param {Boolean} options.intrinsicHeadPersistBody include un-sent body on ctx for automatic HEAD requests
52 * @param {Object} options.querystring alternate qs parser to use
53 */
54 constructor(logger = console, options = {}) {
55 common.setOptions(this, defaultOptions, options);
56
57 this.router = new Router(options);
58
59 if (!this.proxyPrefix) {
60 this._stripPrefix = (p) => p;
61 }
62
63 this.responseTypes = [
64 Enum.ContentType.TextHTML,
65 Enum.ContentType.TextPlain,
66 Enum.ContentType.ApplicationJson,
67 ];
68
69 this.logger = logger;
70 }
71
72
73 /**
74 * Resolve relative and empty paths in url
75 * @param {string} p path
76 */
77 _normalizePath(p) {
78 const pathNorm = path.normalize(p); // This isn't perfectly correct, but it's easy...
79 return this._stripPrefix(pathNorm);
80 }
81
82
83 /**
84 * Remove a leading portion of url path
85 * N.B. This method gets obliterated if there is no prefix defined at construction
86 * @param {string} p path
87 */
88 _stripPrefix(p) {
89 if (p.startsWith(this.proxyPrefix)) {
90 return p.slice(this.proxyPrefix.length);
91 }
92 return p;
93 }
94
95
96 /**
97 * Returns the path part, and querystring object, from a request url.
98 * @param {string} url
99 */
100 _splitUrl(url) {
101 const [ p, qs ] = common.splitFirst(url, '?');
102 return {
103 pathPart: this._normalizePath(p),
104 queryParams: this.querystring.parse(qs),
105 };
106 }
107
108
109 /**
110 * Insert a new path handler
111 * @param {string} method
112 * @param {string} urlPath
113 * @param {fn} handler
114 */
115 on(method, urlPath, handler, ...handlerArgs) {
116 this.router.on(method, urlPath, handler, handlerArgs);
117 }
118
119
120 /**
121 * Common header tagging for all requests.
122 * Add our own identifier, and persist any external transit identifiers.
123 * Sets requestId on ctx to a new uuid.
124 * If X-Request-Id or X-Correlation-Id exist on incoming headers, sets them
125 * on outgoing headers and sets on ctx.
126 * @param {http.ClientRequest} req
127 * @param {http.ServerResponse} res
128 * @param {object} ctx
129 */
130 static tagContext(req, res, ctx) {
131 const requestId = common.requestId();
132 ctx.requestId = requestId;
133 res.setHeader(Enum.Header.RequestId, requestId);
134 [Enum.Header.XRequestId, Enum.Header.XCorrelationId].forEach((h) => {
135 const v = req.getHeader(h);
136 if (v) {
137 ctx[h.replace(/-/g, '')] = v;
138 res.setHeader(h, v);
139 }
140 });
141 return requestId;
142 }
143
144
145 /**
146 *
147 * @param {http.ClientRequest} req
148 */
149 _getAddress(req) {
150 // TODO: RFC7239 Forwarded support
151 const address = (this.trustProxy && req?.getHeader(Enum.Header.XForwardedFor)) ||
152 (this.trustProxy && req?.getHeader(Enum.Header.XRealIP)) ||
153 (req?.connection?.remoteAddress) ||
154 '';
155 return address.split(/\s*,\s*/u)[0];
156 }
157
158
159 /**
160 *
161 * @param {http.ClientRequest} req
162 */
163 _getProtocol(req) {
164 // TODO: RFC7239 Forwarded support
165 const protocol = (this.trustProxy && req?.getHeader(Enum.Header.XForwardedProto)) ||
166 ((req?.connection?.encrypted) ? 'https' : 'http');
167 return protocol.split(/\s*,\s*/u)[0];
168 }
169
170
171 /**
172 * Sets ctx.clientAddress and ctx.clientProtocol.
173 * @param {http.ClientRequest} req
174 * @param {http.ServerResponse} res
175 * @param {object} ctx
176 */
177 clientAddressContext(req, res, ctx) {
178 ctx.clientAddress = this._getAddress(req);
179 ctx.clientProtocol = this._getProtocol(req);
180 }
181
182
183 /**
184 * Sets ctx.cookie from Cookie header.
185 * @param {http.ClientRequest} req
186 * @param {http.ServerResponse} res
187 * @param {object} ctx
188 */
189 static ingestCookie(req, res, ctx) {
190 ctx.cookie = {};
191 req.getHeader(Enum.Header.Cookie)?.split(cookieSplitRE).forEach((cookie) => {
192 const [ name, value ] = common.splitFirst(cookie, '=', null).map((x) => {
193 try {
194 return decodeURIComponent(x.trim());
195 } catch (e) {
196 return x;
197 }
198 });
199 if (name && !(name in ctx.cookie)) {
200 const isQuoted = value?.startsWith('"') && value.endsWith('"');
201 ctx.cookie[name] = isQuoted ? value.slice(1, -1) : value; // eslint-disable-line security/detect-object-injection
202 }
203 });
204 }
205
206
207 /**
208 * Called before every request handler.
209 * Sets tracking identifiers and client information on ctx.
210 * @param {http.ClientRequest} req
211 * @param {http.ServerResponse} res
212 * @param {object} ctx
213 */
214 async preHandler(req, res, ctx) {
215 this.constructor.tagContext(req, res, ctx);
216 this.clientAddressContext(req, res, ctx);
217 this.constructor.ingestCookie(req, res, ctx);
218 }
219
220
221 /**
222 * Helper for collecting chunks as array of buffers.
223 * @param {Buffer[]} chunks
224 * @param {string|Buffer} chunk
225 * @param {string} encoding
226 */
227 static pushBufChunk(chunks, chunk, encoding = 'utf8') {
228 if (chunk) {
229 if (typeof chunk === 'string') {
230 chunk = Buffer.from(chunk, encoding);
231 }
232 chunks.push(chunk);
233 }
234 }
235
236
237 /**
238 * Sets ctx.responseBody and calls handler upon res.end().
239 * @param {http.ClientRequest} req
240 * @param {http.ServerResponse} res
241 * @param {object} ctx
242 * @param {*} handler fn(req, res, ctx)
243 */
244 static setEndBodyHandler(req, res, ctx, handler) {
245 const origWrite = res.write.bind(res);
246 const origEnd = res.end.bind(res);
247 const chunks = [];
248 res.write = function (chunk, encoding, ...rest) {
249 Dingus.pushBufChunk(chunks, chunk, encoding);
250 return origWrite(chunk, encoding, ...rest);
251 };
252 res.end = function (data, encoding, ...rest) {
253 Dingus.pushBufChunk(chunks, data, encoding);
254 ctx.responseBody = Buffer.concat(chunks);
255 handler(req, res, ctx);
256 return origEnd(data, encoding, ...rest);
257 };
258 }
259
260
261 /**
262 * Intercept writes for head requests, do not send to client,
263 * but send length, and make body available in context.
264 * N.B. If persisted, ctx.responseBody will be a raw buffer, be aware when logging.
265 * @param {http.ClientRequest} req
266 * @param {http.ServerResponse} res
267 * @param {object} ctx
268 * @param {Boolean} persistResponseBody
269 */
270 static setHeadHandler(req, res, ctx, persistResponseBody = false) {
271 if (req.method === 'HEAD') {
272 const origEnd = res.end.bind(res);
273 const chunks = [];
274 res.write = function (chunk, encoding) {
275 Dingus.pushBufChunk(chunks, chunk, encoding);
276 // No call to original res.write.
277 };
278 res.end = function (data, encoding, ...rest) {
279 Dingus.pushBufChunk(chunks, data, encoding);
280 const responseBody = Buffer.concat(chunks);
281 res.setHeader(Enum.Header.ContentLength, Buffer.byteLength(responseBody));
282 if (persistResponseBody) {
283 ctx.responseBody = responseBody;
284 }
285 return origEnd(undefined, encoding, ...rest);
286 };
287 }
288 }
289
290
291 /**
292 * Resolve the handler to invoke for a request.
293 * @param {http.ClientRequest} req
294 * @param {http.ServerResponse} res
295 * @param {object} ctx
296 * @returns {object}
297 */
298 _determineHandler(req, res, ctx) {
299 const _scope = _fileScope('_determineHandler');
300
301 const { pathPart, queryParams } = this._splitUrl(req.url);
302 ctx.queryParams = queryParams;
303
304 let handler, handlerArgs = [];
305 try {
306 ({ handler, handlerArgs } = this.router.lookup(req.method, pathPart, ctx));
307 } catch (e) {
308 if (e instanceof URIError) {
309 handler = this.handlerBadRequest.bind(this);
310 } else if (e instanceof RouterNoPathError) {
311 handler = this.handlerNotFound.bind(this);
312 } else if (e instanceof RouterNoMethodError) {
313 if (this.intrinsicHeadMethod && req.method === 'HEAD') {
314 ({ handler, handlerArgs } = this._determineHeadHandler(req, res, ctx, pathPart));
315 } else {
316 handler = this.handlerMethodNotAllowed.bind(this);
317 }
318 } else {
319 this.logger.error(_scope, 'unexpected error', { error: e });
320 handler = this.handlerInternalServerError.bind(this);
321 }
322 }
323 return { handler, handlerArgs };
324 }
325
326
327 /**
328 * For intrinsic HEAD requests, resolve the handler to invoke.
329 * @param {http.ClientRequest} req
330 * @param {http.ServerResponse} res
331 * @param {object} ctx
332 * @param {string} pathPart
333 * @returns {object}
334 */
335 _determineHeadHandler(req, res, ctx, pathPart) {
336 const _scope = _fileScope('_determineHeadHandler');
337 let handler, handlerArgs = [];
338 try {
339 ({ handler, handlerArgs } = this.router.lookup('GET', pathPart, ctx));
340 Dingus.setHeadHandler(req, res, ctx, this.intrinsicHeadPersistBody);
341 } catch (e) {
342 if (e instanceof RouterNoMethodError) {
343 handler = this.handlerMethodNotAllowed.bind(this);
344 } else {
345 this.logger.error(_scope, 'unexpected error', { error: e });
346 handler = this.handlerInternalServerError.bind(this);
347 }
348 }
349 return { handler, handlerArgs };
350 }
351
352
353 /**
354 * Dispatch the handler for a request
355 * @param {http.ClientRequest} req
356 * @param {http.ServerResponse} res
357 * @param {object} ctx
358 */
359 async dispatch(req, res, ctx = {}) {
360 const { handler, handlerArgs } = this._determineHandler(req, res, ctx);
361 try {
362 await this.preHandler(req, res, ctx);
363 return await handler(req, res, ctx, ...handlerArgs);
364 } catch (e) {
365 ctx.error = e;
366 this.sendErrorResponse(e, req, res, ctx);
367 }
368 }
369
370
371 /**
372 * Return normalized type, without any parameters.
373 * @param {http.ClientRequest} req
374 * @returns {string}
375 */
376 static getRequestContentType(req) {
377 const contentType = req.getHeader(Enum.Header.ContentType);
378 return (contentType || '').split(';')[0].trim().toLowerCase();
379 }
380
381
382 /**
383 * Parse rawBody as contentType into ctx.parsedBody.
384 * @param {string} contentType
385 * @param {object} ctx
386 * @param {string|buffer} rawBody
387 */
388 parseBody(contentType, ctx, rawBody) {
389 const _scope = _fileScope('parseBody');
390
391 switch (contentType) {
392 case Enum.ContentType.ApplicationForm:
393 ctx.parsedBody = this.querystring.parse(rawBody);
394 break;
395
396 case Enum.ContentType.ApplicationJson:
397 try {
398 ctx.parsedBody = JSON.parse(rawBody);
399 } catch (e) {
400 this.logger.debug(_scope, 'JSON parse failed', { requestId: ctx.requestId, error: e });
401 throw new ResponseError(Enum.ErrorResponse.BadRequest, e.message);
402 }
403 break;
404
405 default:
406 this.logger.debug(_scope, 'unhandled content-type', { requestId: ctx.requestId, contentType });
407 throw new ResponseError(Enum.ErrorResponse.UnsupportedMediaType);
408 }
409 }
410
411
412 /**
413 * Return all body data from a request.
414 * @param {http.ClientRequest} req
415 * @param {Number=} maximumBodySize
416 * @param {Boolean=} toString
417 */
418 async bodyData(req, maximumBodySize, toString = true) {
419 const _scope = _fileScope('bodyData');
420 return new Promise((resolve, reject) => {
421 const body = [];
422 let length = 0;
423 req.on('data', (chunk) => {
424 body.push(chunk);
425 length += Buffer.byteLength(chunk);
426 if (maximumBodySize && length > maximumBodySize) {
427 this.logger.debug(_scope, 'body data exceeded limit', { length, maximumBodySize });
428 reject(new ResponseError(Enum.ErrorResponse.RequestEntityTooLarge));
429 }
430 });
431 req.on('end', () => {
432 const bodyBuffer = Buffer.concat(body);
433 resolve(toString ? bodyBuffer.toString() : bodyBuffer);
434 });
435 req.on('error', (e) => {
436 this.logger.error(_scope, 'failed', { error: e });
437 reject(e);
438 });
439 });
440 }
441
442
443 /**
444 * Read and parse request body data.
445 * Sets ctx.parsedBody, and optionally ctx.rawBody.
446 * @param {http.ClientRequest} req
447 * @param {http.ServerResponse} res
448 * @param {object} ctx
449 * @param {object}
450 * @param {Boolean} .parseEmptyBody
451 * @param {Boolean} .persistRawBody
452 */
453 async ingestBody(req, res, ctx, { parseEmptyBody = true, persistRawBody = false, maximumBodySize } = {}) {
454 const rawBody = await this.bodyData(req, maximumBodySize);
455 if (persistRawBody) {
456 ctx.rawBody = rawBody;
457 }
458 if (rawBody || parseEmptyBody) {
459 const contentType = Dingus.getRequestContentType(req);
460 this.parseBody(contentType, ctx, rawBody);
461 }
462 }
463
464
465 /**
466 * Return the best matching response type.
467 * @param {string[]} responseTypes
468 * @param {http.ClientRequest} req
469 */
470 static getResponseContentType(responseTypes, req) {
471 const acceptHeader = req.getHeader(Enum.Header.Accept);
472 return ContentNegotiation.accept(responseTypes, acceptHeader);
473 }
474
475
476 /**
477 * Returns a list of the most-preferred content encodings for the response.
478 * @param {string[]} responseEncodings
479 * @param {http.ClientRequest} req
480 */
481 static getResponseEncoding(responseEncodings, req) {
482 const acceptEncodingHeader = req.getHeader(Enum.Header.AcceptEncoding);
483 return ContentNegotiation.preferred(responseEncodings, acceptEncodingHeader);
484 }
485
486
487 /**
488 * Set the best content type for the response.
489 * Sets ctx.responseType, and Content-Type header.
490 * @param {string[]} responseTypes default first
491 * @param {http.ClientRequest} req
492 * @param {http.ServerResponse} res
493 * @param {object} ctx
494 */
495 setResponseType(responseTypes, req, res, ctx) {
496 const _scope = _fileScope('setResponseType');
497 ctx.responseType = Dingus.getResponseContentType(responseTypes, req);
498 if (!ctx.responseType) {
499 if (this.strictAccept) {
500 this.logger.debug(_scope, 'unhandled strict accept', { requestId: req.requestId });
501 throw new ResponseError(Enum.ErrorResponse.NotAcceptable);
502 } else {
503 ctx.responseType = responseTypes[0];
504 }
505 }
506 res.setHeader(Enum.Header.ContentType, ctx.responseType);
507 }
508
509
510 /**
511 * Inserts an encoding into Content-Encoding header.
512 * @param {http.ServerResponse} res
513 * @param {string} encoding
514 */
515 static addEncodingHeader(res, encoding) {
516 const existingEncodings = res.getHeader(Enum.Header.ContentEncoding);
517 if (existingEncodings) {
518 encoding = `${encoding}, ${existingEncodings}`;
519 }
520 res.setHeader(Enum.Header.ContentEncoding, encoding);
521 }
522
523
524 /**
525 * Attempt to fetch both data and metadata for a file.
526 * @param {string} filePath
527 */
528 async _readFileInfo(filePath) {
529 const _scope = _fileScope('_readFileInfo');
530 let result;
531 try {
532 // eslint-disable-next-line security/detect-non-literal-fs-filename
533 const stat = fsPromises.stat(filePath);
534 // eslint-disable-next-line security/detect-non-literal-fs-filename
535 const data = fsPromises.readFile(filePath);
536 result = await Promise.all([stat, data]);
537 } catch (e) {
538 if (['ENOENT', 'EACCES', 'EISDIR', 'ENAMETOOLONG', 'EINVAL'].includes(e.code)) {
539 return [null, null];
540 }
541 this.logger.error(_scope, 'fs error', { error: e, filePath });
542 throw e;
543 }
544 return result;
545 }
546
547
548 /**
549 * Potentially add additional headers from static file meta-file.
550 * @param {http.ServerResponse} res
551 * @param {string} directory
552 * @param {string} fileName - already normalized and filtered
553 */
554 async _serveFileMetaHeaders(res, directory, fileName) {
555 const _scope = _fileScope('_serveFileMetaHeaders');
556
557 let added = false;
558 const metaPrefix = '.';
559 const metaSuffix = '.meta';
560 const metaFileName = `${metaPrefix}${fileName}${metaSuffix}`;
561 const metaFilePath = path.join(directory, metaFileName);
562
563 const [stat, data] = await this._readFileInfo(metaFilePath);
564 if (!stat) {
565 return added;
566 }
567
568 const lineBreakRE = /\r\n|\n|\r/;
569 const lines = data.toString().split(lineBreakRE);
570 common.unfoldHeaderLines(lines);
571
572 const headerParseRE = /^(?<name>[^:]+): +(?<value>.*)$/;
573 lines.forEach((line) => {
574 if (line) {
575 const result = headerParseRE.exec(line);
576 const { groups: header } = result;
577 res.setHeader(header.name, header.value);
578 added = true;
579 }
580 });
581 return added;
582 }
583
584
585 /**
586 * Serve a file from a directory, with rudimentary cache awareness.
587 * This will also serve pre-encoded variations if available and requested.
588 * @param {http.ClientRequest} req
589 * @param {http.ServerResponse} res
590 * @param {object} ctx
591 * @param {string} directory
592 * @param {string} fileName
593 */
594 async serveFile(req, res, ctx, directory, fileName) {
595 const _scope = _fileScope('serveFile');
596 this.logger.debug(_scope, 'called', { req, ctx });
597
598 // Require a directory field.
599 if (!directory) {
600 this.logger.debug(_scope, 'rejected unset directory', { fileName });
601 return this.handlerNotFound(req, res, ctx);
602 }
603
604 // Normalize the supplied path, as encoded path-navigation may have been (maliciously) present.
605 fileName = path.normalize(fileName);
606
607 // We will not deal with any subdirs, nor any dot-files.
608 // (Note that we could not deal with subdirs even if we wanted, due to simple router matching scheme.)
609 if (fileName.indexOf(path.sep) >= 0
610 || fileName.startsWith('.')) {
611 this.logger.debug(_scope, 'rejected filename', { fileName });
612 return this.handlerNotFound(req, res, ctx);
613 }
614
615 const filePath = path.join(directory, fileName);
616
617 // File must exist, before any alternate static encodings will be considered.
618 let [stat, data] = await this._readFileInfo(filePath);
619 if (!stat) {
620 return this.handlerNotFound(req, res, ctx);
621 }
622
623 // If encodings were requested, check for static versions to serve.
624 // Update stat and data if matching version is found.
625 ctx.availableEncodings = Dingus.getResponseEncoding(Object.values(Enum.EncodingType), req);
626 if (ctx.availableEncodings.length === 0) {
627 // Identity encoding was specifically denied, and nothing else available.
628 this.logger.debug(_scope, 'no suitable encodings', { ctx });
629 return this.handlerMethodNotAllowed(req, res, ctx);
630 }
631 for (const encoding of ctx.availableEncodings) {
632 if (encoding === Enum.EncodingType.Identity) {
633 break;
634 }
635 const suffix = Enum.EncodingTypeSuffix[encoding];
636 if (!suffix) {
637 this.logger.error(_scope, 'supported encoding missing mapped suffix', { ctx, encoding });
638 continue;
639 }
640 const encodedFilePath = `${filePath}${suffix}`;
641 const [ encodedStat, encodedData ] = await this._readFileInfo(encodedFilePath);
642 if (encodedStat) {
643 ([ stat, data ] = [ encodedStat, encodedData ]);
644 ctx.selectedEncoding = encoding;
645 Dingus.addEncodingHeader(res, encoding);
646 res.setHeader(Enum.Header.Vary, Enum.Header.AcceptEncoding);
647 this.logger.debug(_scope, 'serving encoded version', { ctx, encodedFilePath });
648 }
649 break;
650 }
651
652 const lastModifiedDate = new Date(stat.mtimeMs);
653 res.setHeader(Enum.Header.LastModified, lastModifiedDate.toGMTString());
654
655 const eTag = common.generateETag(filePath, stat, data);
656 res.setHeader(Enum.Header.ETag, eTag);
657
658 if (common.isClientCached(req, stat.mtimeMs, eTag)) {
659 this.logger.debug(_scope, 'client cached file', { filePath });
660 res.statusCode = 304; // Not Modified
661 res.end();
662 return;
663 }
664
665 // Set the type based on extension of un-encoded filename.
666 const ext = path.extname(filePath).slice(1); // Drop the dot
667 const contentType = extensionToMime(ext);
668 res.setHeader(Enum.Header.ContentType, contentType);
669
670 // We presume static files are relatively cacheable.
671 res.setHeader(Enum.Header.CacheControl, 'public');
672
673 if (this.staticMetadata) {
674 ctx.metaHeaders = await this._serveFileMetaHeaders(res, directory, fileName);
675 }
676
677 this.logger.debug(_scope, 'serving file', { filePath, contentType });
678 res.end(data);
679 }
680
681
682 /**
683 * Return a content-type appropriate rendering of an errorResponse object.
684 * @param {string} type content-type of response
685 * @param {object} err either an Error object, or an error response
686 * @param {number} err.statusCode
687 * @param {string} err.errorMessage
688 * @param {string|string[]} err.details
689 */
690 // eslint-disable-next-line class-methods-use-this
691 renderError(contentType, err) {
692 switch (contentType) {
693 case Enum.ContentType.ApplicationJson:
694 return JSON.stringify(err);
695
696 case Enum.ContentType.TextHTML:
697 return Template.errorHTML(err);
698
699 case Enum.ContentType.TextPlain:
700 default:
701 return [err.errorMessage, err.details].join('\r\n');
702 }
703 }
704
705
706 /**
707 * Send an error response. Terminal.
708 * Logs any non-error-response errors as such.
709 * @param {object} err either an Error object, or an error response
710 * @param {http.ClientRequest} req
711 * @param {http.ServerResponse} res
712 * @param {object} ctx
713 */
714 sendErrorResponse(err, req, res, ctx) {
715 const _scope = _fileScope('sendErrorResponse');
716 let body;
717
718 // Default to a content type if one is not yet present
719 if (!res.hasHeader(Enum.Header.ContentType)) {
720 res.setHeader(Enum.Header.ContentType, Enum.ContentType.TextPlain);
721 }
722
723 if (err?.statusCode) {
724 res.statusCode = err.statusCode;
725 body = this.renderError(res.getHeader(Enum.Header.ContentType), err);
726 this.logger.debug(_scope, 'handler error', { err, req, res, ctx });
727 } else {
728 res.statusCode = 500;
729 body = this.renderError(res.getHeader(Enum.Header.ContentType), Enum.ErrorResponse.InternalServerError);
730 this.logger.error(_scope, 'handler exception', { err, req, res, ctx });
731 }
732 res.end(body);
733 }
734
735
736 /**
737 * @param {http.ClientRequest} req
738 * @param {http.ServerResponse} res
739 * @param {object} ctx
740 * @param {String} file - override ctx.params.file
741 */
742 async handlerGetStaticFile(req, res, ctx, file) {
743 Dingus.setHeadHandler(req, res, ctx);
744
745 // Set a default response type to handle any errors; will be re-set to serve actual static content type.
746 this.setResponseType(this.responseTypes, req, res, ctx);
747
748 await this.serveFile(req, res, ctx, this.staticPath, file || ctx.params.file);
749 }
750
751
752 /**
753 * @param {http.ClientRequest} req
754 * @param {http.ServerResponse} res
755 * @param {Object} ctx
756 * @param {String} newPath
757 * @param {Number} statusCode
758 */
759 async handlerRedirect(req, res, ctx, newPath, statusCode = 307) {
760 this.setResponseType(this.responseTypes, req, res, ctx);
761 res.setHeader(Enum.Header.Location, newPath);
762 res.statusCode = statusCode;
763 res.end();
764 }
765
766
767 /**
768 * @param {http.ClientRequest} req
769 * @param {http.ServerResponse} res
770 * @param {object} ctx
771 */
772 async handlerMethodNotAllowed(req, res, ctx) {
773 this.setResponseType(this.responseTypes, req, res, ctx);
774 throw new ResponseError(Enum.ErrorResponse.MethodNotAllowed);
775 }
776
777
778 /**
779 * @param {http.ClientRequest} req
780 * @param {http.ServerResponse} res
781 * @param {object} ctx
782 */
783 async handlerNotFound(req, res, ctx) {
784 this.setResponseType(this.responseTypes, req, res, ctx);
785 throw new ResponseError(Enum.ErrorResponse.NotFound);
786 }
787
788
789 /**
790 * @param {http.ClientRequest} req
791 * @param {http.ServerResponse} res
792 * @param {object} ctx
793 */
794 async handlerBadRequest(req, res, ctx) {
795 this.setResponseType(this.responseTypes, req, res, ctx);
796 throw new ResponseError(Enum.ErrorResponse.BadRequest);
797 }
798
799
800 /**
801 * @param {http.ClientRequest} req
802 * @param {http.ServerResponse} res
803 * @param {object} ctx
804 */
805 async handlerInternalServerError(req, res, ctx) {
806 this.setResponseType(this.responseTypes, req, res, ctx);
807 throw new ResponseError(Enum.ErrorResponse.InternalServerError);
808 }
809
810 }
811
812 module.exports = Dingus;