kite-framework
Version:
Modern, fast, flexible HTTP-JSON-RPC framework
415 lines (414 loc) • 16.8 kB
JavaScript
;
/***
* Copyright (c) 2017 [Arthur Xie]
* <https://github.com/kite-js/kite>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.isMapInputOnly = exports.MapInputOnly = exports.getEntryParams = exports.hasEntryPoint = exports.Entry = void 0;
require("reflect-metadata");
const model_1 = require("./model");
const vm = require("vm");
const http = require("http");
const MK_ENTRY_POINT = 'kite:entry-point';
const MK_ENTRY_PARAMS = 'kite:entry-params';
const MK_MAP_INPUT_ONLY = 'kite:map-input-only';
/**
* Kite controller entry point decorator.
*
* ## Description
*
* Entry decorator marks a function of controller as "entry point" for Kite, the framework will invoke it
* when request comes in.
*
* The name of entry point function is not limited, you can name it at will.
* Please note that __only one entry point__ can be annotated in a Kite controller, if more
* than one `@Entry()` appeares in controller, an error will be given.
*
* ## How to use
* An entry point must be an
* "[asynchronous function](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function)",
* means it must be declared like following:
*
* ```typescript
* @Controller()
* class AController {
* @Entry()
* async exec() {
* // statments
* }
* }
* ```
* or with a return type declared:
* ```typescript
* @Controller()
* class AController {
* @Entry()
* exec(): Promise<any> {
* // statments
* }
* }
* ```
* or combine both:
* ```typescript
* @Controller()
* class AController {
* @Entry()
* async exec(): Promise<any> {
* // statments
* }
* }
* ```
*
* ## Parameter mapping
* The parameters of entry point function are mapped to client inputs or / and some other special context variables,
* base on types paramters:
* + __javascript types (number, string, boolean, array, boolean)__ - search for property which has the same name with the
* parameter in the raw input object, and parse input value to declared types
* + __Other objects__ - create a parameter object with "new XObject(inputs.paramName)" and set to these parameters,
* these objects should support constructor initialization, such as Date `new Date(inputs)` and MongoDB ObjectId `new ObjectId(inputs)`
* + __Kite model__ - create an model and filter the inputs with declared rules
* + __[IncomingMessage](https://nodejs.org/api/http.html#http_class_http_incomingmessage)__ - current IncomingMessage (request) object
*
* ### Basic mappings
* A shortcut to map client inputs to controllers is define map rules for parameters of entry points.
* For example, let's assume "http://localhost:4000/UserUpdate?_id=1000&name=Tom" mapped to a controller named "UserUpdateController":
* ```typescript
* @Controller()
* export class UserUpdateController {
* @Entry()
* async exec(_id: number, name: string, country: string = 'unknown') {
* // do your things here
* console.log(_id, name, country);
* return {_id, name, country};
* }
* }
* ```
* the server console will output "
*
* `> 1000 Tom unknow`
*
* Kite maps "_id" and "name" from URL to parameters of controller entry point,
* both "_id" and "name" are required fields, if any of them is omitted, Kite will give an error to clients;
* the third parameter "country" is assigned with a default value, Kite will treat this field as an "optional" parameter,
* means process is continued with the default value even it's omitted from request.
* And, if "country" is given in request, parameter "country" of "UserUpdateController.exec()" is set to a given value,
* for example http://localhost:4000/UserUpdate?_id=1000&name=Tom&country=US" outputs:
*
* `> 1000 Tom US`
*
* Kite treats non-default arguments as "required" fields for entry points, and arguments with default values are treated
* as optional fields, so you can place the optional arguments any where:
* ```typescript
* export class UserUpdateController {
* @Entry()
* async exec(_id: number, country: string = 'unknown', name: string) {
* // do your things here
* console.log(_id, name, country);
* return {_id, name, country};
* }
* }
* ```
*
* Sometimes you might need arguments to be "optional" without default values, in this case, you should
* assign 'undefined' as default values to these arguments, the following code shows this trick:
* ```typescript
* export class UserUpdateController {
* @Entry()
* async exec(_id: number, country: string = undefined, name: string) {
* // do your things here
* console.log(_id, name, country);
* return {_id, name, country};
* }
* }
* ```
*
* This "required/optional" checking mechanism is different from Kite model, where fields be treated as
* "optional" if `required: true` is not explicitly annonced in "@In()".
*
* Kite allows you define rules for each parameter at `@Entry()` decorator as well, rule definition is as same as `@In()`:
* ```typescript
* @Controller()
* export class UserUpdateController {
* @Entry({
* name: { min: 3 } // "name" minimal length is 3
* })
* async exec(_id: number, name: string, country: string = 'unknown') {
* // do your things here
* }
* }
* ```
* Please note that, the above example has implicit "required" rules applied to "_id" and "name", even though there is no "required: true"
* defined in the rule.
*
* ### Kite model mapping
* Any argument annonced as type of Kite model will cause Kite to map entire raw input object to this argument, this is useful when coding
* "insert", "update" APIs, generally these APIs accept the data that maps to database tables or documents, it's not friendly writing
* losts of arguments in entry point functions, so "Kite model" is a workaround.
* ```typescript
* @Model()
* export class UserModel {
* _id: number;
*
* @In({required: true, min: 3}) // limit input "name" minimal length to 3
* name: string;
*
* @In({min: 10}) // limit input "age" minimal value to 10
* age: number;
*
* @In() // no rule for input "country", accept any value
* country: string;
* }
*
* @Controller()
* export class UserCreateController {
* @Entry()
* async exec(user: UserModel) {
* // db.user.insertOne(user); // insert to DB, that's it
* return { success: true };
* }
*
* }
* ```
* The above code shows a controller named "UserCreateController", accept `user: UserModel` as parameter, `UserModel` mapping
* client inputs: "name", "age" and "country" as input values, And, `UserModel` is also mapping to a database table -
* let's assume table name is "user" - which owns fields "_id", "name", "age", "country", in this table, "_id" is generated as
* key by database when data is inserted.
*
* Here, "_id" is excluded from client inputs because it's a "key" for this row of data and you don't want a client-input value
* set to this key.
*
* Before Kite call this controller, the framework checks the input data follow the rules that defined in "@In(...)" for you.
*
* With this feature, Kite is extremely easy to map & validate complex data objects:
* ```typescript
* @Model() export class Addr {
* @In()
* addr: string;
*
* @In()
* city: string;
*
* @In()
* state: string;
*
* @In()
* zipcode: string;
* }
*
* @Model() export class User {
*
* _id: number;
*
* @In()
* name: string;
*
* @In()
* addr: Addr;
* }
*
* @Controller()
* export class UserCreateController {
* @Entry()
* async exec(user: User) {
* // save 'user' to db
* }
* }
* ```
*
* ### Mixing basic types and Kite model
* As the above example shows, "_id" is not an input field, this is reasonable to insert data to database,
* but it's unreasonable to update, we certainly require "_id" here. Therefore, you can mix basic types
* and Kite model in an entry point:
* ```typescript
* export class UserUpdateController {
* @Entry()
* async exec(_id: number, user: User) {
* // update 'user' to db
* }
* }
* ```
*
*/
function Entry(config) {
return function (target, propertyKey, descriptor) {
// If more than one entrypoint be annotated, throw an error
if (Reflect.hasMetadata(MK_ENTRY_POINT, target)) {
// tslint:disable-next-line:max-line-length
throw new Error(`Only one entry is allowed for controller "${target.constructor.name}", please remove "@Entry()" from function "${propertyKey}"`);
}
// Check return type, return type must be a "Promise"
let returntype = Reflect.getMetadata('design:returntype', target, propertyKey);
if (returntype !== Promise) {
throw new Error(`Return type of controller "${target.constructor.name}" entry point must be a "Promise"`);
}
// define entry point metadata for controller, tell `@controller` there is an entry point
Reflect.defineMetadata(MK_ENTRY_POINT, propertyKey, target);
// Parameter types
let paramtypes = Reflect.getMetadata('design:paramtypes', target, propertyKey);
// split parameter names from function source, parameter names are used for client input mappings
let fnstr = descriptor.value.toString();
// Raw parameters with default value
let rawParams = fnstr.match(/\(.*?\)/)[0].replace(/(\/\/.*$)|(\/\*[\s\S]*?\*\/)|(\s)|\(|\)/g, '');
// parametre names only
let params = rawParams.replace(/=.*?,(?=[_A-Za-z\.\$])/g, ',').replace(/=.*?$/g, '');
// make the parameters to independent
let paramnames = params ? params.split(',') : [];
// save parameter name and relative type to object
let paramReflection = {};
paramnames.forEach((name, index) => {
paramReflection[name] = paramtypes[index];
});
// save parameter names and tyes for reflection (for middleware)
Reflect.defineMetadata(MK_ENTRY_PARAMS, paramReflection, target);
let proxyMakerParamNames = [];
let proxyMakerParams = [];
// A dynamically created Kite model for mapping controller's entry point parameters
let _Param;
// Dynamically create a Kite model class for controller parameter mapping
if (paramtypes.length) {
let modelname = `__${target.constructor.name}Param`;
_Param = vm.runInThisContext(`(class {})`, { filename: modelname + '.vm' });
}
// Walk through parameter types, check if there is only one Kite model exists
let numModels = 0;
paramtypes.forEach(type => {
if (model_1.isKiteModel(type)) {
numModels++;
}
});
// Here, limit only one Kite model in parameter array of entry point
// TODO: allow child models mapping in parameter directly ?
if (numModels > 1) {
throw Error(`Only one Kite model is allowed in parameter list of entry point, please check controller: "${target.constructor.name}"`);
}
let entryParams = [], numGlobalMapping = 0;
paramnames.forEach((name, idx) => {
let type = paramtypes[idx];
if (type === http.IncomingMessage) {
entryParams.push('request');
}
else if (model_1.isKiteModel(type) && numModels === 1) { // Only one Kite model type in parameters, treat it as global mapping type
numGlobalMapping++;
let typename, index;
// type is already cached?
index = proxyMakerParams.indexOf(type);
if (index === -1) {
typename = `_${type.name}`;
if (proxyMakerParamNames.includes(typename)) {
typename += proxyMakerParamNames.length;
}
proxyMakerParamNames.push(typename);
proxyMakerParams.push(type);
}
else {
typename = proxyMakerParamNames[index];
}
if (model_1.isKiteModel(type)) {
if (isMapInputOnly(target)) {
entryParams.push(`Object.create(${typename}.prototype)._$filter(inputs, true)`);
}
else {
entryParams.push(`new ${typename}()._$filter(inputs)`);
}
}
else {
entryParams.push(`new ${typename}(inputs)`);
}
}
else {
Reflect.defineMetadata('design:type', type, _Param.prototype, name);
// by default, all parameters are required fields if no rule be defined,
// if any argument has a default value, let's treat it as an optional input
let defaultValueArg = new RegExp(`(^|,)${name}=`);
let rule = {
required: !defaultValueArg.test(rawParams)
};
// If filter rule available, use user defined, else create one
if (config) {
Object.assign(rule, config[name]);
}
model_1.In(rule)(_Param.prototype, name);
entryParams.push('param.' + name);
}
});
let paramExp = '';
// if `@In()` is applied to "_Param" class, this Kite model will have MK_KITE_INPUTS metadata,
// else the dynamically created class "_Param" does nothing, therefore it is an useless Kite model
if (_Param && model_1.hasModelInputs(_Param.prototype)) {
// "decoreate" the new created class as KiteModel, force it to create a _$filter() for this class
model_1.Model()(_Param);
paramExp = 'let param = new _Param()._$filter(inputs);';
proxyMakerParamNames.push('_Param');
proxyMakerParams.push(_Param);
}
let proxyMakerParamNameStr = proxyMakerParamNames.join(', ');
let callParamsStr = entryParams.join(', ');
let fnsrc = `(function(${proxyMakerParamNameStr}) {
return function(inputs, request) {
${paramExp}
return this.${propertyKey}(${callParamsStr});
}
})`;
let proxyProviderSrc = new vm.Script(fnsrc, { filename: `__${target.constructor.name}.$proxy.vm` });
let $proxy = proxyProviderSrc.runInThisContext()(...proxyMakerParams);
target.$proxy = $proxy;
};
}
exports.Entry = Entry;
/**
* Test a class has entry point or not
* @param controller any value
*/
function hasEntryPoint(controller) {
return Reflect.hasMetadata(MK_ENTRY_POINT, controller);
}
exports.hasEntryPoint = hasEntryPoint;
/**
* Get parameter reflection object from a controller
* @param controller constroller instance
*/
function getEntryParams(controller) {
return Reflect.getMetadata(MK_ENTRY_PARAMS, controller);
}
exports.getEntryParams = getEntryParams;
/**
* Tell Kite only map client input to a Kite model and its children,
* without calling the constructor.
*
* This decorator can be only applied to Kite controller entry point function,
* and must be placed after `@Entry()` decorator, for example:
* ```typescript
* import { Controller, Entry } from 'kite-framework';
*
* @Controller()
* export class TypesController {
* @Entry()
* @MapInputOnly
* async exec(str: string, num: number, bool: boolean, date: Date = undefined) {
* return { values: { str, num, bool, date } };
* }
* }
* ```
*/
function MapInputOnly(target, propertyKey, descriptor) {
Reflect.defineMetadata(MK_MAP_INPUT_ONLY, true, target);
}
exports.MapInputOnly = MapInputOnly;
/**
* Test a Kite model is only map client inputs or not
* @param target
*/
function isMapInputOnly(target) {
return Reflect.hasMetadata(MK_MAP_INPUT_ONLY, target);
}
exports.isMapInputOnly = isMapInputOnly;