Skip to content

Commit

Permalink
$batch
Browse files Browse the repository at this point in the history
Change-type: major
  • Loading branch information
myarmolinsky committed Jul 3, 2023
1 parent 77dfb77 commit 4fc5b74
Show file tree
Hide file tree
Showing 9 changed files with 758 additions and 35 deletions.
130 changes: 101 additions & 29 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,7 +135,8 @@ export interface ApiKey extends Actor {
}

export interface Response {
statusCode: number;
id?: string | undefined;
status: number;
headers?:
| {
[headerName: string]: any;
Expand Down Expand Up @@ -1022,15 +1025,15 @@ export const runURI = async (
throw response;
}

const { body: responseBody, statusCode, headers } = response as Response;
const { body: responseBody, status, headers } = response as Response;

if (statusCode != null && statusCode >= 400) {
if (status != null && status >= 400) {
const ErrorClass =
statusCodeToError[statusCode as keyof typeof statusCodeToError];
statusCodeToError[status as keyof typeof statusCodeToError];
if (ErrorClass != null) {
throw new ErrorClass(undefined, responseBody, headers);
}
throw new HttpError(statusCode, undefined, responseBody, headers);
throw new HttpError(status, undefined, responseBody, headers);
}

return responseBody as AnyObject | undefined;
Expand Down Expand Up @@ -1069,7 +1072,7 @@ export const getAffectedIds = async (
args: HookArgs & {
tx: Db.Tx;
},
): Promise<number[]> => {
): Promise<string[]> => {
const { request } = args;
if (request.affectedIds) {
return request.affectedIds;
Expand All @@ -1094,7 +1097,7 @@ const $getAffectedIds = async ({
tx,
}: HookArgs & {
tx: Db.Tx;
}): Promise<number[]> => {
}): Promise<string[]> => {
if (!['PATCH', 'DELETE'].includes(request.method)) {
// We can only find the affected ids in advance for requests that modify existing records, if they
// can insert new records (POST/PUT) then we're unable to find the ids until the request has actually run
Expand All @@ -1108,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 @@ -1158,6 +1162,65 @@ const runODataRequest = (req: Express.Request, vocabulary: string) => {
api[vocabulary].logger.log('Parsing', req.method, req.url);
}

if (req.url === `/${vocabulary}/$batch`) {
const { requests } = req.body as { requests: uriParser.UnparsedRequest[] };
req.batch = requests;
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.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',
);
}
}

// 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 @@ -1209,7 +1272,7 @@ const runODataRequest = (req: Express.Request, vocabulary: string) => {
requests = req.batch;
} else {
const { method, url, body } = req;
requests = [{ method, url, data: body }];
requests = [{ method, url, body }];
}

const prepareRequest = async (
Expand Down Expand Up @@ -1268,15 +1331,17 @@ 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.headers,
);

let request: uriParser.ODataRequest | uriParser.ODataRequest[];
if (Array.isArray(parsedRequest)) {
request = await controlFlow.mapSeries(parsedRequest, prepareRequest);
} else {
request = await prepareRequest(parsedRequest);
}
// Run the request in its own transaction
return await runTransaction<Response | Response[]>(
req,
request,
Expand All @@ -1293,7 +1358,7 @@ const runODataRequest = (req: Express.Request, vocabulary: string) => {
}
});
if (Array.isArray(request)) {
const changeSetResults = new Map<number, Response>();
const changeSetResults = new Map<string, Response>();
const changeSetRunner = runChangeSet(req, tx);
for (const r of request) {
await changeSetRunner(changeSetResults, r);
Expand All @@ -1314,7 +1379,7 @@ const runODataRequest = (req: Express.Request, vocabulary: string) => {
if (
!Array.isArray(result) &&
result.body == null &&
result.statusCode == null
result.status == null
) {
console.error('No status or body set', req.url, responses);
return new InternalRequestError();
Expand Down Expand Up @@ -1352,15 +1417,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) {
response = httpErrorToResponse(response);
return httpErrorToResponse(response);
} else {
return response;
}
}),
);
});
}
} catch (e: any) {
if (handleHttpErrors(req, res, e)) {
Expand All @@ -1387,16 +1452,16 @@ 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;
}
return false;
};
const handleResponse = (res: Express.Response, response: Response): void => {
const { body, headers, statusCode } = response as Response;
const { body, headers, status } = response as Response;
res.set(headers);
res.status(statusCode);
res.status(status);
if (!body) {
res.end();
} else {
Expand All @@ -1406,10 +1471,15 @@ const handleResponse = (res: Express.Response, response: Response): void => {

const httpErrorToResponse = (
err: HttpError,
): RequiredField<Response, 'statusCode'> => {
req?: Express.Request,
): RequiredField<Response, 'status'> => {
const message = err.getResponseBody();
return {
statusCode: err.status,
body: err.getResponseBody(),
status: err.status,
body:
req?.batch != null && req.batch.length > 0
? { responses: [], message }
: message,
headers: err.headers,
};
};
Expand Down Expand Up @@ -1514,7 +1584,7 @@ const runRequest = async (
const runChangeSet =
(req: Express.Request, tx: Db.Tx) =>
async (
changeSetResults: Map<number, Response>,
changeSetResults: Map<string, Response>,
request: uriParser.ODataRequest,
): Promise<void> => {
request = updateBinds(changeSetResults, request);
Expand All @@ -1532,7 +1602,7 @@ const runChangeSet =
// deferred untill the request they reference is run and returns an insert ID.
// This function compiles the sql query of a request which has been deferred
const updateBinds = (
changeSetResults: Map<number, Response>,
changeSetResults: Map<string, Response>,
request: uriParser.ODataRequest,
) => {
if (request._defer) {
Expand Down Expand Up @@ -1700,7 +1770,8 @@ const respondGet = async (
);

const response = {
statusCode: 200,
id: request.id,
status: 200,
body: { d },
headers: { 'content-type': 'application/json' },
};
Expand All @@ -1715,14 +1786,15 @@ const respondGet = async (
} else {
if (request.resourceName === '$metadata') {
return {
statusCode: 200,
id: request.id,
status: 200,
body: models[vocab].odataMetadata,
headers: { 'content-type': 'xml' },
};
} else {
// TODO: request.resourceName can be '$serviceroot' or a resource and we should return an odata xml document based on that
return {
statusCode: 404,
status: 404,
};
}
}
Expand Down Expand Up @@ -1778,7 +1850,7 @@ const respondPost = async (
}

const response = {
statusCode: 201,
status: 201,
body: result.d[0],
headers: {
'content-type': 'application/json',
Expand Down Expand Up @@ -1826,7 +1898,7 @@ const respondPut = async (
tx: Db.Tx,
): Promise<Response> => {
const response = {
statusCode: 200,
status: 200,
};
await runHooks('PRERESPOND', request.hooks, {
req,
Expand Down
30 changes: 24 additions & 6 deletions src/sbvr-api/uri-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,19 @@ import * as sbvrUtils from './sbvr-utils';
export type OdataBinds = ODataBinds;

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

export interface ParsedODataRequest {
headers?: {
[key: string]: string | string[] | undefined;
};
method: SupportedMethod;
url: string;
vocabulary: string;
Expand All @@ -47,7 +51,7 @@ export interface ParsedODataRequest {
odataQuery: ODataQuery;
odataBinds: OdataBinds;
custom: AnyObject;
id?: number | undefined;
id?: string | undefined;
_defer?: boolean;
}
export interface ODataRequest extends ParsedODataRequest {
Expand All @@ -60,8 +64,8 @@ export interface ODataRequest extends ParsedODataRequest {
modifiedFields?: ReturnType<
AbstractSQLCompiler.EngineInstance['getModifiedFields']
>;
affectedIds?: number[];
pendingAffectedIds?: Promise<number[]>;
affectedIds?: string[];
pendingAffectedIds?: Promise<string[]>;
hooks?: Array<[string, InstantiatedHooks]>;
engine: AbstractSQLCompiler.Engines;
}
Expand Down Expand Up @@ -263,15 +267,27 @@ export const metadataEndpoints = ['$metadata', '$serviceroot'];

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

return {
id: b.id,
headers: { ...headers, ...b.headers } ?? 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 +380,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 Down
Loading

0 comments on commit 4fc5b74

Please sign in to comment.