Skip to content

Commit 49e0ae4

Browse files
committed
Add $batch endpoint for batch requests
Change-type: minor
1 parent bfa407f commit 49e0ae4

File tree

10 files changed

+828
-15
lines changed

10 files changed

+828
-15
lines changed

src/sbvr-api/permissions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1508,7 +1508,7 @@ export const resolveApiKey = async (
15081508
tx?: Tx,
15091509
): Promise<PermissionReq['apiKey']> => {
15101510
const apiKey =
1511-
req.params[paramName] ?? req.body[paramName] ?? req.query[paramName];
1511+
req.params?.[paramName] ?? req.body?.[paramName] ?? req.query?.[paramName];
15121512
if (apiKey == null) {
15131513
return;
15141514
}

src/sbvr-api/sbvr-utils.ts

Lines changed: 123 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@ import * as odataResponse from './odata-response';
9595
import { env } from '../server-glue/module';
9696
import { translateAbstractSqlModel } from './translations';
9797

98+
const validBatchMethods = new Set(['PUT', 'POST', 'PATCH', 'DELETE', 'GET']);
99+
98100
const LF2AbstractSQLTranslator = LF2AbstractSQL.createTranslator(sbvrTypes);
99101
const LF2AbstractSQLTranslatorVersion = `${LF2AbstractSQLVersion}+${sbvrTypesVersion}`;
100102

@@ -1109,6 +1111,7 @@ const $getAffectedIds = async ({
11091111
const parsedRequest: uriParser.ParsedODataRequest &
11101112
Partial<Pick<uriParser.ODataRequest, 'engine' | 'translateVersions'>> =
11111113
await uriParser.parseOData({
1114+
id: request.id,
11121115
method: request.method,
11131116
url: `/${request.vocabulary}${request.url}`,
11141117
});
@@ -1154,11 +1157,101 @@ const $getAffectedIds = async ({
11541157
return result.rows.map((row) => row[idField]);
11551158
};
11561159

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+
11571246
const runODataRequest = (req: Express.Request, vocabulary: string) => {
11581247
if (env.DEBUG) {
11591248
api[vocabulary].logger.log('Parsing', req.method, req.url);
11601249
}
11611250

1251+
if (req.url.startsWith(`/${vocabulary}/$batch`)) {
1252+
validateBatch(req);
1253+
}
1254+
11621255
// Get the hooks for the current method/vocabulary as we know it,
11631256
// in order to run PREPARSE hooks, before parsing gets us more info
11641257
const { versions } = models[vocabulary];
@@ -1206,11 +1299,20 @@ const runODataRequest = (req: Express.Request, vocabulary: string) => {
12061299
await runHooks('PREPARSE', reqHooks, { req, tx: req.tx });
12071300
let requests: uriParser.UnparsedRequest[];
12081301
// Check if it is a single request or a batch
1209-
if (req.batch != null && req.batch.length > 0) {
1210-
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;
12111313
} else {
12121314
const { method, url, body } = req;
1213-
requests = [{ method, url, data: body }];
1315+
requests = [{ method, url, body }];
12141316
}
12151317

12161318
const prepareRequest = async (
@@ -1274,7 +1376,13 @@ const runODataRequest = (req: Express.Request, vocabulary: string) => {
12741376

12751377
// Parse the OData requests
12761378
const results = await mappingFn(requests, async (requestPart) => {
1277-
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+
);
12781386

12791387
let request: uriParser.ODataRequest | uriParser.ODataRequest[];
12801388
if (Array.isArray(parsedRequest)) {
@@ -1349,7 +1457,10 @@ export const handleODataRequest: Express.Handler = async (req, res, next) => {
13491457

13501458
res.set('Cache-Control', 'no-cache');
13511459
// If we are dealing with a single request unpack the response and respond normally
1352-
if (req.batch == null || req.batch.length === 0) {
1460+
if (
1461+
!req.url.startsWith(`/${apiRoot}/$batch`) ||
1462+
req.body.requests?.length === 0
1463+
) {
13531464
let [response] = responses;
13541465
if (response instanceof HttpError) {
13551466
response = httpErrorToResponse(response);
@@ -1358,15 +1469,15 @@ export const handleODataRequest: Express.Handler = async (req, res, next) => {
13581469

13591470
// Otherwise its a multipart request and we reply with the appropriate multipart response
13601471
} else {
1361-
(res.status(200) as any).sendMulti(
1362-
responses.map((response) => {
1472+
res.status(200).json({
1473+
responses: responses.map((response) => {
13631474
if (response instanceof HttpError) {
13641475
return httpErrorToResponse(response);
13651476
} else {
13661477
return response;
13671478
}
13681479
}),
1369-
);
1480+
});
13701481
}
13711482
} catch (e: any) {
13721483
if (handleHttpErrors(req, res, e)) {
@@ -1393,7 +1504,7 @@ export const handleHttpErrors = (
13931504
for (const handleErrorFn of handleErrorFns) {
13941505
handleErrorFn(req, err);
13951506
}
1396-
const response = httpErrorToResponse(err);
1507+
const response = httpErrorToResponse(err, req);
13971508
handleResponse(res, response);
13981509
return true;
13991510
}
@@ -1412,10 +1523,12 @@ const handleResponse = (res: Express.Response, response: Response): void => {
14121523

14131524
const httpErrorToResponse = (
14141525
err: HttpError,
1526+
req?: Express.Request,
14151527
): RequiredField<Response, 'status'> => {
1528+
const message = err.getResponseBody();
14161529
return {
14171530
status: err.status,
1418-
body: err.getResponseBody(),
1531+
body: req != null && 'batch' in req ? { responses: [], message } : message,
14191532
headers: err.headers,
14201533
};
14211534
};

src/sbvr-api/uri-parser.ts

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,19 @@ import * as sbvrUtils from './sbvr-utils';
2929
export type OdataBinds = ODataBinds;
3030

3131
export interface UnparsedRequest {
32+
id?: string;
3233
method: string;
3334
url: string;
34-
data?: any;
35+
body?: any;
3536
headers?: { [header: string]: string };
3637
changeSet?: UnparsedRequest[];
3738
_isChangeSet?: boolean;
3839
}
3940

4041
export interface ParsedODataRequest {
42+
headers?: {
43+
[key: string]: string | string[] | undefined;
44+
};
4145
method: SupportedMethod;
4246
url: string;
4347
vocabulary: string;
@@ -263,15 +267,27 @@ export const metadataEndpoints = ['$metadata', '$serviceroot'];
263267

264268
export async function parseOData(
265269
b: UnparsedRequest & { _isChangeSet?: false },
270+
headers?: {
271+
[key: string]: string | string[] | undefined;
272+
},
266273
): Promise<ParsedODataRequest>;
267274
export async function parseOData(
268275
b: UnparsedRequest & { _isChangeSet: true },
276+
headers?: {
277+
[key: string]: string | string[] | undefined;
278+
},
269279
): Promise<ParsedODataRequest[]>;
270280
export async function parseOData(
271281
b: UnparsedRequest,
282+
headers?: {
283+
[key: string]: string | string[] | undefined;
284+
},
272285
): Promise<ParsedODataRequest | ParsedODataRequest[]>;
273286
export async function parseOData(
274287
b: UnparsedRequest,
288+
batchHeaders?: {
289+
[key: string]: string | string[] | undefined;
290+
},
275291
): Promise<ParsedODataRequest | ParsedODataRequest[]> {
276292
try {
277293
if (b._isChangeSet && b.changeSet != null) {
@@ -292,13 +308,23 @@ export async function parseOData(
292308
const odata = memoizedParseOdata(url);
293309

294310
return {
311+
id: b.id,
312+
headers: { ...batchHeaders, ..._.omit(b.headers, 'authorization') },
295313
method: b.method as SupportedMethod,
296314
url,
297315
vocabulary: apiRoot,
298316
resourceName: odata.tree.resource,
299317
originalResourceName: odata.tree.resource,
300-
values: b.data ?? {},
301-
odataQuery: odata.tree,
318+
values: b.body ?? {},
319+
odataQuery: {
320+
...odata.tree,
321+
options: {
322+
..._.omit(odata.tree.options, 'apikey'),
323+
...(batchHeaders == null && odata.tree.options?.apikey
324+
? { apikey: odata.tree.options.apikey }
325+
: {}),
326+
},
327+
},
302328
odataBinds: odata.binds,
303329
custom: {},
304330
_defer: false,
@@ -362,7 +388,7 @@ const parseODataChangeset = (
362388
originalResourceName: odata.tree.resource,
363389
odataBinds: odata.binds,
364390
odataQuery: odata.tree,
365-
values: b.data ?? {},
391+
values: b.body ?? {},
366392
custom: {},
367393
id: contentId,
368394
_defer: defer,

0 commit comments

Comments
 (0)