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 26df847 commit 12f462a
Show file tree
Hide file tree
Showing 10 changed files with 830 additions and 22 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
154 changes: 138 additions & 16 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 @@ -133,6 +135,7 @@ export interface ApiKey extends Actor {
}

export interface Response {
id?: string;
status: number;
headers?:
| {
Expand Down Expand Up @@ -1108,6 +1111,7 @@ const $getAffectedIds = async ({
const parsedRequest: uriParser.ParsedODataRequest &
Partial<Pick<uriParser.ODataRequest, 'engine' | 'translateVersions'>> =
await uriParser.parseOData({
id: request.batchRequestId,
method: request.method,
url: `/${request.vocabulary}${request.url}`,
});
Expand Down Expand Up @@ -1153,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 @@ -1205,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 @@ -1273,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 @@ -1348,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 @@ -1357,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 @@ -1392,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 @@ -1411,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 Expand Up @@ -1528,7 +1642,8 @@ const runChangeSet =
throw new Error('No request id');
}
result.headers ??= {};
result.headers['content-id'] = request.id;
result.headers['content-id'] = request.batchRequestId;
result.id = request.batchRequestId;
changeSetResults.set(request.id, result);
};

Expand Down Expand Up @@ -1567,22 +1682,29 @@ const prepareResponse = async (
result: any,
tx: Db.Tx,
): Promise<Response> => {
let response: Response;
switch (request.method) {
case 'GET':
return await respondGet(req, request, result, tx);
response = await respondGet(req, request, result, tx);
break;
case 'POST':
return await respondPost(req, request, result, tx);
response = await respondPost(req, request, result, tx);
break;
case 'PUT':
case 'PATCH':
case 'MERGE':
return await respondPut(req, request, result, tx);
response = await respondPut(req, request, result, tx);
break;
case 'DELETE':
return await respondDelete(req, request, result, tx);
response = await respondDelete(req, request, result, tx);
break;
case 'OPTIONS':
return await respondOptions(req, request, result, tx);
response = await respondOptions(req, request, result, tx);
break;
default:
throw new MethodNotAllowedError();
}
return { ...response, id: request.batchRequestId };
};

const checkReadOnlyRequests = (request: uriParser.ODataRequest) => {
Expand Down
20 changes: 15 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 All @@ -48,6 +51,7 @@ export interface ParsedODataRequest {
odataBinds: OdataBinds;
custom: AnyObject;
id?: number | undefined;
batchRequestId?: string;
_defer?: boolean;
}
export interface ODataRequest extends ParsedODataRequest {
Expand Down Expand Up @@ -263,15 +267,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 +300,14 @@ export async function parseOData(
const odata = memoizedParseOdata(url);

return {
batchRequestId: 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 +372,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 +389,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 12f462a

Please sign in to comment.