@resin/pinejs
Version:
Pine.js is a sophisticated rules-driven API engine that enables you to define rules in a structured subset of English. Those rules are used in order for Pine.js to generate a database schema and the associated [OData](http://www.odata.org/) API. This make
1,795 lines (1,653 loc) • 46.5 kB
text/typescript
import type * as Express from 'express';
import type * as Db from '../database-layer/db';
import type { Model } from '../config-loader/config-loader';
import type { AnyObject, OptionalField, RequiredField } from './common-types';
declare global {
namespace Express {
export interface Request {
tx?: Db.Tx;
batch?: uriParser.UnparsedRequest[];
}
}
}
import * as Bluebird from 'bluebird';
import * as _ from 'lodash';
Bluebird.config({
cancellation: true,
});
import { TypedError } from 'typed-error';
import { cachedCompile } from './cached-compile';
type LFModel = any[];
import * as AbstractSQLCompiler from '@resin/abstract-sql-compiler';
import { version as AbstractSQLCompilerVersion } from '@resin/abstract-sql-compiler/package.json';
import * as LF2AbstractSQL from '@balena/lf-to-abstract-sql';
import {
odataNameToSqlName,
sqlNameToODataName,
SupportedMethod,
} from '@resin/odata-to-abstract-sql';
import * as sbvrTypes from '@resin/sbvr-types';
import deepFreeze = require('deep-freeze');
import { PinejsClientCoreFactory } from 'pinejs-client-core';
import { ExtendedSBVRParser } from '../extended-sbvr-parser/extended-sbvr-parser';
import * as migrator from '../migrator/migrator';
import { generateODataMetadata } from '../odata-metadata/odata-metadata-generator';
// tslint:disable-next-line:no-var-requires
const devModel = require('./dev.sbvr');
import * as permissions from './permissions';
export * from './permissions';
import {
BadRequestError,
ConflictError,
HttpError,
InternalRequestError,
MethodNotAllowedError,
NotFoundError,
ParsingError,
PermissionError,
PermissionParsingError,
SbvrValidationError,
SqlCompilationError,
statusCodeToError,
TranslationError,
UnauthorizedError,
} from './errors';
import * as uriParser from './uri-parser';
export * from './errors';
import {
HookBlueprint,
InstantiatedHooks,
instantiateHooks,
rollbackRequestHooks,
} from './hooks';
import * as memoize from 'memoizee';
import memoizeWeak = require('memoizee/weak');
import * as controlFlow from './control-flow';
const { DEBUG } = process.env;
export let db = (undefined as any) as Db.Database;
export { sbvrTypes };
import { version as LF2AbstractSQLVersion } from '@balena/lf-to-abstract-sql/package.json';
import { version as sbvrTypesVersion } from '@resin/sbvr-types/package.json';
import {
compileRequest,
getAndCheckBindValues,
isRuleAffected,
} from './abstract-sql';
export { resolveOdataBind } from './abstract-sql';
import * as odataResponse from './odata-response';
const LF2AbstractSQLTranslator = LF2AbstractSQL.createTranslator(sbvrTypes);
const LF2AbstractSQLTranslatorVersion = `${LF2AbstractSQLVersion}+${sbvrTypesVersion}`;
export type ExecutableModel =
| RequiredField<Model, 'apiRoot' | 'modelText'>
| RequiredField<Model, 'apiRoot' | 'abstractSql'>;
interface CompiledModel {
vocab: string;
se?: string;
lf?: LFModel;
abstractSql: AbstractSQLCompiler.AbstractSqlModel;
sql: AbstractSQLCompiler.SqlModel;
odataMetadata: ReturnType<typeof generateODataMetadata>;
}
const models: {
[vocabulary: string]: CompiledModel;
} = {};
export interface HookReq {
user?: User;
apiKey?: ApiKey;
method: string;
url: string;
query: AnyObject;
params: AnyObject;
body: AnyObject;
custom?: AnyObject;
tx?: Db.Tx;
hooks?: InstantiatedHooks<Hooks>;
}
export interface HookArgs {
req: HookReq;
request: HookRequest;
api: PinejsClient;
tx?: Db.Tx;
}
export type HookResponse = PromiseLike<any> | null | void;
export type HookRequest = uriParser.ODataRequest;
export interface Hooks {
PREPARSE?: (options: HookArgs) => HookResponse;
POSTPARSE?: (options: HookArgs) => HookResponse;
PRERUN?: (options: HookArgs & { tx: Db.Tx }) => HookResponse;
POSTRUN?: (options: HookArgs & { tx: Db.Tx; result: any }) => HookResponse;
PRERESPOND?: (
options: HookArgs & {
tx: Db.Tx;
result: any;
res: any;
data?: any;
},
) => HookResponse;
'POSTRUN-ERROR'?: (
options: HookArgs & { error: TypedError | any },
) => HookResponse;
}
type HookBlueprints = { [key in keyof Hooks]: HookBlueprint[] };
const hookNames: Array<keyof Hooks> = [
'PREPARSE',
'POSTPARSE',
'PRERUN',
'POSTRUN',
'PRERESPOND',
'POSTRUN-ERROR',
];
const isValidHook = (x: any): x is keyof Hooks => hookNames.includes(x);
interface VocabHooks {
[resourceName: string]: HookBlueprints;
}
interface MethodHooks {
[vocab: string]: VocabHooks;
}
const apiHooks = {
all: {} as MethodHooks,
GET: {} as MethodHooks,
PUT: {} as MethodHooks,
POST: {} as MethodHooks,
PATCH: {} as MethodHooks,
MERGE: {} as MethodHooks,
DELETE: {} as MethodHooks,
OPTIONS: {} as MethodHooks,
};
// Share hooks between merge and patch since they are the same operation,
// just MERGE was the OData intermediary until the HTTP spec added PATCH.
apiHooks.MERGE = apiHooks.PATCH;
export interface Actor {
permissions?: string[];
}
export interface User extends Actor {
id: number;
actor: number;
}
export interface ApiKey extends Actor {
key: string;
actor?: number;
}
interface Response {
status?: number;
headers?: {
[headerName: string]: any;
};
body?: AnyObject | string;
}
const memoizedResolvedSynonym = memoizeWeak(
(
abstractSqlModel: AbstractSQLCompiler.AbstractSqlModel,
resourceName: string,
): string => {
const sqlName = odataNameToSqlName(resourceName);
return _(sqlName)
.split('-')
.map((namePart) => {
const synonym = abstractSqlModel.synonyms[namePart];
if (synonym != null) {
return synonym;
}
return namePart;
})
.join('-');
},
{ primitive: true },
);
export const resolveSynonym = (
request: Pick<
uriParser.ODataRequest,
'abstractSqlModel' | 'resourceName' | 'vocabulary'
>,
): string => {
const abstractSqlModel = getAbstractSqlModel(request);
return memoizedResolvedSynonym(abstractSqlModel, request.resourceName);
};
const memoizedResolveNavigationResource = memoizeWeak(
(
abstractSqlModel: AbstractSQLCompiler.AbstractSqlModel,
resourceName: string,
navigationName: string,
): string => {
const navigation = _(odataNameToSqlName(navigationName))
.split('-')
.flatMap((namePart) =>
memoizedResolvedSynonym(abstractSqlModel, namePart).split('-'),
)
.concat('$')
.value();
const resolvedResourceName = memoizedResolvedSynonym(
abstractSqlModel,
resourceName,
);
const mapping = _.get(
abstractSqlModel.relationships[resolvedResourceName],
navigation,
) as undefined | AbstractSQLCompiler.RelationshipMapping;
if (mapping == null) {
throw new Error(
`Cannot navigate from '${resourceName}' to '${navigationName}'`,
);
}
if (mapping.length < 2) {
throw new Error(
`'${resourceName}' to '${navigationName}' is a field not a navigation`,
);
}
// we do check the length above, but typescript thinks the second
// element could be undefined
return sqlNameToODataName(abstractSqlModel.tables[mapping[1]![0]].name);
},
{ primitive: true },
);
export const resolveNavigationResource = (
request: Pick<
uriParser.ODataRequest,
'resourceName' | 'vocabulary' | 'abstractSqlModel'
>,
navigationName: string,
): string => {
const abstractSqlModel = getAbstractSqlModel(request);
return memoizedResolveNavigationResource(
abstractSqlModel,
request.resourceName,
navigationName,
);
};
// TODO: Clean this up and move it into the db module.
const prettifyConstraintError = (
err: Error | TypedError,
request: uriParser.ODataRequest,
) => {
if (err instanceof db.ConstraintError) {
let matches: RegExpExecArray | null = null;
if (err instanceof db.UniqueConstraintError) {
switch (db.engine) {
case 'mysql':
matches = /ER_DUP_ENTRY: Duplicate entry '.*?[^\\]' for key '(.*?[^\\])'/.exec(
err.message,
);
break;
case 'postgres':
const resourceName = resolveSynonym(request);
const abstractSqlModel = getAbstractSqlModel(request);
matches = new RegExp(
'"' + abstractSqlModel.tables[resourceName].name + '_(.*?)_key"',
).exec(err.message);
break;
}
// We know it's the right error type, so if matches exists just throw a generic error message, since we have failed to get the info for a more specific one.
if (matches == null) {
throw new db.UniqueConstraintError('Unique key constraint violated');
}
const columns = matches[1].split('_');
throw new db.UniqueConstraintError(
'"' +
columns.map(sqlNameToODataName).join('" and "') +
'" must be unique.',
);
}
if (err instanceof db.ForeignKeyConstraintError) {
switch (db.engine) {
case 'mysql':
matches = /ER_ROW_IS_REFERENCED_: Cannot delete or update a parent row: a foreign key constraint fails \(".*?"\.(".*?").*/.exec(
err.message,
);
break;
case 'postgres':
const resourceName = resolveSynonym(request);
const abstractSqlModel = getAbstractSqlModel(request);
const tableName = abstractSqlModel.tables[resourceName].name;
matches = new RegExp(
'"' +
tableName +
'" violates foreign key constraint ".*?" on table "(.*?)"',
).exec(err.message);
if (matches == null) {
matches = new RegExp(
'"' +
tableName +
'" violates foreign key constraint "' +
tableName +
'_(.*?)_fkey"',
).exec(err.message);
}
break;
}
// We know it's the right error type, so if no matches exists just throw a generic error message,
// since we have failed to get the info for a more specific one.
if (matches == null) {
throw new db.ForeignKeyConstraintError(
'Foreign key constraint violated',
);
}
throw new db.ForeignKeyConstraintError(
'Data is referenced by ' + sqlNameToODataName(matches[1]) + '.',
);
}
throw err;
}
};
export const validateModel = (
tx: Db.Tx,
modelName: string,
request?: uriParser.ODataRequest,
): Bluebird<void> => {
return Bluebird.map(models[modelName].sql.rules, async (rule) => {
if (!isRuleAffected(rule, request)) {
// If none of the fields intersect we don't need to run the rule! :D
return;
}
const values = await getAndCheckBindValues(
{
vocabulary: modelName,
odataBinds: [],
values: {},
engine: db.engine,
},
rule.bindings,
);
const result = await tx.executeSql(rule.sql, values);
const v = result.rows[0].result;
if (v === false || v === 0 || v === '0') {
throw new SbvrValidationError(rule.structuredEnglish);
}
}).return();
};
export const generateLfModel = (seModel: string): LFModel =>
cachedCompile('lfModel', ExtendedSBVRParser.version, seModel, () =>
ExtendedSBVRParser.matchAll(seModel, 'Process'),
);
export const generateAbstractSqlModel = (
lfModel: LFModel,
): AbstractSQLCompiler.AbstractSqlModel =>
cachedCompile(
'abstractSqlModel',
LF2AbstractSQLTranslatorVersion,
lfModel,
() => LF2AbstractSQLTranslator(lfModel, 'Process'),
);
export const generateModels = (
model: ExecutableModel,
targetDatabaseEngine: AbstractSQLCompiler.Engines,
): CompiledModel => {
const { apiRoot: vocab, modelText: se } = model;
let { abstractSql: maybeAbstractSql } = model;
let lf: ReturnType<typeof generateLfModel> | undefined;
if (se) {
try {
lf = generateLfModel(se);
} catch (e) {
console.error(`Error parsing model '${vocab}':`, e);
throw new Error(`Error parsing model '${vocab}': ` + e);
}
try {
maybeAbstractSql = generateAbstractSqlModel(lf);
} catch (e) {
console.error(`Error translating model '${vocab}':`, e);
throw new Error(`Error translating model '${vocab}': ` + e);
}
}
const abstractSql = maybeAbstractSql!;
const odataMetadata = cachedCompile(
'metadata',
generateODataMetadata.version,
{ vocab, abstractSqlModel: abstractSql },
() => generateODataMetadata(vocab, abstractSql),
);
let sql: ReturnType<AbstractSQLCompiler.EngineInstance['compileSchema']>;
try {
sql = cachedCompile(
'sqlModel',
AbstractSQLCompilerVersion + '+' + targetDatabaseEngine,
abstractSql,
() =>
AbstractSQLCompiler[targetDatabaseEngine].compileSchema(abstractSql),
);
} catch (e) {
console.error(`Error compiling model '${vocab}':`, e);
throw new Error(`Error compiling model '${vocab}': ` + e);
}
return { vocab, se, lf, abstractSql, sql, odataMetadata };
};
export const executeModel = (
tx: Db.Tx,
model: ExecutableModel,
): Bluebird<void> => executeModels(tx, [model]);
export const executeModels = Bluebird.method(
async (tx: Db.Tx, execModels: ExecutableModel[]): Promise<void> => {
try {
await Bluebird.map(execModels, async (model) => {
const { apiRoot } = model;
await migrator.run(tx, model);
const compiledModel = generateModels(model, db.engine);
// Create tables related to terms and fact types
// Run statements sequentially, as the order of the CREATE TABLE statements matters (eg. for foreign keys).
for (const createStatement of compiledModel.sql.createSchema) {
const promise = tx.executeSql(createStatement);
if (db.engine === 'websql') {
promise.catch((err) => {
console.warn(
"Ignoring errors in the create table statements for websql as it doesn't support CREATE IF NOT EXISTS",
err,
);
});
}
await promise;
}
await migrator.postRun(tx, model);
odataResponse.prepareModel(compiledModel.abstractSql);
deepFreeze(compiledModel.abstractSql);
models[apiRoot] = compiledModel;
// Validate the [empty] model according to the rules.
// This may eventually lead to entering obligatory data.
// For the moment it blocks such models from execution.
await validateModel(tx, apiRoot);
// TODO: Can we do this without the cast?
api[apiRoot] = new PinejsClient('/' + apiRoot + '/') as LoggingClient;
api[apiRoot].logger = _.cloneDeep(console);
if (model.logging != null) {
const defaultSetting = model.logging?.default ?? true;
for (const k of Object.keys(model.logging)) {
const key = k as keyof Console;
if (
typeof api[apiRoot].logger[key] === 'function' &&
!(model.logging?.[key] ?? defaultSetting)
) {
api[apiRoot].logger[key] = _.noop;
}
}
}
return compiledModel;
// Only update the dev models once all models have finished executing.
}).map((model: CompiledModel) => {
const updateModel = async (modelType: keyof CompiledModel) => {
if (model[modelType] == null) {
return api.dev.delete({
resource: 'model',
passthrough: {
tx,
req: permissions.root,
},
options: {
$filter: {
is_of__vocabulary: model.vocab,
model_type: modelType,
},
},
});
}
const result = (await api.dev.get({
resource: 'model',
passthrough: {
tx,
req: permissions.rootRead,
},
options: {
$select: 'id',
$filter: {
is_of__vocabulary: model.vocab,
model_type: modelType,
},
},
})) as Array<{ id: number }>;
let method: SupportedMethod = 'POST';
let uri = '/dev/model';
const body: AnyObject = {
is_of__vocabulary: model.vocab,
model_value: model[modelType],
model_type: modelType,
};
const id = result?.[0]?.id;
if (id != null) {
uri += '(' + id + ')';
method = 'PATCH';
body.id = id;
} else {
uri += '?returnResource=false';
}
return runURI(method, uri, body, tx, permissions.root);
};
return Bluebird.map(
['se', 'lf', 'abstractSql', 'sql', 'odataMetadata'],
updateModel,
);
});
} catch (err) {
await Bluebird.map(execModels, ({ apiRoot }) => cleanupModel(apiRoot));
throw err;
}
},
);
const cleanupModel = (vocab: string) => {
delete models[vocab];
delete api[vocab];
};
const mergeHooks = (a: HookBlueprints, b: HookBlueprints): HookBlueprints => {
return _.mergeWith({}, a, b, (x, y) => {
if (Array.isArray(x)) {
return x.concat(y);
}
});
};
type HookMethod = keyof typeof apiHooks;
const getResourceHooks = (vocabHooks: VocabHooks, resourceName?: string) => {
if (vocabHooks == null) {
return {};
}
// When getting the hooks list for the sake of PREPARSE hooks
// we don't know the resourceName we'll be acting on yet
if (resourceName == null) {
return vocabHooks['all'];
}
return mergeHooks(vocabHooks[resourceName], vocabHooks['all']);
};
const getVocabHooks = (
methodHooks: MethodHooks,
vocabulary: string,
resourceName?: string,
) => {
if (methodHooks == null) {
return {};
}
return mergeHooks(
getResourceHooks(methodHooks[vocabulary], resourceName),
getResourceHooks(methodHooks['all'], resourceName),
);
};
const getMethodHooks = memoize(
(method: SupportedMethod, vocabulary: string, resourceName?: string) =>
mergeHooks(
getVocabHooks(apiHooks[method], vocabulary, resourceName),
getVocabHooks(apiHooks['all'], vocabulary, resourceName),
),
{ primitive: true },
);
const getHooks = (
request: Pick<
OptionalField<HookRequest, 'resourceName'>,
'resourceName' | 'method' | 'vocabulary'
>,
): InstantiatedHooks<Hooks> => {
let { resourceName } = request;
if (resourceName != null) {
resourceName = resolveSynonym(
request as Pick<HookRequest, 'resourceName' | 'method' | 'vocabulary'>,
);
}
return instantiateHooks(
getMethodHooks(request.method, request.vocabulary, resourceName),
);
};
getHooks.clear = () => getMethodHooks.clear();
const runHooks = Bluebird.method(
async (
hookName: keyof Hooks,
hooksList: InstantiatedHooks<Hooks> | undefined,
args: {
request?: uriParser.ODataRequest;
req: Express.Request;
res?: Express.Response;
tx?: Db.Tx;
result?: any;
data?: number | any[];
error?: TypedError | any;
},
) => {
if (hooksList == null) {
return;
}
const hooks = hooksList[hookName];
if (hooks == null || hooks.length === 0) {
return;
}
const { request, req, tx } = args;
if (request != null) {
const { vocabulary } = request;
Object.defineProperty(args, 'api', {
get: _.once(() =>
api[vocabulary].clone({
passthrough: { req, tx },
}),
),
});
}
await Bluebird.map(hooks, async (hook) => {
await hook.run(args);
});
},
);
export const deleteModel = Bluebird.method(async (vocabulary: string) => {
await db.transaction((tx) => {
const dropStatements: Array<Bluebird<any>> = models[
vocabulary
].sql.dropSchema.map((dropStatement) => tx.executeSql(dropStatement));
return Promise.all(
dropStatements.concat([
api.dev.delete({
resource: 'model',
passthrough: {
tx,
req: permissions.root,
},
options: {
$filter: {
is_of__vocabulary: vocabulary,
},
},
}),
]),
);
});
await cleanupModel(vocabulary);
});
const isWhereNode = (
x: AbstractSQLCompiler.AbstractSqlType,
): x is AbstractSQLCompiler.WhereNode => x[0] === 'Where';
const isEqualsNode = (
x: AbstractSQLCompiler.AbstractSqlType,
): x is AbstractSQLCompiler.EqualsNode => x[0] === 'Equals';
export const getID = (vocab: string, request: uriParser.ODataRequest) => {
if (request.abstractSqlQuery == null) {
throw new Error('Can only get the id if an abstractSqlQuery is provided');
}
const { idField } = models[vocab].abstractSql.tables[request.resourceName];
for (const whereClause of request.abstractSqlQuery) {
if (isWhereNode(whereClause)) {
for (const comparison of whereClause.slice(1)) {
if (isEqualsNode(comparison)) {
if (comparison[1][2] === idField) {
return comparison[2][1];
}
if (comparison[2][2] === idField) {
return comparison[1][1];
}
}
}
}
}
return 0;
};
export const runRule = (() => {
const LF2AbstractSQLPrepHack = LF2AbstractSQL.LF2AbstractSQLPrep._extend({
CardinalityOptimisation() {
this._pred(false);
},
});
const translator = LF2AbstractSQL.LF2AbstractSQL.createInstance();
translator.addTypes(sbvrTypes);
return Bluebird.method(async (vocab: string, rule: string) => {
const seModel = models[vocab].se;
const { logger } = api[vocab];
let lfModel: LFModel;
let slfModel: LFModel;
let abstractSqlModel: AbstractSQLCompiler.AbstractSqlModel;
try {
lfModel = ExtendedSBVRParser.matchAll(
seModel + '\nRule: ' + rule,
'Process',
);
} catch (e) {
logger.error('Error parsing rule', rule, e);
throw new Error(`Error parsing rule'${rule}': ${e}`);
}
const ruleLF = lfModel.pop();
try {
slfModel = LF2AbstractSQL.LF2AbstractSQLPrep.match(lfModel, 'Process');
slfModel.push(ruleLF);
slfModel = LF2AbstractSQLPrepHack.match(slfModel, 'Process');
translator.reset();
abstractSqlModel = translator.match(slfModel, 'Process');
} catch (e) {
logger.error('Error compiling rule', rule, e);
throw new Error(`Error compiling rule '${rule}': ${e}`);
}
const formulationType = ruleLF[1][0];
let resourceName: string;
if (ruleLF[1][1][0] === 'LogicalNegation') {
resourceName = ruleLF[1][1][1][1][2][1];
} else {
resourceName = ruleLF[1][1][1][2][1];
}
let fetchingViolators = false;
const ruleAbs = _.last(abstractSqlModel.rules);
if (ruleAbs == null) {
throw new Error('Unable to generate rule');
}
const ruleBody = ruleAbs.find((node) => node[0] === 'Body') as [
'Body',
...any[]
];
if (
ruleBody[1][0] === 'Not' &&
ruleBody[1][1][0] === 'Exists' &&
ruleBody[1][1][1][0] === 'SelectQuery'
) {
// Remove the not exists
ruleBody[1] = ruleBody[1][1][1];
fetchingViolators = true;
} else if (
ruleBody[1][0] === 'Exists' &&
ruleBody[1][1][0] === 'SelectQuery'
) {
// Remove the exists
ruleBody[1] = ruleBody[1][1];
} else {
throw new Error('Unsupported rule formulation');
}
const wantNonViolators =
formulationType in
['PossibilityFormulation', 'PermissibilityFormulation'];
if (wantNonViolators === fetchingViolators) {
// What we want is the opposite of what we're getting, so add a not to the where clauses
ruleBody[1] = ruleBody[1].map(
(queryPart: AbstractSQLCompiler.AbstractSqlQuery) => {
if (queryPart[0] !== 'Where') {
return queryPart;
}
if (queryPart.length > 2) {
throw new Error('Unsupported rule formulation');
}
return ['Where', ['Not', queryPart[1]]];
},
);
}
// Select all
ruleBody[1] = ruleBody[1].map(
(queryPart: AbstractSQLCompiler.AbstractSqlQuery) => {
if (queryPart[0] !== 'Select') {
return queryPart;
}
return ['Select', '*'];
},
);
const compiledRule = AbstractSQLCompiler[db.engine].compileRule(ruleBody);
if (Array.isArray(compiledRule)) {
throw new Error('Unexpected query generated');
}
const values = await getAndCheckBindValues(
{
vocabulary: vocab,
odataBinds: [],
values: {},
engine: db.engine,
},
compiledRule.bindings,
);
const result = await db.executeSql(compiledRule.query, values);
const table = models[vocab].abstractSql.tables[resourceName];
const odataIdField = sqlNameToODataName(table.idField);
let ids = result.rows.map((row) => row[table.idField]);
ids = _.uniq(ids);
ids = ids.map((id) => odataIdField + ' eq ' + id);
let filter: string;
if (ids.length > 0) {
filter = ids.join(' or ');
} else {
filter = '0 eq 1';
}
const odataResult = (await runURI(
'GET',
'/' +
vocab +
'/' +
sqlNameToODataName(table.resourceName) +
'?$filter=' +
filter,
undefined,
undefined,
permissions.rootRead,
)) as AnyObject;
odataResult.__formulationType = formulationType;
odataResult.__resourceName = resourceName;
return odataResult;
});
})();
export type Passthrough = AnyObject & {
req?: {
user?: User;
};
tx?: Db.Tx;
};
export class PinejsClient extends PinejsClientCoreFactory(Bluebird)<
PinejsClient,
Bluebird<{}>,
Bluebird<PinejsClientCoreFactory.PromiseResultTypes>
> {
public passthrough: Passthrough;
public _request({
method,
url,
body,
tx,
req,
custom,
}: {
method: string;
url: string;
body?: AnyObject;
tx?: Db.Tx;
req?: permissions.PermissionReq;
custom?: AnyObject;
}) {
return runURI(method, url, body, tx, req, custom);
}
}
export type LoggingClient = PinejsClient & {
logger: Console;
};
export const api: {
[vocab: string]: LoggingClient;
} = {};
// We default to guest only permissions if no req object is passed in
export const runURI = (
method: string,
uri: string,
body: AnyObject = {},
tx?: Db.Tx,
req?: permissions.PermissionReq,
custom?: AnyObject,
): Bluebird<PinejsClientCoreFactory.PromiseResultTypes> => {
let user: User | undefined;
let apiKey: ApiKey | undefined;
if (req != null && _.isObject(req)) {
user = req.user;
apiKey = req.apiKey;
} else {
if (req != null) {
console.warn('Non-object req passed to runURI?', req, new Error().stack);
}
user = {
id: 0,
actor: 0,
permissions: [],
};
}
// Remove undefined values from the body, as normally they would be removed by the JSON conversion
_.forEach(body, (v, k) => {
if (v === undefined) {
delete body[k];
}
});
const emulatedReq: Express.Request = {
on: _.noop,
custom,
user,
apiKey,
method,
url: uri,
body,
params: {},
query: {},
tx,
} as any;
return new Bluebird<PinejsClientCoreFactory.PromiseResultTypes>(
(resolve, reject) => {
const res: Express.Response = {
__internalPinejs: true,
on: _.noop,
statusCode: 200,
status(statusCode: number) {
this.statusCode = statusCode;
return this;
},
sendStatus: (statusCode: number) => {
if (statusCode >= 400) {
const ErrorClass =
statusCodeToError[statusCode as keyof typeof statusCodeToError];
if (ErrorClass != null) {
reject(new ErrorClass());
} else {
reject(new HttpError(statusCode));
}
} else {
resolve();
}
},
send(statusCode: number) {
if (statusCode == null) {
statusCode = this.statusCode;
}
this.sendStatus(statusCode);
},
json(data: any, statusCode: number) {
if (_.isError(data)) {
reject(data);
return;
}
if (statusCode == null) {
statusCode = this.statusCode;
}
if (statusCode >= 400) {
const ErrorClass =
statusCodeToError[statusCode as keyof typeof statusCodeToError];
if (ErrorClass != null) {
reject(new ErrorClass(data));
} else {
reject(new HttpError(statusCode, data));
}
} else {
resolve(data);
}
},
set: _.noop,
type: _.noop,
} as any;
const next = (route?: string) => {
console.warn('Next called on a runURI?!', method, uri, route);
res.sendStatus(500);
};
handleODataRequest(emulatedReq, res, next);
},
);
};
export const getAbstractSqlModel = (
request: Pick<uriParser.ODataRequest, 'vocabulary' | 'abstractSqlModel'>,
): AbstractSQLCompiler.AbstractSqlModel => {
if (request.abstractSqlModel == null) {
request.abstractSqlModel = models[request.vocabulary].abstractSql;
}
return request.abstractSqlModel;
};
export const getAffectedIds = Bluebird.method(
async ({
req,
request,
tx,
}: {
req: HookReq;
request: HookRequest;
tx: Db.Tx;
}): Promise<number[]> => {
if (request.method === 'GET') {
// GET requests don't affect anything so passing one to this method is a mistake
throw new Error('Cannot call `getAffectedIds` with a GET request');
}
// We reparse to make sure we get a clean odataQuery, without permissions already added
// And we use the request's url rather than the req for things like batch where the req url is ../$batch
request = await uriParser.parseOData({
method: request.method,
url: `/${request.vocabulary}${request.url}`,
});
request.engine = db.engine;
const abstractSqlModel = getAbstractSqlModel(request);
const resourceName = resolveSynonym(request);
const resourceTable = abstractSqlModel.tables[resourceName];
if (resourceTable == null) {
throw new Error('Unknown resource: ' + request.resourceName);
}
const { idField } = resourceTable;
if (request.odataQuery.options == null) {
request.odataQuery.options = {};
}
request.odataQuery.options.$select = {
properties: [{ name: idField }],
};
// Delete any $expand that might exist as they're ignored on non-GETs but we're converting this request to a GET
delete request.odataQuery.options.$expand;
await permissions.addPermissions(req, request);
request.method = 'GET';
request = uriParser.translateUri(request);
request = compileRequest(request);
let result;
if (tx != null) {
result = await runQuery(tx, request);
} else {
result = await runTransaction(req, (newTx) => runQuery(newTx, request));
}
return result.rows.map((row) => row[idField]);
},
);
export const handleODataRequest: Express.Handler = (req, res, next) => {
const [, apiRoot] = req.url.split('/', 2);
if (apiRoot == null || models[apiRoot] == null) {
return next('route');
}
if (DEBUG) {
api[apiRoot].logger.log('Parsing', req.method, req.url);
}
const mapSeries = controlFlow.getMappingFn(req.headers);
// Get the hooks for the current method/vocabulary as we know it,
// in order to run PREPARSE hooks, before parsing gets us more info
const reqHooks = getHooks({
method: req.method as SupportedMethod,
vocabulary: apiRoot,
});
req.on('close', () => {
handlePromise.cancel();
rollbackRequestHooks(reqHooks);
});
res.on('close', () => {
handlePromise.cancel();
rollbackRequestHooks(reqHooks);
});
if (req.tx != null) {
req.tx.on('rollback', () => {
rollbackRequestHooks(reqHooks);
});
}
const handlePromise = runHooks('PREPARSE', reqHooks, { req, tx: req.tx })
.then(async () => {
let requests: uriParser.UnparsedRequest[];
// Check if it is a single request or a batch
if (req.batch != null && req.batch.length > 0) {
requests = req.batch;
} else {
const { method, url, body } = req;
requests = [{ method, url, data: body }];
}
const prepareRequest = async ($request: uriParser.ODataRequest) => {
$request.engine = db.engine;
// Get the full hooks list now that we can.
$request.hooks = getHooks($request);
// Add/check the relevant permissions
try {
await runHooks('POSTPARSE', $request.hooks, {
req,
request: $request,
tx: req.tx,
});
const translatedRequest = await uriParser.translateUri($request);
return await compileRequest(translatedRequest);
} catch (err) {
rollbackRequestHooks(reqHooks);
rollbackRequestHooks($request.hooks);
throw err;
}
};
// Parse the OData requests
const results = await mapSeries(requests, async (requestPart) => {
let request = await uriParser.parseOData(requestPart);
if (Array.isArray(request)) {
request = await Bluebird.mapSeries(request, prepareRequest);
} else {
request = await prepareRequest(request);
}
// Run the request in its own transaction
return runTransaction<Response | Response[]>(req, async (tx) => {
tx.on('rollback', () => {
rollbackRequestHooks(reqHooks);
if (Array.isArray(request)) {
request.forEach(({ hooks }) => {
rollbackRequestHooks(hooks);
});
} else {
rollbackRequestHooks(request.hooks);
}
});
if (Array.isArray(request)) {
const env = await Bluebird.reduce(
request,
runChangeSet(req, res, tx),
new Map<number, Response>(),
);
return Array.from(env.values());
} else {
return runRequest(req, res, tx, request);
}
});
});
const responses = results.map((result) => {
if (_.isError(result)) {
return convertToHttpError(result);
} else {
return result;
}
});
res.set('Cache-Control', 'no-cache');
// If we are dealing with a single request unpack the response and respond normally
if (req.batch == null || req.batch.length === 0) {
let [response] = responses;
if (_.isError(response)) {
if ((res as AnyObject).__internalPinejs === true) {
return res.json(response);
} else {
response = {
status: response.status,
body: response.getResponseBody(),
};
}
}
const { body, headers, status } = response as Response;
if (status) {
res.status(status);
}
_.forEach(headers, (headerValue, headerName) => {
res.set(headerName, headerValue);
});
if (!body) {
if (status != null) {
res.sendStatus(status);
} else {
console.error('No status or body set', req.url, responses);
res.sendStatus(500);
}
} else {
if (status != null) {
res.status(status);
}
res.json(body);
}
// Otherwise its a multipart request and we reply with the appropriate multipart response
} else {
(res.status(200) as any).sendMulti(
responses.map((response) => {
if (_.isError(response)) {
return {
status: response.status,
body: response.getResponseBody(),
};
} else {
return response;
}
}),
);
}
})
.catch((e: Error) => {
// If an error bubbles here it must have happened in the last then block
// We just respond with 500 as there is probably not much we can do to recover
console.error('An error occurred while constructing the response', e);
res.sendStatus(500);
});
return handlePromise;
};
// Reject the error to use the nice catch syntax
const convertToHttpError = (err: any): HttpError => {
if (err instanceof HttpError) {
return err;
}
if (err instanceof SbvrValidationError) {
return new BadRequestError(err);
}
if (err instanceof PermissionError) {
return new UnauthorizedError(err);
}
if (err instanceof db.ConstraintError) {
return new ConflictError(err);
}
if (
err instanceof SqlCompilationError ||
err instanceof TranslationError ||
err instanceof ParsingError ||
err instanceof PermissionParsingError
) {
return new InternalRequestError();
}
console.error('Unexpected response error type', err);
return new NotFoundError(err);
};
const runRequest = async (
req: Express.Request,
res: Express.Response,
tx: Db.Tx,
request: uriParser.ODataRequest,
): Promise<Response> => {
const { logger } = api[request.vocabulary];
if (DEBUG) {
logger.log('Running', req.method, req.url);
}
let result: Db.Result | number | undefined;
try {
try {
// Forward each request to the correct method handler
await runHooks('PRERUN', request.hooks, { req, request, tx });
switch (request.method) {
case 'GET':
result = await runGet(req, res, request, tx);
break;
case 'POST':
result = await runPost(req, res, request, tx);
break;
case 'PUT':
case 'PATCH':
case 'MERGE':
result = await runPut(req, res, request, tx);
break;
case 'DELETE':
result = await runDelete(req, res, request, tx);
break;
}
} catch (err) {
if (err instanceof db.DatabaseError) {
prettifyConstraintError(err, request);
logger.error(err);
// Override the error message so we don't leak any internal db info
err.message = 'Database error';
throw err;
}
if (
err instanceof uriParser.SyntaxError ||
err instanceof EvalError ||
err instanceof RangeError ||
err instanceof ReferenceError ||
err instanceof SyntaxError ||
err instanceof TypeError ||
err instanceof URIError
) {
logger.error(err);
throw new InternalRequestError();
}
throw err;
}
await runHooks('POSTRUN', request.hooks, { req, request, result, tx });
} catch (err) {
await runHooks('POSTRUN-ERROR', request.hooks, {
req,
request,
tx,
error: err,
});
throw err;
}
return prepareResponse(req, res, request, result, tx);
};
const runChangeSet = (
req: Express.Request,
res: Express.Response,
tx: Db.Tx,
) => async (
env: Map<number, Response>,
request: uriParser.ODataRequest,
): Promise<Map<number, Response>> => {
request = updateBinds(env, request);
const result = await runRequest(req, res, tx, request);
if (request.id == null) {
throw new Error('No request id');
}
if (result.headers == null) {
result.headers = {};
}
result.headers['Content-Id'] = request.id;
env.set(request.id, result);
return env;
};
// Requests inside a changeset may refer to resources created inside the
// changeset, the generation of the sql query for those requests must be
// deferred untill the request they reference is run and returns an insert ID.
// This function compiles the sql query of a request which has been deferred
const updateBinds = (
env: Map<number, Response>,
request: uriParser.ODataRequest,
) => {
if (request._defer) {
request.odataBinds = request.odataBinds.map(([tag, id]) => {
if (tag === 'ContentReference') {
const ref = env.get(id);
if (
ref == null ||
ref.body == null ||
typeof ref.body === 'string' ||
ref.body.id === undefined
) {
throw new BadRequestError(
'Reference to a non existing resource in Changeset',
);
}
return uriParser.parseId(ref.body.id);
}
return [tag, id];
});
}
return request;
};
const prepareResponse = async (
req: Express.Request,
res: Express.Response,
request: uriParser.ODataRequest,
result: any,
tx: Db.Tx,
): Promise<Response> => {
switch (request.method) {
case 'GET':
return respondGet(req, res, request, result, tx);
case 'POST':
return respondPost(req, res, request, result, tx);
case 'PUT':
case 'PATCH':
case 'MERGE':
return respondPut(req, res, request, result, tx);
case 'DELETE':
return respondDelete(req, res, request, result, tx);
case 'OPTIONS':
return respondOptions(req, res, request, result, tx);
default:
throw new MethodNotAllowedError();
}
};
// This is a helper method to handle using a passed in req.tx when available, or otherwise creating a new tx and cleaning up after we're done.
const runTransaction = <T>(
req: HookReq,
callback: (tx: Db.Tx) => Promise<T>,
): Promise<T> => {
if (req.tx != null) {
// If an existing tx was passed in then use it.
return callback(req.tx);
} else {
// Otherwise create a new transaction and handle tidying it up.
return db.transaction(callback);
}
};
// This is a helper function that will check and add the bind values to the SQL query and then run it.
const runQuery = async (
tx: Db.Tx,
request: uriParser.ODataRequest,
queryIndex?: number,
addReturning?: string,
): Promise<Db.Result> => {
const { vocabulary } = request;
let { sqlQuery } = request;
if (sqlQuery == null) {
throw new InternalRequestError('No SQL query available to run');
}
if (request.engine == null) {
throw new InternalRequestError('No database engine specified');
}
if (Array.isArray(sqlQuery)) {
if (queryIndex == null) {
throw new InternalRequestError(
'Received a query index to run but the query is not an array',
);
}
sqlQuery = sqlQuery[queryIndex];
}
const { query, bindings } = sqlQuery;
const values = await getAndCheckBindValues(
request as RequiredField<typeof request, 'engine'>,
bindings,
);
if (DEBUG) {
api[vocabulary].logger.log(query, values);
}
return tx.executeSql(query, values, addReturning);
};
const runGet = (
_req: Express.Request,
_res: Express.Response,
request: uriParser.ODataRequest,
tx: Db.Tx,
) => {
if (request.sqlQuery != null) {
return runQuery(tx, request);
}
};
const respondGet = async (
req: Express.Request,
res: Express.Response,
request: uriParser.ODataRequest,
result: any,
tx: Db.Tx,
): Promise<Response> => {
const vocab = request.vocabulary;
if (request.sqlQuery != null) {
const d = await odataResponse.process(
vocab,
getAbstractSqlModel(request),
request.resourceName,
result.rows,
);
await runHooks('PRERESPOND', request.hooks, {
req,
res,
request,
result,
data: d,
tx,
});
return { body: { d }, headers: { contentType: 'application/json' } };
} else {
if (request.resourceName === '$metadata') {
return {
body: models[vocab].odataMetadata,
headers: { contentType: 'xml' },
};
} else {
// TODO: request.resourceName can be '$serviceroot' or a resource and we should return an odata xml document based on that
return {
status: 404,
};
}
}
};
const runPost = async (
_req: Express.Request,
_res: Express.Response,
request: uriParser.ODataRequest,
tx: Db.Tx,
): Promise<number | undefined> => {
const vocab = request.vocabulary;
const { idField } = getAbstractSqlModel(request).tables[
resolveSynonym(request)
];
const { rowsAffected, insertId } = await runQuery(
tx,
request,
undefined,
idField,
);
if (rowsAffected === 0) {
throw new PermissionError();
}
await validateModel(tx, vocab, request);
return insertId;
};
const respondPost = async (
req: Express.Request,
res: Express.Response,
request: uriParser.ODataRequest,
id: number,
tx: Db.Tx,
): Promise<Response> => {
const vocab = request.vocabulary;
const location = odataResponse.resourceURI(vocab, request.resourceName, id);
if (DEBUG) {
api[vocab].logger.log('Insert ID: ', request.resourceName, id);
}
let result: AnyObject = { d: [{ id }] };
if (
location != null &&
!['0', 'false'].includes(
request?.odataQuery?.options?.returnResource as string,
)
) {
try {
result = (await runURI('GET', location, undefined, tx, req)) as AnyObject;
} catch {
// If we failed to fetch the created resource then we use just the id as default.
}
}
await runHooks('PRERESPOND', request.hooks, {
req,
res,
request,
result,
tx,
});
return {
status: 201,
body: result.d[0],
headers: {
contentType: 'application/json',
Location: location,
},
};
};
const runPut = async (
_req: Express.Request,
_res: Express.Response,
request: uriParser.ODataRequest,
tx: Db.Tx,
): Promise<undefined> => {
const vocab = request.vocabulary;
let rowsAffected: number;
// If request.sqlQuery is an array it means it's an UPSERT, ie two queries: [InsertQuery, UpdateQuery]
if (Array.isArray(request.sqlQuery)) {
// Run the update query first
({ rowsAffected } = await runQuery(tx, request, 1));
if (rowsAffected === 0) {
// Then run the insert query if nothing was updated
({ rowsAffected } = await runQuery(tx, request, 0));
}
} else {
({ rowsAffected } = await runQuery(tx, request));
}
if (rowsAffected > 0) {
await validateModel(tx, vocab, request);
}
return undefined;
};
const respondPut = async (
req: Express.Request,
res: Express.Response,
request: uriParser.ODataRequest,
_result: any,
tx: Db.Tx,
): Promise<Response> => {
await runHooks('PRERESPOND', request.hooks, {
req,
res,
request,
tx,
});
return {
status: 200,
headers: {},
};
};
const respondDelete = respondPut;
const respondOptions = respondPut;
const runDelete = async (
_req: Express.Request,
_res: Express.Response,
request: uriParser.ODataRequest,
tx: Db.Tx,
): Promise<undefined> => {
const vocab = request.vocabulary;
const { rowsAffected } = await runQuery(tx, request);
if (rowsAffected > 0) {
await validateModel(tx, vocab, request);
}
return undefined;
};
export const executeStandardModels = Bluebird.method(
async (tx: Db.Tx): Promise<void> => {
try {
// dev model must run first
await executeModel(tx, {
apiRoot: 'dev',
modelText: devModel,
logging: {
log: false,
},
migrations: {
'11.0.0-modified-at': `
ALTER TABLE "model"
ADD COLUMN IF NOT EXISTS "modified at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL;
`,
},
});
await executeModels(tx, permissions.config.models);
console.info('Successfully executed standard models.');
} catch (err) {
console.error('Failed to execute standard models.', err);
throw err;
}
},
);
export const addSideEffectHook = (
method: HookMethod,
apiRoot: string,
resourceName: string,
hooks: Hooks,
): void => {
const sideEffectHook = _.mapValues(hooks, (hook) => {
if (hook != null) {
return {
HOOK: hook,
effects: true,
};
}
});
addHook(method, apiRoot, resourceName, sideEffectHook);
};
export const addPureHook = (
method: HookMethod,
apiRoot: string,
resourceName: string,
hooks: Hooks,
): void => {
const pureHooks = _.mapValues(hooks, (hook) => {
if (hook != null) {
return {
HOOK: hook,
effects: false,
};
}
});
addHook(method, apiRoot, resourceName, pureHooks);
};
const addHook = (
method: keyof typeof apiHooks,
apiRoot: string,
resourceName: string,
hooks: { [key in keyof Hooks]: HookBlueprint },
) => {
const methodHooks = apiHooks[method];
if (methodHooks == null) {
throw new Error('Unsupported method: ' + method);
}
if (apiRoot !== 'all' && models[apiRoot] == null) {
throw new Error('Unknown api root: ' + apiRoot);
}
if (resourceName !== 'all') {
const origResourceName = resourceName;
resourceName = resolveSynonym({ vocabulary: apiRoot, resourceName });
if (models[apiRoot].abstractSql.tables[resourceName] == null) {
throw new Error(
'Unknown resource for api root: ' + origResourceName + ', ' + apiRoot,
);
}
}
if (methodHooks[apiRoot] == null) {
methodHooks[apiRoot] = {};
}
const apiRootHooks = methodHooks[apiRoot];
if (apiRootHooks[resourceName] == null) {
apiRootHooks[resourceName] = {};
}
const resourceHooks = apiRootHooks[resourceName];
for (const hookType of Object.keys(hooks)) {
if (!isValidHook(hookType)) {
throw new Error('Unknown callback type: ' + hookType);
}
const hook = hooks[hookType];
if (resourceHooks[hookType] == null) {
resourceHooks[hookType] = [];
}
if (hook != null) {
resourceHooks[hookType]!.push(hook);
}
}
getHooks.clear();
};
export const setup = Bluebird.method(
async (_app: Express.Application, $db: Db.Database): Promise<void> => {
exports.db = db = $db;
try {
await db.transaction(async (tx) => {
await executeStandardModels(tx);
await permissions.setup();
});
} catch (err) {
console.error('Could not execute standard models', err);
process.exit(1);
}
try {
await db.executeSql(
'CREATE UNIQUE INDEX "uniq_model_model_type_vocab" ON "model" ("is of-vocabulary", "model type");',
);
} catch {
// we can't use IF NOT EXISTS on all dbs, so we have to ignore the error raised if this index already exists
}
},
);