import { RequestFactory } from "./RequestFactory"; import { BlobBuilder } from "../BlobBuilder"; import { utils } from "../utils"; import { HEADER_NAMES, HTTP_METHODS } from "../constants"; import { ExecutableRequest } from "./ExecutableRequest"; import { SDKOptions, Token, TypeOptions } from "../Sitefinity"; import { RequestBase, RequestOptionsBaseExtended } from "./RequestBase"; import { CreateRelatedRequestOptions, RequestOptionsBase, PropertyRequestOptions, SingleRelatedItemRequestOptions, CreateRequestOptions, UpdateRequestOptions } from "../Data"; import { PostRequestOptionsExtended, PutRequestOptionsExtended, RelatedByIdRequestOptionsExtended, RelatedRequestOptionsExtended, UploadRequestOptionsExtended } from "./Request"; import { Query } from "../Query"; /** * The transaction class is used to group operations that involve data manipulation together and execute them as a single atomic operation. */ export class Transaction { id: number; counter: number; requests: TransactionRequest[]; private urlOptions: TypeOptions; private factory: RequestFactory; constructor(urlOptions: TypeOptions, factory: RequestFactory) { this.urlOptions = urlOptions; this.factory = factory; this.requests = []; this.counter = 0; } upload(parameters: any): number { const urlOptions = this.urlParametersClone(parameters.entitySet); const options: UploadRequestOptionsExtended = { urlOptions: urlOptions, data: parameters.data, contentType: parameters.contentType, fileName: parameters.fileName, key: parameters.key, uploadProperties: parameters.uploadProperties }; const request = this.factory.upload(options); return this.addRequest(request); } /** * Adds a POST request to create a single item. */ create(parameters: BatchCreateRequestOptions) { if (!(parameters || parameters.entitySet)) throw "entitySet is required!"; const urlOptions = this.urlParametersClone(parameters.entitySet); const options: PostRequestOptionsExtended = { key: parameters.key, action: parameters.action, urlOptions: urlOptions, data: parameters.data }; const request = this.factory.create(options); return this.addRequest(request); } /** * Adds a POST request to execute an operation on single item. */ // TODO: Remove operation(parameters: BatchCreateRequestOptions) { if (!(parameters || parameters.entitySet || parameters.key || parameters.data)) throw "parameters: entitySet, key and data are required!"; const urlOptions = this.urlParametersClone(parameters.entitySet); const options: PostRequestOptionsExtended = { urlOptions: urlOptions, key: parameters.key, action: "operation", data: parameters.data }; const request = this.factory.create(options); this.overrideUrlGeneration(request, parameters.key, "operation", options.urlOptions, options.query); this.addRequest(request); } /** * Adds a PATCH request to update a single item. */ update(parameters: BatchUpdateRequestOptions) { if (!(parameters || parameters.entitySet || parameters.key || parameters.data)) throw "parameters: entitySet, key and data are required!"; const urlOptions = this.urlParametersClone(parameters.entitySet); const options: PutRequestOptionsExtended = { urlOptions: urlOptions, saveTemp: parameters.saveTemp, key: parameters.key, data: parameters.data }; const request = this.factory.update(options); this.overrideUrlGeneration(request, parameters.key, undefined, options.urlOptions, options.query); this.addRequest(request); } /** * Adds a DELETE request to delete a single item. */ destroy(parameters: BatchSingleItemRequestOptions) { if (!(parameters || parameters.entitySet || parameters.key)) throw "parameters: entitySet and key are required!"; const urlOptions = this.urlParametersClone(parameters.entitySet); const options: RequestOptionsBaseExtended = { urlOptions: urlOptions, key: parameters.key }; const request = this.factory.destroy(options); this.overrideUrlGeneration(request, parameters.key, undefined, options.urlOptions, options.query); this.addRequest(request); } /** * Adds a DELETE request to delete all of the associated members in the relation property. */ destroyRelated(parameters: BatchPropertyRequestOptions) { if (!(parameters || parameters.entitySet || parameters.key)) throw "parameters: entitySet and key are required!"; const urlOptions = this.urlParametersClone(parameters.entitySet); const options: RelatedRequestOptionsExtended = { urlOptions: urlOptions, key: parameters.key, property: parameters.property }; const request = this.factory.destroyRelated(options); const propertyURL = (request as any).property + "/$ref"; this.overrideUrlGeneration(request, parameters.key, propertyURL, options.urlOptions, options.query); this.addRequest(request); } /** * Adds a DELETE request to retrieve the a single entry of the associated members in the relation property. */ destroyRelatedById(parameters: BatchSingleRelatedItemRequestOptions) { if (!(parameters || parameters.entitySet || parameters.key || parameters.relatedId || parameters.property)) throw "parameters: entitySet, key, relatedId and property are required!"; const urlOptions = this.urlParametersClone(parameters.entitySet); const options: RelatedByIdRequestOptionsExtended = { urlOptions: urlOptions, key: parameters.key, relatedId: parameters.relatedId, property: parameters.property }; const request = this.factory.destroyRelatedById(options); this.addRequest(request); } /** * Adds a POST request to add a related item to the property collection. */ createRelated(parameters: BatchCreateRelatedRequestOptions) { if (!(parameters || parameters.entitySet || parameters.key || parameters.link || parameters.property)) throw "parameters: entitySet, key, link and property are required!"; // TODO: rethink about the urls const urlOptions = this.urlParametersClone(parameters.entitySet); for (let index = 0; index < this.requests.length; index += 1) { if (this.requests[index].id == parameters.link) { parameters.link = "$" + this.requests[index].id; } } const data = { "@odata.id": parameters.link }; const options: RelatedRequestOptionsExtended = { urlOptions: urlOptions, key: parameters.key, property: parameters.property, data: data }; const request = this.factory.createRelated(options); const propertyURL = (request as any).property + "/$ref"; this.overrideUrlGeneration(request, parameters.key, propertyURL, options.urlOptions, options.query); return this.addRequest(request); } private overrideUrlGeneration(request: RequestBase, key: number | string, additionalPropertyURL: string, urlOptions?: TypeOptions, query?: Query) { // TODO: match requests with Content-ID's! // Content-ID's are the same as id's in requests. for (let index = 0; index < this.requests.length; index += 1) { if (this.requests[index].id == key) { request.buildUrl = function () { let uri = "$" + key; if (additionalPropertyURL !== undefined) { uri = uri + "/" + additionalPropertyURL; } if (urlOptions) { const queryString = request.getQueryString(urlOptions, query); if (queryString) { uri = uri + queryString; } } return uri; }; } } } private addRequest(request: RequestBase) { this.requests.push({ id: this.counter, request: request }); return this.counter++; } private urlParametersClone(entitySet: string) { const options = utils.clone(this.urlOptions); options.entitySet = entitySet; return options; } } /** * The batch request class is used for grouping requests into a single batch request to execute on the server. */ export class BatchRequest extends ExecutableRequest { counter: number; requests: BatchInnerRequest[]; private factory: RequestFactory; private boundary: string; private responseHeaderRegex: RegExp; private responseStatusRegex: RegExp; public static constants = { newLine: "\r\n" }; constructor(requestOptions: BatchRequestOptionsExtended, token: Token, sdkOptions: SDKOptions) { super(requestOptions, token, sdkOptions); this.factory = requestOptions.factory; this.requests = []; this.counter = 0; this.boundary = this.createBoundary("sf_batch_"); this.responseHeaderRegex = /^([^()<>@,;:\\"\/[\]?={} \t]+)\s?:\s?(.*)/; this.responseStatusRegex = /^HTTP\/1\.\d (\d{3}) (.*)$/i; } /** * Adds a GET request to retrieve a collection of items. */ get(parameters: BatchRequestOptionsBase) { if (!(parameters || parameters.entitySet)) throw "entitySet is required!"; const urlOptions = this.urlParametersClone(parameters.entitySet); const options: RequestOptionsBaseExtended = { urlOptions: urlOptions, action: parameters.action, query: parameters.query, fallbackProperties: parameters.fallbackProperties }; const request = this.factory.get(options); this.addRequest(request); } /** * Adds a GET request for the retrieval of a single item. */ getSingle(parameters: BatchSingleItemRequestOptions) { if (!(parameters || parameters.entitySet || parameters.key)) throw "parameters: entitySet and key are required!"; const urlOptions = this.urlParametersClone(parameters.entitySet); const options: RequestOptionsBaseExtended = { urlOptions: urlOptions, action: parameters.action, key: parameters.key, query: parameters.query }; const request = this.factory.getSingle(options); this.addRequest(request); } /** * Adds a GET request to get the property of a single item. */ getProperty(parameters: BatchPropertyRequestOptions) { if (!(parameters || parameters.entitySet || parameters.key, parameters.property)) throw "parameters: entitySet, key and property are required!"; const urlOptions = this.urlParametersClone(parameters.entitySet); const options: RelatedRequestOptionsExtended = { urlOptions: urlOptions, key: parameters.key, property: parameters.property }; const request = this.factory.getProperty(options); this.addRequest(request); } /** * Adds a GET request to get the related property value of a single item. */ getRelated(parameters: BatchPropertyRequestOptions) { if (!(parameters || parameters.entitySet || parameters.key || parameters.property)) throw "parameters: entitySet, key and property are required!"; const urlOptions = this.urlParametersClone(parameters.entitySet); const options: RelatedRequestOptionsExtended = { urlOptions: urlOptions, key: parameters.key, property: parameters.property }; const request = this.factory.getRelated(options); this.addRequest(request); } /** * Adds a GET request to retrieve the related property value of a single item by the id of the related item. */ getRelatedById(parameters: BatchSingleRelatedItemRequestOptions) { if (!(parameters || parameters.entitySet || parameters.key || parameters.relatedId || parameters.property)) throw "parameters: entitySet, key, relatedId and property are required!"; const urlOptions = this.urlParametersClone(parameters.entitySet); const options: RelatedByIdRequestOptionsExtended = { urlOptions: urlOptions, key: parameters.key, relatedId: parameters.relatedId, property: parameters.property }; const request = this.factory.getRelatedById(options); this.addRequest(request); } /** * Begins a transaction. */ beginTransaction(): Transaction { const option = utils.clone(this.urlOptions); const newTransaction = new Transaction(option, this.factory); newTransaction.id = ++this.counter; return newTransaction; } /** * Ends a transaction. */ endTransaction(transaction: Transaction) { this.requests.push({ id: transaction.id, request: transaction }); } getBody(): object { return this.buildRequestBody(); } getMethod(): string { return HTTP_METHODS.post; } getHeaders(): { [key: string]: string } { const headers = this.urlOptions.additionalHeaders ?? {}; const contentType = "multipart/mixed; boundary=" + this.boundary; headers[HEADER_NAMES.contentType] = contentType; return headers; } buildUrl(): string { let url = this.urlOptions.baseUrl + "$batch"; const queryString = this.getQueryString(this.urlOptions, this.query); if (queryString) { url += queryString; } return url; } protected parseResponse(http: XMLHttpRequest): any { const context = { position: 0, boundaries: [] }; const pos = context.position; const partHeaders = this.readHeaders(http.getAllResponseHeaders(), context); const partContentType = this.contentType(partHeaders["content-type"]); if (partContentType && partContentType.mediaType == "multipart/mixed") { context.boundaries.push((partContentType.properties as any).boundary); } context.position = pos; const batchResponse = this.parseBatchResponse(http.responseText, context); const responseParsed = []; for (let i = 0; i < batchResponse.length; i++) { try { const currentRequest = this.requests[i]; let finalResult = null; let key = null; const singleResponse = batchResponse[i]; const changesets = singleResponse.__changeResponses; if (changesets) { finalResult = []; const currentRequests = currentRequest ? (currentRequest.request as Transaction).requests : []; for (let j = 0; j < changesets.length; j++) { key = currentRequests[j] ? currentRequests[j].request.key : null; const changeset = changesets[j]; const response = changeset.response; const responseResult = this.getResponseResult(response, key); finalResult.push(responseResult); } } else { key = currentRequest ? (currentRequest.request as any).key : null; const response = singleResponse.response; finalResult = this.getResponseResult(response, key); } const statusCodeCheck = function (r) { return r.statusCode >= 400; }; const responseCheck = function (r) { let success = true; if (r.data && r.data.hasOwnProperty("Success")) { success = r.data.Success; } return success; }; const isSuccessful = Array.isArray(finalResult) ? finalResult.filter(statusCodeCheck).length === 0 && finalResult.filter(responseCheck).length === finalResult.length : !statusCodeCheck(finalResult) && responseCheck(finalResult); responseParsed.push({ isSuccessful: isSuccessful, response: finalResult }); } catch (e) { // TODO: why continue ? continue; } } const isSuccessfulCheck = (r) => { return !r.isSuccessful; }; const batchIsSuccessful = responseParsed.filter(isSuccessfulCheck).length === 0; return { data: responseParsed, isSuccessful: batchIsSuccessful }; } private parseBatchResponse(text, context) { // get boundary from response const delimiter = "--" + this.currentBoundary(context); this.readTo(text, delimiter, context); this.readLine(text, context); const responses = []; let partEnd; while (partEnd !== "--" && context.position < text.length) { const partHeaders = this.readHeaders(text, context); const partContentType = this.contentType(partHeaders["content-type"]); let changeResponses; if (partContentType && partContentType.mediaType === "multipart/mixed") { context.boundaries.push((partContentType.properties as any).boundary); try { changeResponses = this.parseBatchResponse(text, context); } catch (e) { e.response = this.readResponse(text, context, delimiter); changeResponses = [e]; } responses.push({ __changeResponses: changeResponses }); context.boundaries.pop(); this.readTo(text, "--" + this.currentBoundary(context), context); } else { if (!partContentType || partContentType.mediaType !== "application/http") { throw { message: "invalid MIME part type " }; } // Skip empty line this.readLine(text, context); // Read the response let response: any = this.readResponse(text, context, delimiter); try { if (response.statusCode >= 200 && response.statusCode <= 299) { response = { message: response.statusCode + " Http request is OK", response: response }; } else { // Keep track of failed responses and continue processing the batch. response = { message: "HTTP request failed", response: response }; } } catch (e) { response = e; } responses.push(response); } partEnd = text.substr(context.position, 2); // Ignore the incoming line. this.readLine(text, context); } return responses; } private getResponseResult(response, key) { const responseBody = response.body; const data = utils.parseStringToJson(responseBody); return { key: key, data: data, statusCode: response.statusCode, headers: response.headers }; } private currentBoundary(context) { const boundaries = context.boundaries; return boundaries[boundaries.length - 1]; } private contentType(str) { if (!str) { return null; } const contentTypeParts = str.split(";"); const properties = {}; let i, len; for (i = 1, len = contentTypeParts.length; i < len; i++) { const contentTypeParams = contentTypeParts[i].split("="); properties[contentTypeParams[0].replace(/^\s+|\s+$/g, "")] = contentTypeParams[1]; } return { mediaType: contentTypeParts[0].replace(/^\s+|\s+$/g, ""), properties: properties }; } private readTo(text, str, context) { const start = context.position || 0; let end = text.length; if (str) { end = text.indexOf(str, start); if (end === -1) { return null; } context.position = end + str.length; } else { context.position = end; } return text.substring(start, end); } private readLine(text, context) { return this.readTo(text, BatchRequest.constants.newLine, context); } private readHeaders(text, context) { const headers = {}; let parts; let line; let pos; do { pos = context.position; line = this.readLine(text, context); parts = this.responseHeaderRegex.exec(line); if (parts !== null) { headers[parts[1].toLowerCase()] = parts[2]; } else { // Whatever was found is not a header, so reset the context position. context.position = pos; } } while (line && parts); return headers; } private readResponse(text, context, delimiter) { // Read the status line. const pos = context.position; const match = this.responseStatusRegex.exec(this.readLine(text, context)); let statusCode; let statusText; let headers; if (match) { statusCode = match[1]; statusText = match[2]; headers = this.readHeaders(text, context); this.readLine(text, context); } else { context.position = pos; } return { statusCode: statusCode, statusText: statusText, headers: headers, body: this.readTo(text, BatchRequest.constants.newLine + delimiter, context) }; } private buildRequestBody() { const builder = new BlobBuilder(); builder.append(""); for (let index = 0; index < this.requests.length; index += 1) { const request = this.requests[index].request; if (index != 0) builder.append(BatchRequest.constants.newLine + BatchRequest.constants.newLine); builder.append("--" + this.boundary); if (request instanceof Transaction) { builder.append(this.buildTransaction(request)); } else { builder.append(this.buildRequest(request)); } } builder.append(this.createDelimeter(this.boundary, true)); return builder.build(); } private buildRequest(request: RequestBase): Blob { const builder = new BlobBuilder(); builder.append(BatchRequest.constants.newLine + "Content-Type: application/http" + BatchRequest.constants.newLine + "Content-Transfer-Encoding: binary" + BatchRequest.constants.newLine + BatchRequest.constants.newLine + request.getMethod() + " " + request.buildUrl() + " HTTP/1.1"); return builder.build(); } private buildTransaction(transaction: Transaction) { if (transaction.requests.length == 0) return; const builder = new BlobBuilder(); builder.append(BatchRequest.constants.newLine); const changeset = this.createBoundary("sf_changeset_"); builder.append("Content-Type: multipart/mixed; boundary=" + changeset); const changesetRequests = transaction.requests; for (let index = 0; index < changesetRequests.length; index += 1) { const currentRequest = changesetRequests[index].request; // we should add only single new line after blob content. Otherwise the blob is corrupted const shouldAddSingleNewLine = index != 0 && utils.isBlob(changesetRequests[index - 1].request.getBody()); builder.append(this.createDelimeter(changeset, false, shouldAddSingleNewLine) + BatchRequest.constants.newLine + "Content-Type: application/http" + BatchRequest.constants.newLine + "Content-Transfer-Encoding: binary" + BatchRequest.constants.newLine + "Content-ID: " + changesetRequests[index].id); const requestUrl = currentRequest.buildUrl(); builder.append(BatchRequest.constants.newLine + BatchRequest.constants.newLine + currentRequest.getMethod() + " " + requestUrl + " HTTP/1.1"); const headers = currentRequest.getHeaders(); const headerNames = Object.keys(headers); for (let i = 0; i < headerNames.length; i++) { const headerName = headerNames[i]; builder.append(BatchRequest.constants.newLine + headerName + ": " + headers[headerName]); } const body = utils.serializeToJSON(currentRequest.getBody()); if (body) { builder.append(BatchRequest.constants.newLine + BatchRequest.constants.newLine); builder.append(body); } } builder.append(this.createDelimeter(changeset, true)); return builder.build(); } private createDelimeter(boundary, close, shouldAddSingleNewLine = false) { let result = BatchRequest.constants.newLine; if (!shouldAddSingleNewLine) { result += BatchRequest.constants.newLine; } result += "--" + boundary; if (close) { result += "--"; result += BatchRequest.constants.newLine; } return result; } private createBoundary(prefix) { return prefix + utils.hex16() + "-" + utils.hex16() + "-" + utils.hex16(); } private addRequest(request: RequestBase) { this.requests.push({ id: this.counter, request: request }); return this.counter++; } private urlParametersClone(entitySet: string): TypeOptions { const options = utils.clone(this.urlOptions); options.entitySet = entitySet; return options; } } interface TransactionRequest { id: number, request: RequestBase; } interface BatchInnerRequest { id: number, request: RequestBase | Transaction; } export interface BatchRequestOptionsExtended extends RequestOptionsBaseExtended { factory: RequestFactory; } export interface BatchRequestOptionsBase extends RequestOptionsBase { entitySet: string; } export interface BatchCreateRequestOptions extends CreateRequestOptions { entitySet: string; } export interface BatchUpdateRequestOptions extends UpdateRequestOptions { entitySet: string; } export interface BatchSingleItemRequestOptions extends RequestOptionsBase { entitySet: string; } export interface BatchPropertyRequestOptions extends PropertyRequestOptions { entitySet: string; } export interface BatchSingleRelatedItemRequestOptions extends SingleRelatedItemRequestOptions { entitySet: string; } export interface BatchCreateRelatedRequestOptions extends CreateRelatedRequestOptions { entitySet: string; }