@ladjs/api
Version:
API server for Lad
250 lines (210 loc) • 7.36 kB
JavaScript
const process = require('node:process');
const http = require('node:http');
const https = require('node:https');
const util = require('node:util');
const Cabin = require('cabin');
const I18N = require('@ladjs/i18n');
const Passport = require('@ladjs/passport');
const Koa = require('koa');
const Redis = require('@ladjs/redis');
const StoreIPAddress = require('@ladjs/store-ip-address');
const Timeout = require('koa-better-timeout');
const _ = require('lodash');
const auth = require('koa-basic-auth');
const bodyParser = require('koa-bodyparser');
const conditional = require('koa-conditional-get');
const cors = require('kcors');
const errorHandler = require('koa-better-error-handler');
const etag = require('koa-etag');
const json = require('koa-json');
const koa404Handler = require('koa-404-handler');
const koaConnect = require('koa-connect');
const multimatch = require('multimatch');
const ratelimit = require('@ladjs/koa-simple-ratelimit');
const requestId = require('express-request-id');
const requestReceived = require('request-received');
const responseTime = require('response-time');
const sharedConfig = require('@ladjs/shared-config');
const { boolean } = require('boolean');
// https://gist.github.com/titanism/241fc0c5f1c1a0b7cae3d97580e435fb
function removeTrailingSlashes(ctx, next) {
const { path, search } = ctx.request;
if (path !== '/' && !path.startsWith('//') && path.slice(-1) === '/') {
const redirectUrl = path.slice(0, -1) + search;
ctx.response.status = 301;
ctx.redirect(redirectUrl);
return;
}
return next();
}
class API {
// eslint-disable-next-line complexity
constructor(config, Users) {
this.config = {
...sharedConfig('API'),
removeTrailingSlashes: true,
prettyPrintedJSON: process.env.PRETTY_PRINTED_JSON
? Boolean(process.env.PRETTY_PRINTED_JSON)
: false,
...config
};
// Initialize the app
const app = new Koa();
// Only trust proxy if enabled
app.proxy = boolean(process.env.TRUST_PROXY);
// Specify that this is our api (used by error handler)
app.context.api = true;
// Initialize cabin
this.logger = _.isPlainObject(this.config.logger)
? new Cabin(this.config.logger)
: this.config.logger instanceof Cabin
? this.config.logger
: new Cabin({
logger: this.config.logger || console
});
app.context.logger = this.logger;
// Initialize redis
this.client =
this.config.client ||
(this.config.redis === false
? false
: _.isPlainObject(this.config.redis)
? new Redis(this.config.redis, this.logger, this.config.redisMonitor)
: this.config.redis);
app.context.client = this.client;
// Expose passport
this.passport =
this.config.passport === false
? false
: _.isPlainObject(this.config.passport)
? new Passport(this.config.passport, Users)
: this.config.passport;
app.context.passport = this.passport;
// Listen for errors emitted by app
app.on('error', (error, ctx) => {
ctx.logger[error.status && error.status < 500 ? 'warn' : 'error'](error);
});
// Override koa's undocumented error handler
app.context.onerror = errorHandler();
// Adds request received hrtime and date symbols to request object
// (which is used by Cabin internally to add `request.timestamp` to logs
app.use(requestReceived);
// Configure timeout
if (this.config.timeout) {
const timeout = new Timeout(this.config.timeout);
app.use(timeout.middleware);
}
// Adds `X-Response-Time` header to responses
app.use(koaConnect(responseTime()));
// Adds or re-uses `X-Request-Id` header
app.use(koaConnect(requestId()));
// Use the cabin middleware (adds request-based logging and helpers)
app.use(this.logger.middleware);
// Allow before hooks to get setup
if (_.isFunction(this.config.hookBeforeSetup))
this.config.hookBeforeSetup(app);
// Basic auth
if (this.config.auth) app.use(auth(this.config.auth));
// Remove trailing slashes
if (this.config.removeTrailingSlashes) app.use(removeTrailingSlashes);
// I18n
if (this.config.i18n) {
const i18n = this.config.i18n.config
? this.config.i18n
: new I18N({ ...this.config.i18n, logger: this.logger });
app.use(i18n.middleware);
}
// Conditional-get
app.use(conditional());
// Etag
app.use(etag());
// Cors
if (this.config.cors) {
app.use(cors(this.config.cors));
}
// Body parser
// POST /v1/logs (1 MB max so 1.1 MB w/overhead)
// POST /v1/emails (50 MB max so 51 MB w/overhead)
if (this.config.bodyParser !== false)
app.use((ctx, next) => {
// check against ignored paths
if (
Array.isArray(this.config.bodyParserIgnoredPathGlobs) &&
this.config.bodyParserIgnoredPathGlobs.length > 0
) {
const match = multimatch(
ctx.path,
this.config.bodyParserIgnoredPathGlobs
);
if (Array.isArray(match) && match.length > 0) return next();
}
return bodyParser(this.config.bodyParser || {})(ctx, next);
});
// pretty-printed json responses
// (default unless in development/test environment)
app.use(json({ pretty: this.config.prettyPrintedJSON, param: 'pretty' }));
// Passport
if (this.passport) app.use(this.passport.initialize());
// Rate limiting
if (this.client && this.config.rateLimit)
app.use(
ratelimit({
...this.config.rateLimit,
db: this.client,
logger: this.logger
})
);
// Store the user's last ip address in the background
if (this.config.storeIPAddress) {
const storeIPAddress = new StoreIPAddress({
logger: this.logger,
...this.config.storeIPAddress
});
app.use(storeIPAddress.middleware);
}
// 404 handler
app.use(koa404Handler);
// X-Robots-Tag
// <https://developers.google.com/search/docs/crawling-indexing/robots-meta-tag>
app.use((ctx, next) => {
ctx.set('X-Robots-Tag', 'none');
return next();
});
// Allow before hooks to get setup
if (_.isFunction(this.config.hookBeforeRoutes))
this.config.hookBeforeRoutes(app, this.config);
// Mount the app's defined and nested routes
if (this.config.routes) {
if (_.isFunction(this.config.routes.routes)) {
app.use(this.config.routes.routes());
} else {
app.use(this.config.routes);
}
}
// Start server on either http or https
this.server =
this.config.protocol === 'https'
? https.createServer(this.config.ssl, app.callback())
: http.createServer(app.callback());
// Expose the app
this.app = app;
// Bind listen/close to this
this.listen = this.listen.bind(this);
this.close = this.close.bind(this);
}
async listen(
port = this.config.port,
host = this.config.serverHost,
...args
) {
await util.promisify(this.server.listen).bind(this.server)(
port,
host,
...args
);
}
async close() {
await util.promisify(this.server.close).bind(this.server)();
}
}
module.exports = API;