Skip to content

Commit

Permalink
Add $batch endpoint for batch requests
Browse files Browse the repository at this point in the history
Change-type: minor
  • Loading branch information
myarmolinsky committed Aug 14, 2023
1 parent bfa407f commit 09b8c46
Show file tree
Hide file tree
Showing 10 changed files with 812 additions and 16 deletions.
2 changes: 1 addition & 1 deletion src/sbvr-api/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1508,7 +1508,7 @@ export const resolveApiKey = async (
tx?: Tx,
): Promise<PermissionReq['apiKey']> => {
const apiKey =
req.params[paramName] ?? req.body[paramName] ?? req.query[paramName];
req.params?.[paramName] ?? req.body?.[paramName] ?? req.query?.[paramName];
if (apiKey == null) {
return;
}
Expand Down
133 changes: 123 additions & 10 deletions src/sbvr-api/sbvr-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ import * as odataResponse from './odata-response';
import { env } from '../server-glue/module';
import { translateAbstractSqlModel } from './translations';

const validBatchMethods = new Set(['PUT', 'POST', 'PATCH', 'DELETE', 'GET']);

const LF2AbstractSQLTranslator = LF2AbstractSQL.createTranslator(sbvrTypes);
const LF2AbstractSQLTranslatorVersion = `${LF2AbstractSQLVersion}+${sbvrTypesVersion}`;

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

const validateBatch = (req: Express.Request) => {
const { requests } = req.body as { requests: uriParser.UnparsedRequest[] };
if (!Array.isArray(requests)) {
throw new BadRequestError(
'Batch requests must include an array of requests in the body via the "requests" property',
);
}
if (req.headers != null && req.headers['content-type'] == null) {
throw new BadRequestError(
'Headers in a batch request must include a "content-type" header if they are provided',
);
}
if (
requests.find(
(request) =>
request.headers?.authorization != null ||
request.url?.includes('apikey='),
) != null
) {
throw new BadRequestError(
'Authorization may only be passed to the main batch request',
);
}
const ids = new Set<string>(
requests
.map((request) => request.id)
.filter((id) => typeof id === 'string') as string[],
);
if (ids.size !== requests.length) {
throw new BadRequestError(
'All requests in a batch request must have unique string ids',
);
}

for (const request of requests) {
if (
request.headers != null &&
request.headers['content-type'] == null &&
(req.headers == null || req.headers['content-type'] == null)
) {
throw new BadRequestError(
'Requests of a batch request that have headers must include a "content-type" header',
);
}
if (request.method == null) {
throw new BadRequestError(
'Requests of a batch request must have a "method"',
);
}
const upperCaseMethod = request.method.toUpperCase();
if (!validBatchMethods.has(upperCaseMethod)) {
throw new BadRequestError(
`Requests of a batch request must have a method matching one of the following: ${Array.from(
validBatchMethods,
).join(', ')}`,
);
}
if (
request.body !== undefined &&
(upperCaseMethod === 'GET' || upperCaseMethod === 'DELETE')
) {
throw new BadRequestError(
'GET and DELETE requests of a batch request must not have a body',
);
}
}

const urls = new Set<string | undefined>(
requests.map((request) => request.url),
);
if (urls.has(undefined)) {
throw new BadRequestError('Requests of a batch request must have a "url"');
}
if (urls.has('/university/$batch')) {
throw new BadRequestError('Batch requests cannot contain batch requests');
}
const urlModels = new Set(
Array.from(urls.values()).map((url: string) => url.split('/')[1]),
);
if (urlModels.size > 1) {
throw new BadRequestError(
'Batch requests must consist of requests for only one model',
);
}
};

const runODataRequest = (req: Express.Request, vocabulary: string) => {
if (env.DEBUG) {
api[vocabulary].logger.log('Parsing', req.method, req.url);
}

if (req.url.startsWith(`/${vocabulary}/$batch`)) {
validateBatch(req);
}

// 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 { versions } = models[vocabulary];
Expand Down Expand Up @@ -1206,11 +1299,20 @@ const runODataRequest = (req: Express.Request, vocabulary: string) => {
await runHooks('PREPARSE', reqHooks, { req, tx: req.tx });
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;
if (req.url.startsWith(`/${vocabulary}/$batch`)) {
await Promise.all(
req.body.requests.map(
async (request: HookReq) =>
await runHooks('PREPARSE', reqHooks, {
req: request,
tx: req.tx,
}),
),
);
requests = req.body.requests;
} else {
const { method, url, body } = req;
requests = [{ method, url, data: body }];
requests = [{ method, url, body }];
}

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

// Parse the OData requests
const results = await mappingFn(requests, async (requestPart) => {
const parsedRequest = await uriParser.parseOData(requestPart);
const parsedRequest = await uriParser.parseOData(
requestPart,
req.url.startsWith(`/${vocabulary}/$batch`) &&
!requestPart.url.includes(`/${vocabulary}/$batch`)
? req.headers
: undefined,
);

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

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) {
if (
!req.url.startsWith(`/${apiRoot}/$batch`) ||
req.body.requests?.length === 0
) {
let [response] = responses;
if (response instanceof HttpError) {
response = httpErrorToResponse(response);
Expand All @@ -1358,15 +1469,15 @@ export const handleODataRequest: Express.Handler = async (req, res, next) => {

// Otherwise its a multipart request and we reply with the appropriate multipart response
} else {
(res.status(200) as any).sendMulti(
responses.map((response) => {
res.status(200).json({
responses: responses.map((response) => {
if (response instanceof HttpError) {
return httpErrorToResponse(response);
} else {
return response;
}
}),
);
});
}
} catch (e: any) {
if (handleHttpErrors(req, res, e)) {
Expand All @@ -1393,7 +1504,7 @@ export const handleHttpErrors = (
for (const handleErrorFn of handleErrorFns) {
handleErrorFn(req, err);
}
const response = httpErrorToResponse(err);
const response = httpErrorToResponse(err, req);
handleResponse(res, response);
return true;
}
Expand All @@ -1412,10 +1523,12 @@ const handleResponse = (res: Express.Response, response: Response): void => {

const httpErrorToResponse = (
err: HttpError,
req?: Express.Request,
): RequiredField<Response, 'status'> => {
const message = err.getResponseBody();
return {
status: err.status,
body: err.getResponseBody(),
body: req != null && 'batch' in req ? { responses: [], message } : message,
headers: err.headers,
};
};
Expand Down
19 changes: 14 additions & 5 deletions src/sbvr-api/uri-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,19 +25,22 @@ import {
TranslationError,
} from './errors';
import * as sbvrUtils from './sbvr-utils';
import { IncomingHttpHeaders } from 'http';

export type OdataBinds = ODataBinds;

export interface UnparsedRequest {
id?: string;
method: string;
url: string;
data?: any;
headers?: { [header: string]: string };
body?: any;
headers?: IncomingHttpHeaders;
changeSet?: UnparsedRequest[];
_isChangeSet?: boolean;
}

export interface ParsedODataRequest {
headers?: IncomingHttpHeaders;
method: SupportedMethod;
url: string;
vocabulary: string;
Expand Down Expand Up @@ -263,15 +266,19 @@ export const metadataEndpoints = ['$metadata', '$serviceroot'];

export async function parseOData(
b: UnparsedRequest & { _isChangeSet?: false },
headers?: IncomingHttpHeaders,
): Promise<ParsedODataRequest>;
export async function parseOData(
b: UnparsedRequest & { _isChangeSet: true },
headers?: IncomingHttpHeaders,
): Promise<ParsedODataRequest[]>;
export async function parseOData(
b: UnparsedRequest,
headers?: IncomingHttpHeaders,
): Promise<ParsedODataRequest | ParsedODataRequest[]>;
export async function parseOData(
b: UnparsedRequest,
batchHeaders?: IncomingHttpHeaders,
): Promise<ParsedODataRequest | ParsedODataRequest[]> {
try {
if (b._isChangeSet && b.changeSet != null) {
Expand All @@ -292,12 +299,14 @@ export async function parseOData(
const odata = memoizedParseOdata(url);

return {
id: b.id,
headers: { ...batchHeaders, ...b.headers },
method: b.method as SupportedMethod,
url,
vocabulary: apiRoot,
resourceName: odata.tree.resource,
originalResourceName: odata.tree.resource,
values: b.data ?? {},
values: b.body ?? {},
odataQuery: odata.tree,
odataBinds: odata.binds,
custom: {},
Expand Down Expand Up @@ -362,7 +371,7 @@ const parseODataChangeset = (
originalResourceName: odata.tree.resource,
odataBinds: odata.binds,
odataQuery: odata.tree,
values: b.data ?? {},
values: b.body ?? {},
custom: {},
id: contentId,
_defer: defer,
Expand All @@ -379,7 +388,7 @@ const splitApiRoot = (url: string) => {
};

const mustExtractHeader = (
body: { headers?: { [header: string]: string } },
body: { headers?: IncomingHttpHeaders },
header: string,
) => {
const h: any = body.headers?.[header]?.[0];
Expand Down
Loading

0 comments on commit 09b8c46

Please sign in to comment.