Steroids helps building backend solutions with Node.js, Express, and TypeScript by introducing new components that are easy and fast to develop.
Here's a quick list of features Steroids provides:
- TypeScript enabled
- Powered by Express
- Automatic code minification
- Logic is encapsulated into "Services" and router middlewares are grouped as "Routers"
- Routes are easily defined using the Router decorator
- Built-in input validation mechanism with routers (request body, headers, query parameters, etc.)
- Ability to extend the validation logic with custom validators
- Dynamic route and service installation
- Dynamic service injection without circular dependency issues
- Path alias support for easier imports
- Customizable built-in logger with file manager and auto archiver
- Unit testing with Mocha and Chai
- TypeDoc ready
You can use the template repository directly or use the Steroids CLI for even faster development (recommended). Steroids CLI allows creating new backend projects using Steroids template (with custom configuration) and also to add components (such as routers, services, etc.) with template code.
npm start
: Builds and runs the server.npm test
: Builds and runs the tests.npm run build
: Build the server intodist
.npm run launch
: Runs the last build.npm run docs
: Builds the internal documentation.
If launching from the project root, run any of the following:
npm run launch
npm run start
(builds first)sd run
(using Steroids CLI)node dist/@steroids/main
If running from the dist directory, run node @steroids/main
NOTE: If running the server from any other directory where CWD is not dist or project root, path aliases (TypeScript paths defined in tsconfig.json) will fail to resolve.
Steroids introduces two new components to work with: Services and Routers.
A service is basically a singleton class which is shared and accessible throughout the whole app (really similar to Angular singleton/global services).
To build a service, simply create a file with the extension .service.ts
anywhere inside the src
directory and decorate it using the @Service
decorator as shown below:
// all imports are from ''@steroids/core'
import { Service } from '@steroids/core';
@Service({
name: 'foo'
})
export class FooService {
log() {
console.log('Foo service!');
}
}
This file will be automatically picked up by the server, so there's no need for any installation. You can then inject these services into your routers and also into other services, as shown below:
import { Service, OnInjection } from '@steroids/core';
import { FooService } from '@steroids/service/foo'; // For typings
@Service({
name: 'bar'
})
export class BarService implements OnInjection {
private foo: FooService;
// Inject the service
onInjection(services: any) {
this.foo = services.foo;
this.foo.log(); // Logs: Foo service!
}
}
Similar to services, you can build a router by creating a file with the extension .router.ts
stored anywhere inside the src
directory. The @Router
decorator is then used to decorate the class as a router while providing route definitions and other metadata.
import { Router, OnInjection } from '@steroids/core';
import { BarService } from '@steroids/service/bar';
@Router({
name: 'router1'
})
export class Router1 implements OnInjection {
private bar: BarService;
onInjection(services: any) {
this.bar = services.bar;
}
}
The @Router
decorator accepts the following properties:
Key | Type | Description |
---|---|---|
name | string | The name of the router (only used for logging). |
priority | number | Routers are sorted by priority before mounting their middleware in the Express stack (defaults to 0 ). |
routes | RouteDefinition[] | An array of route definitions. |
The RouteDefinition
interface is as follows:
Key | Type | Required | Description |
---|---|---|---|
path | string | Yes | The path of the route (identical to app.use(path)). |
handler | string | Yes | The name of the route handler function (must exist in the router class). |
method | RouteMethod | No | The HTTP method of the route. If not provided, the route will cover all methods (global). |
validate | ValidationRule[] | No | Used to easily install validators for validating input (body, header, etc.) |
The following is an example of a simple router which defines the route GET /test
linked to the Router1.routeHandler1
route handler:
import { Router, RouteMethod } from '@steroids/core';
import { Request, Response, NextFunction } from 'express';
@Router({
name: 'router1',
routes: [
{ path: '/test', method: RouteMethod.GET, handler: 'routeHandler1' }
]
})
export class Router1 {
routeHandler1(req: Request, res: Response) {
res.status(200).send('OK');
}
}
There are four types of validation in routers: Headers, Query Parameters, Body (only JSON and urlencoded), and custom validation:
header({ name: value, ... })
: With header validation you can check if headers are present and set with the required value.query(['paramName', ...])
: The query parameters validator can only check the presence of the query input.body({ key: ValidatorFunction })
: Body validators can create complex validation with ease by combining different logic on each key validation.custom(ValidatorFunction)
: If none of the above fit your needs, you can always take control!
Note: You can stack multiple validators of the same kind inside the
validate
array.
When validation is used, the requests that won't pass the validation will automatically get rejected with a validation error.
import { Router, RouteMethod, header } from '@steroids/core';
@Router({
name: 'router1',
priority: 100,
routes: [
{ path: '/postdata', handler: 'postHandler', method: RouteMethod.POST, validate: [
header({ 'content-type': 'application/json' })
]}
]
})
export class Router1 {
postHandler(req, res) {
// req.body is ensured to be valid JSON
}
}
import { Router, query } from '@steroids/core';
@Router({
name: 'router1',
priority: 100,
routes: [
{ path: '*', handler: 'authHandler', validate: [
query(['token'])
]}
]
})
export class Router1 {
authHandler(req, res) {
// req.query.token is definitely provided
}
}
Now let's get crazy! Let's write a validator which requires the following JSON body:
Key | Requirement |
---|---|
title | Must be present and a valid string. |
authors | Must be present and a valid array of strings with at least one entry. |
co-authors | Optional, but if present, it has the same requirement as authors but all entries must be prefixed with co- ! |
release | A namespace. |
release.year | Must be a valid number between 2000 and 2019 . |
release.sells | Must be a valid number. |
price | Cannot be a boolean. |
import {
Router,
RouteMethod,
body,
type,
len,
opt,
and,
match,
not
} from '@steroids/core';
@Router({
name: 'router1',
priority: 100,
routes: [
{ path: '/book/new', handler: 'newBook', method: RouteMethod.POST, validate: [
body({
title: type.string,
authors: type.array(type.string, len.min(1)),
'co-authors': opt(type.array(and(type.string, match(/^co-.+$/))), len.min(1)),
release: {
year: and(type.number, range(2000, 2019)),
sells: type.number
},
price: not(type.boolean)
})
]}
]
})
export class Router1 {
newBook(req, res) {
// req.body has passed the validation test
}
}
Properties of the body object can be checked against any enums (including string enums) using the type.ofenum
validator:
import {
Router,
RouteMethod,
body,
type
} from '@steroids/core';
enum Category {
Category1,
Category2
}
@Router({
name: 'router1',
priority: 100,
routes: [
{ path: '/test', handler: 'testHandler', method: RouteMethod.POST, validate: [
body({
category: type.ofenum(Category)
})
]}
]
})
export class Router1 {
testHandler(req, res) {
// req.body has passed the validation test
}
}
An array of objects can be validated using the sub
validator. It takes a body validator object (the same object the body
function takes except there can be no nested objects) and validates all the items inside the array against it:
import {
Router,
RouteMethod,
body,
type,
sub,
len
} from '@steroids/core';
@Router({
name: 'router1',
priority: 100,
routes: [
{ path: '/test', handler: 'testHandler', method: RouteMethod.POST, validate: [
body({
authors: type.array(sub({
firstName: type.string,
lastName: type.string
}), len.min(1))
})
]}
]
})
export class Router1 {
testHandler(req, res) {
// req.body has passed the validation test
}
}
Here's an in-depth documentation on all the built-in body validator functions:
Validator | Signature | Description |
---|---|---|
type.string | NA | Checks if the value is a valid string. |
type.number | NA | Checks if the value is a valid number. |
type.boolean | NA | Checks if the value is a valid boolean. |
type.nil | NA | Checks if the value is null. |
type.array | type.array([validator, arrayValidator]) | Checks if the value is a valid array. If the validator is provided, it also validates each item of the array against it, if the arrayValidator is provided, the array itself is validated against it (useful for enforcing length restrictions on the array). |
type.ofenum | type.ofenum(enumerator) | Checks if the value is included in the given enumerator. |
equal | equal(val) | Checks if the body value is equal to the given value. |
or | or(...validators) | ORs all given validators. |
and | and(...validators) | ANDs all given validators. |
not | not(validator) | Negates the given validator. |
opt | opt(validator) | Applies the given validator only if the value is present (makes the value optional). |
match | match(regex) | Validates the value against the given regular expression (without string type check). |
num.min | num.min(val) | Checks if the value is greater than or equal to the given number. |
num.max | num.max(val) | Checks if the value is less than or equal to the given number. |
num.range | num.range(min, max) | Checks if the value is between the given range (inclusive). |
len.min | len.min(val) | Checks if the length of the value is greater than or equal to the given number. |
len.max | len.max(val) | Checks if the length of the value is less than or equal to the given number. |
len.range | len.range(min, max) | Checks if the length of the value is between the given range (inclusive). |
sub | sub(bodyValidator) | Validates an object against the given flat body validator (useful for validating arrays of objects). |
If you need to perform a more complex body validation, you can always create a validator function or factory that takes arguments and returns a validator function. Below is an example of a custom body validator which validates the property oddOnly
on the body using a validator function and evenOnly
using a validator factory:
import {
Router,
RouteMethod,
body,
type,
ValidatorFunction
} from '@steroids/core';
@Router({
name: 'router1',
priority: 100,
routes: [
{ path: '/test', handler: 'testHandler', method: RouteMethod.POST, validate: [
body({
oddOnly: odd,
evenOnly: parity(false)
})
]}
]
})
export class Router1 {
testHandler(req, res) {
// req.body has passed the validation test
}
}
function odd(value: any): boolean {
// Reusing type.number validator function
return type.number(value) && (value % 2 === 1);
}
function parity(odd: boolean): ValidatorFunction {
return (value: any): boolean => {
return type.number(value) && (value % 2 === (odd ? 1 : 0));
};
}
NOTE: Read about custom error messages and the ValidationResult type.
If you need to do something more complex or unique and still want to benefit from reusability and the auto-respond feature of the validators, you can create a function with this signature (req: Request) => boolean|ValidationResult
and pass it to the custom
method:
import { Router, RouteMethod, custom } from '@steroids/core';
import { Request } from 'express';
@Router({
name: 'auth',
priority: 100,
routes: [
{ path: '*', handler: 'authHandler', validate: [
custom(basicAuthValidator)
]}
]
})
export class Router1 {
authHandler(req, res, next) {
// Do basic auth
next();
}
}
// Making sure basic authentication credentials are provided
function basicAuthValidator(req: Request): boolean {
const authorization = req.header('Authorization');
return authorization && authorization.substr(0, 5) === 'Basic';
}
NOTE: Read about custom error messages and the ValidationResult type.
Custom validators, excluding custom body validators, can return a promise instead if asynchronous execution is needed.
import { Router, RouteMethod, custom } from '@steroids/core';
import { Request } from 'express';
@Router({
name: 'auth',
priority: 100,
routes: [
{ path: '*', handler: 'authHandler', validate: [
custom(userExistsPromise),
custom(userExistsAsync)
]}
]
})
export class Router1 {
authHandler(req, res, next) {
// req.user now exists from now on
next();
}
}
function userExistsPromise(req: Request): Promise<boolean> {
return new Promise((resolve, reject) => {
this.db.getUser(req.query.token)
.then(user => {
req.user = user;
resolve(true); // Must resolve with true
})
.catch(reject);
});
}
async function userExistsAsync(req: Request) {
req.user = await this.db.getUser(req.query.token);
return true;
}
NOTE: Read about custom error messages and the ValidationResult type.
All custom validators and custom body validators can return a ValidationResult object instead to customize the error message.
The returned object must contain valid
(boolean) and error
(string) properties. If error
string is missing, the default message would be used instead.
function customValidator(req: Request) {
// Invalid query
if ( ! req.query.token ) return { valid: false, error: 'You must provide your auth token with all requests!' };
return true;
}
The built-in logger is available globally as log
with the following methods:
log.debug('Log at debug level', 'Additional message 1', 'Additional message 2');
log.info('Log at info level');
log.notice('Log important message at notice level');
log.warn('Log warning message at warn level');
log.error('Log this error at error level', new Error('Some error!'));
Using the built-in logger instead of plain console logs has the following benefits:
- Formatted logs with date time strings (local to the server timezone set in the config)
- Writing logs on disk at
dist/.logs/
, organized by date (each file contains logs for one day) - Disk management for deleting/archiving log files by setting a max age in the server config
- Automatically archiving log files (when passed their max age) by moving them to
dist/.logs/archived/
and compressing them for smaller disk space usage - Five different log levels
- Both the console and log files can be customized to contain different log levels
- Internal queue system to guarantee logging order on disk without blocking the event loop
- Identical API to console.log
The built-in logger saves logs on disk by default (only if the log
API is used). In order to disable this behavior, set writeLogsToFile
to false
in the server config.
All logs are separated into various files organized based on their date. Each file has the date as its filename (dd-mm-yyyy.log
) and lives at dist/.logs/
directory.
The default max age of log files is 7 days. This means that any file that is 7 days older than the current date (based on server timezone) will be archived.
Archived log files are compressed and moved to dist/.logs/archived/
directory. If archiving is disabled (by setting archiveLogs
to false
in the server config) then logs will be deleted instead.
However, if max age is set to 0 (by changing logFileMaxAge
in server config) log files won't be archived nor deleted.
Both the console and disk can be customized to include any levels of logs. By default, the console displays all levels except for debug
and all log levels are saved on disk. To customize those behaviors, change consoleLogLevels
and logFileLevels
in the server config.
The server has a configuration file located at src/config.json
with the following settings:
Key | Type | Description |
---|---|---|
port | number | The port number to launch the server on (defaults to 5000 ). |
timezone | string | The timezone of the server (defaults to local system timezone). |
colorfulLogs | boolean | If true, console logs will have color (defaults to true ). |
consoleLogLevels | all|Array<debug|info|notice|warn|error> | An array of log levels to show in the console or all (no array) to show all levels (defaults to ['info', 'notice', 'warn', 'error'] ). |
writeLogsToFile | boolean | Will write all server logs on disk (defaults to true ). |
logFileMaxAge | number | The maximum age of a logs file in days. When passed, the file will either get archived or deleted (defaults to 7 ). |
logFileLevels | all|Array<debug|info|notice|warn|error> | An array of log levels to write on disk or all (no array) to write all levels (defaults to 'all' ). |
archiveLogs | boolean | Will archive logs written on disk that are older than their max age (defaults to true ). |
predictive404 | boolean | If true, installs a 404 handler at the top of the stack instead (this can be configured through predictive404Priority ), which predicts if path will match with any middleware in the stack and rejects the request with a 404 error if not. This is useful as requests that will eventually result in a 404 error won't go through the stack in the first place. Please note that the prediction ignores all * and / routes (defaults to false ). |
predictive404Priority | number | The priority of the predictive 404 middleware (defaults to Infinity ). |
If you need to get access to the config object inside your services or routers, implement OnConfig
on your classes and define the following function:
import { OnConfig, ServerConfig } from '@steroids/core';
class implements OnConfig {
onConfig(config: ServerConfig) {
// Inject or use...
}
}
You can expand the config object typing by editing the ServerConfig
model inside src/config.model.ts
. This would provide typings for your customized server config to all routers and services accessing the config object through OnConfig
interface.
Assets can be declared inside package.json
using the assets
array. Any files or directories declared inside the array will be copied to the dist
folder after the server build. Keep in mind that all paths should be relative to the src
directory.
{
"name": "steroids-template",
...
"assets": [
"firebase.certificate.json",
"public"
]
}
The ServerError
class is available for responding to users with errors through the REST API and is used by the server internally when rejecting requests (e.g. validation errors, internal errors, 404 errors, etc.). Use the following example as how to interact with the ServerError
class:
import { ServerError } from '@steroids/core';
const error = new ServerError('Message', 'ERROR_CODE'); // Code defaults to 'UNKNOWN_ERROR' when not provided
// res.json(error) --> { error: true, message: 'Message', code: 'ERROR_CODE' }
NOTE: This class is not an actual extension of the Error class and should not be used when stack trace is needed.
Unit testing has been setup using Mocha and Chai. The test files are written in TypeScript inside test/src
directory and are transpiled to JavaScript in test/dist
using test/tsconfig.json
before being run.
When the main test suite test/dist/main.spec.js
runs, the following happens:
- The latest server build (located at
/dist
) is reconfigured to run at port 8000 without file logging (to avoid polluting the log files) and is launched. - The
test/dist/
directory is scanned recursively and all test suites are run. - After all tests finish, the server is killed and reconfigured back to original.
You should write your tests suites inside test/src/
at any depth and run npm test
to start the tests.
- The directory structure of the server is totally up to you, since the server scans the build directory for
.service.js
and.router.js
files to install at any depth. Though, path aliases are setup for the default directory structure where services are kept atsrc/services
and routers atsrc/routers
. You can change aliases by editingcompilerOptions.paths
intsconfig.json
if desired or use the Steroids CLI to manage path aliases. - You can make your validators modular by storing the validator functions and the validator body definitions inside other files and reuse them everywhere.