@balena/pinejs 16.0.0-build-renovate-node-20-x-f810059b8839e410aab086f27909fb948e1199db-1 → 16.0.0-build--batch-2e2ff450b9d769d665896b33912819ec4d2d47fe-1

Sign up to get free protection for your applications and to get access to all the features.
@@ -95,6 +95,8 @@ import * as odataResponse from './odata-response';
95
95
  import { env } from '../server-glue/module';
96
96
  import { translateAbstractSqlModel } from './translations';
97
97
 
98
+ const validBatchMethods = new Set(['PUT', 'POST', 'PATCH', 'DELETE', 'GET']);
99
+
98
100
  const LF2AbstractSQLTranslator = LF2AbstractSQL.createTranslator(sbvrTypes);
99
101
  const LF2AbstractSQLTranslatorVersion = `${LF2AbstractSQLVersion}+${sbvrTypesVersion}`;
100
102
 
@@ -133,7 +135,8 @@ export interface ApiKey extends Actor {
133
135
  }
134
136
 
135
137
  export interface Response {
136
- statusCode: number;
138
+ id?: string | undefined;
139
+ status: number;
137
140
  headers?:
138
141
  | {
139
142
  [headerName: string]: any;
@@ -1022,15 +1025,15 @@ export const runURI = async (
1022
1025
  throw response;
1023
1026
  }
1024
1027
 
1025
- const { body: responseBody, statusCode, headers } = response as Response;
1028
+ const { body: responseBody, status, headers } = response as Response;
1026
1029
 
1027
- if (statusCode != null && statusCode >= 400) {
1030
+ if (status != null && status >= 400) {
1028
1031
  const ErrorClass =
1029
- statusCodeToError[statusCode as keyof typeof statusCodeToError];
1032
+ statusCodeToError[status as keyof typeof statusCodeToError];
1030
1033
  if (ErrorClass != null) {
1031
1034
  throw new ErrorClass(undefined, responseBody, headers);
1032
1035
  }
1033
- throw new HttpError(statusCode, undefined, responseBody, headers);
1036
+ throw new HttpError(status, undefined, responseBody, headers);
1034
1037
  }
1035
1038
 
1036
1039
  return responseBody as AnyObject | undefined;
@@ -1069,7 +1072,7 @@ export const getAffectedIds = async (
1069
1072
  args: HookArgs & {
1070
1073
  tx: Db.Tx;
1071
1074
  },
1072
- ): Promise<number[]> => {
1075
+ ): Promise<string[]> => {
1073
1076
  const { request } = args;
1074
1077
  if (request.affectedIds) {
1075
1078
  return request.affectedIds;
@@ -1094,7 +1097,7 @@ const $getAffectedIds = async ({
1094
1097
  tx,
1095
1098
  }: HookArgs & {
1096
1099
  tx: Db.Tx;
1097
- }): Promise<number[]> => {
1100
+ }): Promise<string[]> => {
1098
1101
  if (!['PATCH', 'DELETE'].includes(request.method)) {
1099
1102
  // We can only find the affected ids in advance for requests that modify existing records, if they
1100
1103
  // can insert new records (POST/PUT) then we're unable to find the ids until the request has actually run
@@ -1108,6 +1111,7 @@ const $getAffectedIds = async ({
1108
1111
  const parsedRequest: uriParser.ParsedODataRequest &
1109
1112
  Partial<Pick<uriParser.ODataRequest, 'engine' | 'translateVersions'>> =
1110
1113
  await uriParser.parseOData({
1114
+ id: request.id,
1111
1115
  method: request.method,
1112
1116
  url: `/${request.vocabulary}${request.url}`,
1113
1117
  });
@@ -1153,11 +1157,101 @@ const $getAffectedIds = async ({
1153
1157
  return result.rows.map((row) => row[idField]);
1154
1158
  };
1155
1159
 
1160
+ const validateBatch = (req: Express.Request) => {
1161
+ const { requests } = req.body as { requests: uriParser.UnparsedRequest[] };
1162
+ if (!Array.isArray(requests)) {
1163
+ throw new BadRequestError(
1164
+ 'Batch requests must include an array of requests in the body via the "requests" property',
1165
+ );
1166
+ }
1167
+ if (req.headers != null && req.headers['content-type'] == null) {
1168
+ throw new BadRequestError(
1169
+ 'Headers in a batch request must include a "content-type" header if they are provided',
1170
+ );
1171
+ }
1172
+ if (
1173
+ requests.find(
1174
+ (request) =>
1175
+ request.headers?.authorization != null ||
1176
+ request.url?.includes('apikey='),
1177
+ ) != null
1178
+ ) {
1179
+ throw new BadRequestError(
1180
+ 'Authorization may only be passed to the main batch request',
1181
+ );
1182
+ }
1183
+ const ids = new Set<string>(
1184
+ requests
1185
+ .map((request) => request.id)
1186
+ .filter((id) => typeof id === 'string') as string[],
1187
+ );
1188
+ if (ids.size !== requests.length) {
1189
+ throw new BadRequestError(
1190
+ 'All requests in a batch request must have unique string ids',
1191
+ );
1192
+ }
1193
+
1194
+ for (const request of requests) {
1195
+ if (
1196
+ request.headers != null &&
1197
+ request.headers['content-type'] == null &&
1198
+ (req.headers == null || req.headers['content-type'] == null)
1199
+ ) {
1200
+ throw new BadRequestError(
1201
+ 'Requests of a batch request that have headers must include a "content-type" header',
1202
+ );
1203
+ }
1204
+ if (request.method == null) {
1205
+ throw new BadRequestError(
1206
+ 'Requests of a batch request must have a "method"',
1207
+ );
1208
+ }
1209
+ const upperCaseMethod = request.method.toUpperCase();
1210
+ if (!validBatchMethods.has(upperCaseMethod)) {
1211
+ throw new BadRequestError(
1212
+ `Requests of a batch request must have a method matching one of the following: ${Array.from(
1213
+ validBatchMethods,
1214
+ ).join(', ')}`,
1215
+ );
1216
+ }
1217
+ if (
1218
+ request.body !== undefined &&
1219
+ (upperCaseMethod === 'GET' || upperCaseMethod === 'DELETE')
1220
+ ) {
1221
+ throw new BadRequestError(
1222
+ 'GET and DELETE requests of a batch request must not have a body',
1223
+ );
1224
+ }
1225
+ }
1226
+
1227
+ const urls = new Set<string | undefined>(
1228
+ requests.map((request) => request.url),
1229
+ );
1230
+ if (urls.has(undefined)) {
1231
+ throw new BadRequestError('Requests of a batch request must have a "url"');
1232
+ }
1233
+ if (urls.has('/university/$batch')) {
1234
+ throw new BadRequestError('Batch requests cannot contain batch requests');
1235
+ }
1236
+ const urlModels = new Set(
1237
+ Array.from(urls.values()).map((url: string) => url.split('/')[1]),
1238
+ );
1239
+ if (urlModels.size > 1) {
1240
+ throw new BadRequestError(
1241
+ 'Batch requests must consist of requests for only one model',
1242
+ );
1243
+ }
1244
+ };
1245
+
1156
1246
  const runODataRequest = (req: Express.Request, vocabulary: string) => {
1157
1247
  if (env.DEBUG) {
1158
1248
  api[vocabulary].logger.log('Parsing', req.method, req.url);
1159
1249
  }
1160
1250
 
1251
+ if (req.url.startsWith(`/${vocabulary}/$batch`)) {
1252
+ validateBatch(req);
1253
+ }
1254
+
1161
1255
  // Get the hooks for the current method/vocabulary as we know it,
1162
1256
  // in order to run PREPARSE hooks, before parsing gets us more info
1163
1257
  const { versions } = models[vocabulary];
@@ -1205,17 +1299,31 @@ const runODataRequest = (req: Express.Request, vocabulary: string) => {
1205
1299
  await runHooks('PREPARSE', reqHooks, { req, tx: req.tx });
1206
1300
  let requests: uriParser.UnparsedRequest[];
1207
1301
  // Check if it is a single request or a batch
1208
- if (req.batch != null && req.batch.length > 0) {
1209
- requests = req.batch;
1302
+ if (req.url.startsWith(`/${vocabulary}/$batch`)) {
1303
+ await Promise.all(
1304
+ req.body.requests.map(
1305
+ async (request: HookReq) =>
1306
+ await runHooks('PREPARSE', reqHooks, {
1307
+ req: request,
1308
+ tx: req.tx,
1309
+ }),
1310
+ ),
1311
+ );
1312
+ requests = req.body.requests;
1210
1313
  } else {
1211
1314
  const { method, url, body } = req;
1212
- requests = [{ method, url, data: body }];
1315
+ requests = [{ method, url, body }];
1213
1316
  }
1214
1317
 
1215
1318
  const prepareRequest = async (
1216
1319
  parsedRequest: uriParser.ParsedODataRequest &
1217
1320
  Partial<Pick<uriParser.ODataRequest, 'engine' | 'translateVersions'>>,
1218
1321
  ): Promise<uriParser.ODataRequest> => {
1322
+ if (models[parsedRequest.vocabulary] == null) {
1323
+ throw new BadRequestError(
1324
+ 'Unknown vocabulary: ' + parsedRequest.vocabulary,
1325
+ );
1326
+ }
1219
1327
  parsedRequest.engine = db.engine;
1220
1328
  parsedRequest.translateVersions = [...versions];
1221
1329
  // Mark that the engine/translateVersions is required now that we've set it
@@ -1268,7 +1376,13 @@ const runODataRequest = (req: Express.Request, vocabulary: string) => {
1268
1376
 
1269
1377
  // Parse the OData requests
1270
1378
  const results = await mappingFn(requests, async (requestPart) => {
1271
- const parsedRequest = await uriParser.parseOData(requestPart);
1379
+ const parsedRequest = await uriParser.parseOData(
1380
+ requestPart,
1381
+ req.url.startsWith(`/${vocabulary}/$batch`) &&
1382
+ !requestPart.url.includes(`/${vocabulary}/$batch`)
1383
+ ? req.headers
1384
+ : undefined,
1385
+ );
1272
1386
 
1273
1387
  let request: uriParser.ODataRequest | uriParser.ODataRequest[];
1274
1388
  if (Array.isArray(parsedRequest)) {
@@ -1293,7 +1407,7 @@ const runODataRequest = (req: Express.Request, vocabulary: string) => {
1293
1407
  }
1294
1408
  });
1295
1409
  if (Array.isArray(request)) {
1296
- const changeSetResults = new Map<number, Response>();
1410
+ const changeSetResults = new Map<string, Response>();
1297
1411
  const changeSetRunner = runChangeSet(req, tx);
1298
1412
  for (const r of request) {
1299
1413
  await changeSetRunner(changeSetResults, r);
@@ -1314,7 +1428,7 @@ const runODataRequest = (req: Express.Request, vocabulary: string) => {
1314
1428
  if (
1315
1429
  !Array.isArray(result) &&
1316
1430
  result.body == null &&
1317
- result.statusCode == null
1431
+ result.status == null
1318
1432
  ) {
1319
1433
  console.error('No status or body set', req.url, responses);
1320
1434
  return new InternalRequestError();
@@ -1343,7 +1457,10 @@ export const handleODataRequest: Express.Handler = async (req, res, next) => {
1343
1457
 
1344
1458
  res.set('Cache-Control', 'no-cache');
1345
1459
  // If we are dealing with a single request unpack the response and respond normally
1346
- if (req.batch == null || req.batch.length === 0) {
1460
+ if (
1461
+ !req.url.startsWith(`/${apiRoot}/$batch`) ||
1462
+ req.body.requests?.length === 0
1463
+ ) {
1347
1464
  let [response] = responses;
1348
1465
  if (response instanceof HttpError) {
1349
1466
  response = httpErrorToResponse(response);
@@ -1352,15 +1469,15 @@ export const handleODataRequest: Express.Handler = async (req, res, next) => {
1352
1469
 
1353
1470
  // Otherwise its a multipart request and we reply with the appropriate multipart response
1354
1471
  } else {
1355
- (res.status(200) as any).sendMulti(
1356
- responses.map((response) => {
1472
+ res.status(200).json({
1473
+ responses: responses.map((response) => {
1357
1474
  if (response instanceof HttpError) {
1358
- response = httpErrorToResponse(response);
1475
+ return httpErrorToResponse(response);
1359
1476
  } else {
1360
1477
  return response;
1361
1478
  }
1362
1479
  }),
1363
- );
1480
+ });
1364
1481
  }
1365
1482
  } catch (e: any) {
1366
1483
  if (handleHttpErrors(req, res, e)) {
@@ -1387,16 +1504,16 @@ export const handleHttpErrors = (
1387
1504
  for (const handleErrorFn of handleErrorFns) {
1388
1505
  handleErrorFn(req, err);
1389
1506
  }
1390
- const response = httpErrorToResponse(err);
1507
+ const response = httpErrorToResponse(err, req);
1391
1508
  handleResponse(res, response);
1392
1509
  return true;
1393
1510
  }
1394
1511
  return false;
1395
1512
  };
1396
1513
  const handleResponse = (res: Express.Response, response: Response): void => {
1397
- const { body, headers, statusCode } = response as Response;
1514
+ const { body, headers, status } = response as Response;
1398
1515
  res.set(headers);
1399
- res.status(statusCode);
1516
+ res.status(status);
1400
1517
  if (!body) {
1401
1518
  res.end();
1402
1519
  } else {
@@ -1406,10 +1523,12 @@ const handleResponse = (res: Express.Response, response: Response): void => {
1406
1523
 
1407
1524
  const httpErrorToResponse = (
1408
1525
  err: HttpError,
1409
- ): RequiredField<Response, 'statusCode'> => {
1526
+ req?: Express.Request,
1527
+ ): RequiredField<Response, 'status'> => {
1528
+ const message = err.getResponseBody();
1410
1529
  return {
1411
- statusCode: err.status,
1412
- body: err.getResponseBody(),
1530
+ status: err.status,
1531
+ body: req != null && 'batch' in req ? { responses: [], message } : message,
1413
1532
  headers: err.headers,
1414
1533
  };
1415
1534
  };
@@ -1514,7 +1633,7 @@ const runRequest = async (
1514
1633
  const runChangeSet =
1515
1634
  (req: Express.Request, tx: Db.Tx) =>
1516
1635
  async (
1517
- changeSetResults: Map<number, Response>,
1636
+ changeSetResults: Map<string, Response>,
1518
1637
  request: uriParser.ODataRequest,
1519
1638
  ): Promise<void> => {
1520
1639
  request = updateBinds(changeSetResults, request);
@@ -1532,7 +1651,7 @@ const runChangeSet =
1532
1651
  // deferred untill the request they reference is run and returns an insert ID.
1533
1652
  // This function compiles the sql query of a request which has been deferred
1534
1653
  const updateBinds = (
1535
- changeSetResults: Map<number, Response>,
1654
+ changeSetResults: Map<string, Response>,
1536
1655
  request: uriParser.ODataRequest,
1537
1656
  ) => {
1538
1657
  if (request._defer) {
@@ -1700,7 +1819,8 @@ const respondGet = async (
1700
1819
  );
1701
1820
 
1702
1821
  const response = {
1703
- statusCode: 200,
1822
+ id: request.id,
1823
+ status: 200,
1704
1824
  body: { d },
1705
1825
  headers: { 'content-type': 'application/json' },
1706
1826
  };
@@ -1715,14 +1835,15 @@ const respondGet = async (
1715
1835
  } else {
1716
1836
  if (request.resourceName === '$metadata') {
1717
1837
  return {
1718
- statusCode: 200,
1838
+ id: request.id,
1839
+ status: 200,
1719
1840
  body: models[vocab].odataMetadata,
1720
1841
  headers: { 'content-type': 'xml' },
1721
1842
  };
1722
1843
  } else {
1723
1844
  // TODO: request.resourceName can be '$serviceroot' or a resource and we should return an odata xml document based on that
1724
1845
  return {
1725
- statusCode: 404,
1846
+ status: 404,
1726
1847
  };
1727
1848
  }
1728
1849
  }
@@ -1778,7 +1899,7 @@ const respondPost = async (
1778
1899
  }
1779
1900
 
1780
1901
  const response = {
1781
- statusCode: 201,
1902
+ status: 201,
1782
1903
  body: result.d[0],
1783
1904
  headers: {
1784
1905
  'content-type': 'application/json',
@@ -1826,7 +1947,7 @@ const respondPut = async (
1826
1947
  tx: Db.Tx,
1827
1948
  ): Promise<Response> => {
1828
1949
  const response = {
1829
- statusCode: 200,
1950
+ status: 200,
1830
1951
  };
1831
1952
  await runHooks('PRERESPOND', request.hooks, {
1832
1953
  req,
@@ -29,15 +29,19 @@ import * as sbvrUtils from './sbvr-utils';
29
29
  export type OdataBinds = ODataBinds;
30
30
 
31
31
  export interface UnparsedRequest {
32
+ id?: string;
32
33
  method: string;
33
34
  url: string;
34
- data?: any;
35
+ body?: any;
35
36
  headers?: { [header: string]: string };
36
37
  changeSet?: UnparsedRequest[];
37
38
  _isChangeSet?: boolean;
38
39
  }
39
40
 
40
41
  export interface ParsedODataRequest {
42
+ headers?: {
43
+ [key: string]: string | string[] | undefined;
44
+ };
41
45
  method: SupportedMethod;
42
46
  url: string;
43
47
  vocabulary: string;
@@ -47,7 +51,7 @@ export interface ParsedODataRequest {
47
51
  odataQuery: ODataQuery;
48
52
  odataBinds: OdataBinds;
49
53
  custom: AnyObject;
50
- id?: number | undefined;
54
+ id?: string | undefined;
51
55
  _defer?: boolean;
52
56
  }
53
57
  export interface ODataRequest extends ParsedODataRequest {
@@ -60,8 +64,8 @@ export interface ODataRequest extends ParsedODataRequest {
60
64
  modifiedFields?: ReturnType<
61
65
  AbstractSQLCompiler.EngineInstance['getModifiedFields']
62
66
  >;
63
- affectedIds?: number[];
64
- pendingAffectedIds?: Promise<number[]>;
67
+ affectedIds?: string[];
68
+ pendingAffectedIds?: Promise<string[]>;
65
69
  hooks?: Array<[string, InstantiatedHooks]>;
66
70
  engine: AbstractSQLCompiler.Engines;
67
71
  }
@@ -263,15 +267,27 @@ export const metadataEndpoints = ['$metadata', '$serviceroot'];
263
267
 
264
268
  export async function parseOData(
265
269
  b: UnparsedRequest & { _isChangeSet?: false },
270
+ headers?: {
271
+ [key: string]: string | string[] | undefined;
272
+ },
266
273
  ): Promise<ParsedODataRequest>;
267
274
  export async function parseOData(
268
275
  b: UnparsedRequest & { _isChangeSet: true },
276
+ headers?: {
277
+ [key: string]: string | string[] | undefined;
278
+ },
269
279
  ): Promise<ParsedODataRequest[]>;
270
280
  export async function parseOData(
271
281
  b: UnparsedRequest,
282
+ headers?: {
283
+ [key: string]: string | string[] | undefined;
284
+ },
272
285
  ): Promise<ParsedODataRequest | ParsedODataRequest[]>;
273
286
  export async function parseOData(
274
287
  b: UnparsedRequest,
288
+ batchHeaders?: {
289
+ [key: string]: string | string[] | undefined;
290
+ },
275
291
  ): Promise<ParsedODataRequest | ParsedODataRequest[]> {
276
292
  try {
277
293
  if (b._isChangeSet && b.changeSet != null) {
@@ -292,12 +308,14 @@ export async function parseOData(
292
308
  const odata = memoizedParseOdata(url);
293
309
 
294
310
  return {
311
+ id: b.id,
312
+ headers: { ...batchHeaders, ...b.headers },
295
313
  method: b.method as SupportedMethod,
296
314
  url,
297
315
  vocabulary: apiRoot,
298
316
  resourceName: odata.tree.resource,
299
317
  originalResourceName: odata.tree.resource,
300
- values: b.data ?? {},
318
+ values: b.body ?? {},
301
319
  odataQuery: odata.tree,
302
320
  odataBinds: odata.binds,
303
321
  custom: {},
@@ -362,7 +380,7 @@ const parseODataChangeset = (
362
380
  originalResourceName: odata.tree.resource,
363
381
  odataBinds: odata.binds,
364
382
  odataQuery: odata.tree,
365
- values: b.data ?? {},
383
+ values: b.body ?? {},
366
384
  custom: {},
367
385
  id: contentId,
368
386
  _defer: defer,