UNPKG

@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

413 lines (390 loc) • 11.2 kB
import type * as AbstractSQLCompiler from '@resin/abstract-sql-compiler'; import type { ODataBinds, ODataOptions, ODataQuery, SupportedMethod, } from '@balena/odata-parser'; import type { Tx } from '../database-layer/db'; import type { InstantiatedHooks } from './hooks'; import type { AnyObject } from './common-types'; import * as ODataParser from '@balena/odata-parser'; import * as Bluebird from 'bluebird'; export const SyntaxError = ODataParser.SyntaxError; import { OData2AbstractSQL } from '@resin/odata-to-abstract-sql'; import * as _ from 'lodash'; import * as memoize from 'memoizee'; import memoizeWeak = require('memoizee/weak'); export { BadRequestError, ParsingError, TranslationError } from './errors'; import * as deepFreeze from 'deep-freeze'; import * as env from '../config-loader/env'; import { BadRequestError, ParsingError, PermissionError, TranslationError, } from './errors'; import * as sbvrUtils from './sbvr-utils'; export type OdataBinds = ODataBinds; export interface UnparsedRequest { method: string; url: string; data?: any; headers?: { [header: string]: string }; changeSet?: UnparsedRequest[]; _isChangeSet?: boolean; } export interface ODataRequest { method: SupportedMethod; url: string; odataQuery: ODataQuery; odataBinds: OdataBinds; values: AnyObject; abstractSqlModel?: AbstractSQLCompiler.AbstractSqlModel; abstractSqlQuery?: AbstractSQLCompiler.AbstractSqlQuery; sqlQuery?: AbstractSQLCompiler.SqlResult | AbstractSQLCompiler.SqlResult[]; resourceName: string; vocabulary: string; _defer?: boolean; id?: number; custom: AnyObject; tx?: Tx; modifiedFields?: ReturnType< AbstractSQLCompiler.EngineInstance['getModifiedFields'] >; hooks?: InstantiatedHooks<sbvrUtils.Hooks>; engine?: AbstractSQLCompiler.Engines; } // Converts a value to its string representation and tries to parse is as an // OData bind export const parseId = (b: any) => { const { tree, binds } = ODataParser.parse(String(b), { startRule: 'ProcessRule', rule: 'KeyBind', }); return binds[tree.bind]; }; export const memoizedParseOdata = (() => { const parseOdata = (url: string) => { const odata = ODataParser.parse(url); // if we parse a canAccess action rewrite the resource to ensure we // do not run the resource hooks if ( odata.tree.property != null && odata.tree.property.resource === 'canAccess' ) { odata.tree.resource = odata.tree.resource + '#' + odata.tree.property.resource; } return odata; }; const _memoizedParseOdata = memoize(parseOdata, { primitive: true, max: env.cache.parseOData.max, }); return (url: string) => { const queryParamsIndex = url.indexOf('?'); if (queryParamsIndex !== -1) { if (/[?&(]@/.test(url)) { // Try to cache based on parameter aliases if there might be some const parameterAliases = new URLSearchParams(); const queryParams = new URLSearchParams(url.slice(queryParamsIndex)); Array.from(queryParams.entries()).forEach(([key, value]) => { if (key.startsWith('@')) { parameterAliases.append(key, value); queryParams.delete(key); } }); const parameterAliasesString = parameterAliases.toString(); if (parameterAliasesString !== '') { const parsed = _.cloneDeep( _memoizedParseOdata( url.slice(0, queryParamsIndex) + '?' + decodeURIComponent(queryParams.toString()), ), ); const parsedParams = ODataParser.parse( decodeURIComponent(parameterAliasesString), { startRule: 'ProcessRule', rule: 'QueryOptions', }, ); if (parsed.tree.options == null) { parsed.tree.options = {}; } for (const key of Object.keys(parsedParams.tree)) { parsed.tree.options[key] = parsedParams.tree[key]; parsed.binds[key] = parsedParams.binds[key]; } return parsed; } } // If we're doing a complex url then skip caching due to # of permutations return parseOdata(url); } else if (url.includes('(')) { // We're parsing a url like `/pilot(1)` so whilst there are no query options // there are still enough permutations to ruin the hit % return parseOdata(url); } else { // Else if it's simple we can easily skip the parsing as we know we'll get a high % hit rate // We deep clone to avoid mutations polluting the cache return _.cloneDeep(_memoizedParseOdata(url)); } }; })(); const memoizedGetOData2AbstractSQL = memoizeWeak( (abstractSqlModel: AbstractSQLCompiler.AbstractSqlModel) => { return new OData2AbstractSQL(abstractSqlModel); }, ); const memoizedOdata2AbstractSQL = (() => { const $memoizedOdata2AbstractSQL = memoizeWeak( ( abstractSqlModel: AbstractSQLCompiler.AbstractSqlModel, odataQuery: ODataQuery, method: SupportedMethod, bodyKeys: string[], existingBindVarsLength: number, ) => { try { const odata2AbstractSQL = memoizedGetOData2AbstractSQL( abstractSqlModel, ); const abstractSql = odata2AbstractSQL.match( odataQuery, method, bodyKeys, existingBindVarsLength, ); // We deep freeze to prevent mutations, which would pollute the cache deepFreeze(abstractSql); return abstractSql; } catch (e) { if (e instanceof PermissionError) { throw e; } console.error( 'Failed to translate url: ', JSON.stringify(odataQuery, null, '\t'), method, e, ); throw new TranslationError('Failed to translate url'); } }, { normalizer: ( _abstractSqlModel: AbstractSQLCompiler.AbstractSqlModel, [odataQuery, method, bodyKeys, existingBindVarsLength]: [ ODataQuery, SupportedMethod, string[], number, ], ) => { return ( JSON.stringify(odataQuery) + method + bodyKeys + existingBindVarsLength ); }, max: env.cache.odataToAbstractSql.max, }, ); return ( request: Pick< ODataRequest, | 'method' | 'odataQuery' | 'odataBinds' | 'values' | 'vocabulary' | 'abstractSqlModel' >, ) => { const { method, odataBinds, values } = request; let { odataQuery } = request; const abstractSqlModel = sbvrUtils.getAbstractSqlModel(request); // Sort the body keys to improve cache hits const sortedBody = Object.keys(values).sort(); // Remove unused options for odata-to-abstract-sql to improve cache hits if (odataQuery.options) { odataQuery = { ...odataQuery, options: _.pick( odataQuery.options, '$select', '$filter', '$expand', '$orderby', '$top', '$skip', '$count', '$inlinecount', '$format', ) as ODataOptions, }; } const { tree, extraBodyVars, extraBindVars } = $memoizedOdata2AbstractSQL( abstractSqlModel, odataQuery, method, sortedBody, odataBinds.length, ); Object.assign(values, extraBodyVars); odataBinds.push(...extraBindVars); return tree; }; })(); export const metadataEndpoints = ['$metadata', '$serviceroot']; export function parseOData( b: UnparsedRequest & { _isChangeSet?: false }, ): Bluebird<ODataRequest>; export function parseOData( b: UnparsedRequest & { _isChangeSet: true }, ): Bluebird<ODataRequest[]>; export function parseOData( b: UnparsedRequest, ): Bluebird<ODataRequest | ODataRequest[]>; export function parseOData( b: UnparsedRequest, ): Bluebird<ODataRequest | ODataRequest[]> { return Bluebird.try<ODataRequest | ODataRequest[]>(async () => { try { if (b._isChangeSet && b.changeSet != null) { // We sort the CS set once, we must assure that requests which reference // other requests in the changeset are placed last. Once they are sorted // Map will guarantee retrival of results in insertion order const sortedCS = _.sortBy(b.changeSet, (el) => el.url[0] !== '/'); const csReferences = await Bluebird.reduce( sortedCS, parseODataChangeset, new Map<ODataRequest['id'], ODataRequest>(), ); return Array.from(csReferences.values()) as ODataRequest[]; } else { const { url, apiRoot } = splitApiRoot(b.url); const odata = memoizedParseOdata(url); return { method: b.method as SupportedMethod, url, vocabulary: apiRoot, resourceName: odata.tree.resource, odataBinds: odata.binds, odataQuery: odata.tree, values: b.data ?? {}, custom: {}, _defer: false, }; } } catch (err) { if (err instanceof ODataParser.SyntaxError) { throw new BadRequestError(`Malformed url: '${b.url}'`); } if (!(err instanceof BadRequestError || err instanceof ParsingError)) { console.error('Failed to parse url: ', b.method, b.url, err); throw new ParsingError(`Failed to parse url: '${b.url}'`); } throw err; } }); } const parseODataChangeset = ( csReferences: Map<ODataRequest['id'], ODataRequest>, b: UnparsedRequest, ) => { const contentId: ODataRequest['id'] = mustExtractHeader(b, 'content-id'); if (csReferences.has(contentId)) { throw new BadRequestError('Content-Id must be unique inside a changeset'); } let defer: boolean; let odata; let apiRoot: string; let url; if (b.url[0] === '/') { ({ url, apiRoot } = splitApiRoot(b.url)); odata = memoizedParseOdata(url); defer = false; } else { url = b.url; odata = memoizedParseOdata(url); const { bind } = odata.tree.resource; const [, id] = odata.binds[bind]; // Use reference to collect information const ref = csReferences.get(id); if (ref === undefined) { throw new BadRequestError('Content-Id refers to a non existent resource'); } apiRoot = ref.vocabulary; // Update resource with actual resourceName odata.tree.resource = ref.resourceName; defer = true; } const parseResult: ODataRequest = { method: b.method as SupportedMethod, url, vocabulary: apiRoot, resourceName: odata.tree.resource, odataBinds: odata.binds, odataQuery: odata.tree, values: b.data ?? {}, custom: {}, id: contentId, _defer: defer, }; csReferences.set(contentId, parseResult); return csReferences; }; const splitApiRoot = (url: string) => { const urlParts = url.split('/'); const apiRoot = urlParts[1]; if (apiRoot == null) { throw new ParsingError(`No such api root: ${apiRoot}`); } url = '/' + urlParts.slice(2).join('/'); return { url, apiRoot }; }; const mustExtractHeader = ( body: { headers?: { [header: string]: string } }, header: string, ) => { const h: any = body.headers?.[header]?.[0]; if (_.isEmpty(h)) { throw new BadRequestError(`${header} must be specified`); } return h; }; export const translateUri = < T extends Pick< ODataRequest, | 'method' | 'odataQuery' | 'odataBinds' | 'values' | 'vocabulary' | 'resourceName' | 'abstractSqlModel' > >( request: T & { abstractSqlQuery?: ODataRequest['abstractSqlQuery']; }, ): typeof request => { if (request.abstractSqlQuery != null) { return request; } const isMetadataEndpoint = metadataEndpoints.includes(request.resourceName) || request.method === 'OPTIONS'; if (!isMetadataEndpoint) { const abstractSqlQuery = memoizedOdata2AbstractSQL(request); request = { ...request }; request.abstractSqlQuery = abstractSqlQuery; return request; } return request; };