@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.
- package/.pinejs-cache.json +1 -1
- package/.versionbot/CHANGELOG.yml +45 -6
- package/CHANGELOG.md +14 -2
- package/Dockerfile +1 -1
- package/out/sbvr-api/permissions.js +1 -1
- package/out/sbvr-api/permissions.js.map +1 -1
- package/out/sbvr-api/sbvr-utils.d.ts +3 -2
- package/out/sbvr-api/sbvr-utils.js +98 -29
- package/out/sbvr-api/sbvr-utils.js.map +1 -1
- package/out/sbvr-api/uri-parser.d.ts +15 -5
- package/out/sbvr-api/uri-parser.js +5 -3
- package/out/sbvr-api/uri-parser.js.map +1 -1
- package/package.json +2 -2
- package/src/sbvr-api/permissions.ts +1 -1
- package/src/sbvr-api/sbvr-utils.ts +152 -31
- package/src/sbvr-api/uri-parser.ts +24 -6
@@ -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
|
-
|
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,
|
1028
|
+
const { body: responseBody, status, headers } = response as Response;
|
1026
1029
|
|
1027
|
-
if (
|
1030
|
+
if (status != null && status >= 400) {
|
1028
1031
|
const ErrorClass =
|
1029
|
-
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(
|
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<
|
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<
|
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.
|
1209
|
-
|
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,
|
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(
|
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<
|
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.
|
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 (
|
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
|
-
|
1356
|
-
responses.map((response) => {
|
1472
|
+
res.status(200).json({
|
1473
|
+
responses: responses.map((response) => {
|
1357
1474
|
if (response instanceof HttpError) {
|
1358
|
-
|
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,
|
1514
|
+
const { body, headers, status } = response as Response;
|
1398
1515
|
res.set(headers);
|
1399
|
-
res.status(
|
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
|
-
|
1526
|
+
req?: Express.Request,
|
1527
|
+
): RequiredField<Response, 'status'> => {
|
1528
|
+
const message = err.getResponseBody();
|
1410
1529
|
return {
|
1411
|
-
|
1412
|
-
body:
|
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<
|
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<
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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?:
|
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?:
|
64
|
-
pendingAffectedIds?: Promise<
|
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.
|
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.
|
383
|
+
values: b.body ?? {},
|
366
384
|
custom: {},
|
367
385
|
id: contentId,
|
368
386
|
_defer: defer,
|