UNPKG

@itwin/presentation-backend

Version:

Backend of iTwin.js Presentation library

409 lines • 19.6 kB
/*--------------------------------------------------------------------------------------------- * Copyright (c) Bentley Systems, Incorporated. All rights reserved. * See LICENSE.md in the project root for license terms and full copyright notice. *--------------------------------------------------------------------------------------------*/ /** @packageDocumentation * @module RPC */ import { IModelDb, RpcTrace } from "@itwin/core-backend"; import { BeEvent, ErrorCategory, Logger, omit, StatusCategory, SuccessCategory } from "@itwin/core-bentley"; import { RpcPendingResponse } from "@itwin/core-common"; import { ContentFlags, DefaultContentDisplayTypes, KeySet, PresentationError, PresentationRpcInterface, PresentationStatus, RulesetVariable, SelectClassInfo, } from "@itwin/presentation-common"; import { buildElementProperties, createCancellableTimeoutPromise, deepReplaceNullsToUndefined } from "@itwin/presentation-common/internal"; // @ts-expect-error TS complains about `with` in CJS builds; The path is fine at runtime, but not at compile time // eslint-disable-next-line @itwin/import-within-package import packageJson from "../../../package.json" with { type: "json" }; import { PresentationBackendLoggerCategory } from "./BackendLoggerCategory.js"; import { Presentation } from "./Presentation.js"; import { DESCRIPTOR_ONLY_CONTENT_FLAG, getRulesetIdObject } from "./PresentationManagerDetail.js"; import { TemporaryStorage } from "./TemporaryStorage.js"; import { _presentation_manager_detail } from "./InternalSymbols.js"; const packageJsonVersion = packageJson.version; /** @internal */ export const MAX_ALLOWED_PAGE_SIZE = 1000; /** @internal */ export const MAX_ALLOWED_KEYS_PAGE_SIZE = 10000; const DEFAULT_REQUEST_TIMEOUT = 5000; /** * The backend implementation of PresentationRpcInterface. All it's basically * responsible for is forwarding calls to [[Presentation.manager]]. * * @internal */ export class PresentationRpcImpl extends PresentationRpcInterface { _requestTimeout; _pendingRequests; _cancelEvents; _statusHandler; constructor(props) { super(); this._requestTimeout = props?.requestTimeout ?? DEFAULT_REQUEST_TIMEOUT; this._pendingRequests = new TemporaryStorage({ // remove the pending request after request timeout + 10 seconds - this gives // frontend 10 seconds to re-send the request until it's removed from requests' cache unusedValueLifetime: this._requestTimeout > 0 ? this._requestTimeout + 10 * 1000 : undefined, // attempt to clean up every second cleanupInterval: 1000, cleanupHandler: (id, _, reason) => { if (reason !== "request") { Logger.logTrace(PresentationBackendLoggerCategory.Rpc, `Cleaning up request without frontend retrieving it: ${id}.`); /* c8 ignore next */ this._cancelEvents.get(id)?.raiseEvent(); } this._cancelEvents.delete(id); }, }); this._cancelEvents = new Map(); this._statusHandler = createStatusCategoryHandler(); StatusCategory.handlers.add(this._statusHandler); } [Symbol.dispose]() { this._pendingRequests[Symbol.dispose](); StatusCategory.handlers.delete(this._statusHandler); } get requestTimeout() { return this._requestTimeout; } get pendingRequests() { return this._pendingRequests; } /** Returns an ok response with result inside */ successResponse(result, diagnostics) { return { statusCode: PresentationStatus.Success, result, diagnostics, }; } /** Returns a bad request response with empty result and an error code */ errorResponse(errorCode, errorMessage, diagnostics) { return { statusCode: errorCode, result: undefined, errorMessage, diagnostics, }; } /** * Get the [[PresentationManager]] used by this RPC impl. */ getManager(clientId) { return Presentation.getManager(clientId); } async getIModel(token) { const imodel = IModelDb.findByKey(token.key); // call refreshContainer, just in case this is a V2 checkpoint whose sasToken is about to expire, or its default transaction is about to be restarted. await imodel.refreshContainerForRpc(RpcTrace.expectCurrentActivity.accessToken); return imodel; } async makeRequest(token, requestId, requestOptions, request) { const serializedRequestOptionsForLogging = JSON.stringify({ ...omit(requestOptions, ["rulesetOrId"]), ...(requestOptions.rulesetOrId ? { rulesetId: getRulesetIdObject(requestOptions.rulesetOrId).uniqueId } : undefined), }); Logger.logInfo(PresentationBackendLoggerCategory.Rpc, `Received '${requestId}' request. Params: ${serializedRequestOptionsForLogging}`); const imodel = await this.getIModel(token); const requestKey = JSON.stringify({ iModelKey: token.key, requestId, requestOptions }); let resultPromise = this._pendingRequests.getValue(requestKey); if (resultPromise) { Logger.logTrace(PresentationBackendLoggerCategory.Rpc, `Request already pending`); } else { Logger.logTrace(PresentationBackendLoggerCategory.Rpc, `Request not found, creating a new one`); const { clientId: _, diagnostics: diagnosticsOptions, rulesetVariables, ...options } = requestOptions; const managerRequestOptions = { ...options, imodel, cancelEvent: new BeEvent(), }; // set up ruleset variables if (rulesetVariables) { managerRequestOptions.rulesetVariables = rulesetVariables.map(RulesetVariable.fromJSON); } // set up diagnostics listener let diagnostics; const getDiagnostics = () => { if (!diagnostics) { diagnostics = {}; } return diagnostics; }; if (diagnosticsOptions) { if (diagnosticsOptions.backendVersion) { getDiagnostics().backendVersion = packageJsonVersion; } managerRequestOptions.diagnostics = { ...diagnosticsOptions, handler: (d) => { if (d.logs) { const target = getDiagnostics(); if (target.logs) { target.logs.push(...d.logs); } else { target.logs = [...d.logs]; } } }, }; } // initiate request resultPromise = request(managerRequestOptions).then((result) => this.successResponse(result, diagnostics)); // store the request promise this._pendingRequests.addValue(requestKey, resultPromise); this._cancelEvents.set(requestKey, managerRequestOptions.cancelEvent); } if (this._requestTimeout === 0) { Logger.logTrace(PresentationBackendLoggerCategory.Rpc, `Request timeout not configured, returning promise without a timeout.`); void resultPromise.finally(() => { this._pendingRequests.deleteValue(requestKey); }); return resultPromise; } Logger.logTrace(PresentationBackendLoggerCategory.Rpc, `Returning a promise with a timeout of ${this._requestTimeout}.`); const timeout = createCancellableTimeoutPromise(this._requestTimeout); return Promise.race([ resultPromise, timeout.promise.then(() => { // eslint-disable-next-line @typescript-eslint/only-throw-error throw new RpcPendingResponse("Timeout"); }), ]) .then((response) => { Logger.logTrace(PresentationBackendLoggerCategory.Rpc, `Request completed, returning result.`); this._pendingRequests.deleteValue(requestKey); return response; }) .finally(() => { timeout.cancel(); }); } async getNodesCount(token, requestOptions) { return this.makeRequest(token, "getNodesCount", requestOptions, async (options) => { return this.getManager(requestOptions.clientId).getNodesCount(options); }); } async getPagedNodes(token, requestOptions) { return this.makeRequest(token, "getPagedNodes", requestOptions, async (options) => { options = enforceValidPageSize(options); const [serializedHierarchyLevel, count] = await Promise.all([ this.getManager(requestOptions.clientId)[_presentation_manager_detail].getNodes(options), this.getManager(requestOptions.clientId).getNodesCount(options), ]); const hierarchyLevel = deepReplaceNullsToUndefined(JSON.parse(serializedHierarchyLevel)); return { total: count, items: hierarchyLevel.nodes, }; }); } async getNodesDescriptor(token, requestOptions) { return this.makeRequest(token, "getNodesDescriptor", requestOptions, async (options) => { return this.getManager(requestOptions.clientId)[_presentation_manager_detail].getNodesDescriptor(options); }); } async getNodePaths(token, requestOptions) { return this.makeRequest(token, "getNodePaths", requestOptions, async (options) => { return this.getManager(requestOptions.clientId)[_presentation_manager_detail].getNodePaths(options); }); } async getFilteredNodePaths(token, requestOptions) { return this.makeRequest(token, "getFilteredNodePaths", requestOptions, async (options) => { return this.getManager(requestOptions.clientId)[_presentation_manager_detail].getFilteredNodePaths(options); }); } async getContentSources(token, requestOptions) { return this.makeRequest(token, "getContentSources", requestOptions, async (options) => { const result = await this.getManager(requestOptions.clientId).getContentSources(options); const classesMap = {}; const selectClasses = result.map((sci) => SelectClassInfo.toCompressedJSON(sci, classesMap)); return { sources: selectClasses, classesMap }; }); } async getContentDescriptor(token, requestOptions) { return this.makeRequest(token, "getContentDescriptor", requestOptions, async (options) => { options = { ...options, contentFlags: (options.contentFlags ?? 0) | DESCRIPTOR_ONLY_CONTENT_FLAG, // always append the "descriptor only" flag when handling request from the frontend keys: KeySet.fromJSON(options.keys), }; // Here we send a plain JSON string but we will parse it to DescriptorJSON on the frontend. This way we are // bypassing unnecessary deserialization and serialization. return Presentation.getManager(requestOptions.clientId)[_presentation_manager_detail].getContentDescriptor(options); }); } async getContentSetSize(token, requestOptions) { return this.makeRequest(token, "getContentSetSize", requestOptions, async (options) => { options = { ...options, keys: KeySet.fromJSON(options.keys), }; return this.getManager(requestOptions.clientId).getContentSetSize(options); }); } async getPagedContent(token, requestOptions) { return this.makeRequest(token, "getPagedContent", requestOptions, async (options) => { options = enforceValidPageSize({ ...options, keys: KeySet.fromJSON(options.keys), }); const [size, content] = await Promise.all([ this.getManager(requestOptions.clientId).getContentSetSize(options), this.getManager(requestOptions.clientId)[_presentation_manager_detail].getContent(options), ]); if (!content) { return undefined; } return { descriptor: content.descriptor.toJSON(), contentSet: { total: size, items: content.contentSet.map((i) => i.toJSON()), }, }; }); } async getPagedContentSet(token, requestOptions) { // eslint-disable-next-line @typescript-eslint/no-deprecated const response = await this.getPagedContent(token, requestOptions); if (response.statusCode !== PresentationStatus.Success) { return this.errorResponse(response.statusCode, response.errorMessage, response.diagnostics); } if (!response.result) { return this.errorResponse(PresentationStatus.Error, `Failed to get content set (received a success response with empty result)`, response.diagnostics); } return { ...response, result: response.result.contentSet, }; } async getElementProperties(token, requestOptions) { return this.makeRequest(token, "getElementProperties", { ...requestOptions }, async (options) => { const manager = this.getManager(requestOptions.clientId); const { elementId, ...optionsNoElementId } = options; const content = await manager[_presentation_manager_detail].getContent({ ...optionsNoElementId, descriptor: { displayType: DefaultContentDisplayTypes.PropertyPane, contentFlags: ContentFlags.ShowLabels, }, rulesetOrId: "ElementProperties", keys: new KeySet([{ className: "BisCore:Element", id: elementId }]), }); if (!content || content.contentSet.length === 0) { return undefined; } return buildElementProperties(content.descriptor, content.contentSet[0]); }); } async getPagedDistinctValues(token, requestOptions) { return this.makeRequest(token, "getPagedDistinctValues", requestOptions, async (options) => { options = enforceValidPageSize({ ...options, keys: KeySet.fromJSON(options.keys), }); return this.getManager(requestOptions.clientId)[_presentation_manager_detail].getPagedDistinctValues(options); }); } async getContentInstanceKeys(token, requestOptions) { return this.makeRequest(token, "getContentInstanceKeys", requestOptions, async (options) => { const { displayType, ...optionsNoDisplayType } = options; options = enforceValidPageSize({ ...optionsNoDisplayType, keys: KeySet.fromJSON(optionsNoDisplayType.keys), descriptor: { displayType, contentFlags: ContentFlags.KeysOnly, }, }, MAX_ALLOWED_KEYS_PAGE_SIZE); const [size, content] = await Promise.all([ this.getManager(requestOptions.clientId).getContentSetSize(options), this.getManager(requestOptions.clientId)[_presentation_manager_detail].getContent(options), ]); if (size === 0 || !content) { return { total: 0, items: new KeySet().toJSON() }; } return { total: size, items: content.contentSet.reduce((keys, item) => keys.add(item.primaryKeys), new KeySet()).toJSON(), }; }); } async getDisplayLabelDefinition(token, requestOptions) { return this.makeRequest(token, "getDisplayLabelDefinition", requestOptions, async (options) => { const label = await this.getManager(requestOptions.clientId)[_presentation_manager_detail].getDisplayLabelDefinition(options); return label; }); } async getPagedDisplayLabelDefinitions(token, requestOptions) { const pageOpts = enforceValidPageSize({ paging: { start: 0, size: requestOptions.keys.length } }); if (pageOpts.paging.size < requestOptions.keys.length) { requestOptions.keys.splice(pageOpts.paging.size); } return this.makeRequest(token, "getPagedDisplayLabelDefinitions", requestOptions, async (options) => { const labels = await this.getManager(requestOptions.clientId)[_presentation_manager_detail].getDisplayLabelDefinitions({ ...options, keys: options.keys, }); return { total: options.keys.length, items: labels, }; }); } /* eslint-disable @typescript-eslint/no-deprecated */ async getSelectionScopes(token, requestOptions) { return this.makeRequest(token, "getSelectionScopes", requestOptions, async (options) => this.getManager(requestOptions.clientId).getSelectionScopes(options)); } async computeSelection(token, requestOptions) { return this.makeRequest(token, "computeSelection", requestOptions, async (options) => { const keys = await this.getManager(requestOptions.clientId).computeSelection(options); return keys.toJSON(); }); } } const enforceValidPageSize = (requestOptions, maxPageSize = MAX_ALLOWED_PAGE_SIZE) => { const validPageSize = getValidPageSize(requestOptions.paging?.size, maxPageSize); if (!requestOptions.paging || requestOptions.paging.size !== validPageSize) { return { ...requestOptions, paging: { ...requestOptions.paging, size: validPageSize } }; } return requestOptions; }; const getValidPageSize = (size, maxPageSize) => { const requestedSize = size ?? 0; return requestedSize === 0 || requestedSize > maxPageSize ? maxPageSize : requestedSize; }; // not testing temporary solution /* c8 ignore start */ function createStatusCategoryHandler() { return (e) => { if (e instanceof PresentationError) { switch (e.errorNumber) { case PresentationStatus.NotInitialized: return new (class extends ErrorCategory { name = "Internal server error"; code = 500; })(); case PresentationStatus.Canceled: return new (class extends SuccessCategory { name = "Cancelled"; code = 204; })(); case PresentationStatus.ResultSetTooLarge: return new (class extends ErrorCategory { name = "Result set is too large"; code = 413; })(); case PresentationStatus.Error: case PresentationStatus.InvalidArgument: return new (class extends ErrorCategory { name = "Invalid request props"; code = 422; })(); } } return undefined; }; } /* c8 ignore end */ //# sourceMappingURL=PresentationRpcImpl.js.map