/*--------------------------------------------------------------------------------------------- * Copyright (c) Bentley Systems, Incorporated. All rights reserved. * See LICENSE.md in the project root for license terms and full copyright notice. *--------------------------------------------------------------------------------------------*/ /** @packageDocumentation * @module RealityDataClient */ import { RealityDataClientError } from "./RealityDataClientError"; import type { AuthorizationClient } from "./auth"; import { ITwinRealityData } from "./RealityData"; import { getRequestConfig } from "./RequestOptions"; import { Project } from "./Projects"; import { type RealityDataCreate as RCRealityDataCreate, type RealityDataUpdate as RCRealityDataUpdate, type Response as RCResponse, type Type as RCType, RealityCaptureService, } from "@itwin/reality-capture"; /** * A mutable AuthorizationClient that holds an access token. * Used internally to bridge the per-call accessToken pattern of RealityDataAccessClient * to the constructor-injected AuthorizationClient of RealityCaptureService. */ class TokenHolder implements AuthorizationClient { private _token: string = ""; public setToken(token: string) { this._token = token; } public async getAccessToken(): Promise { return this._token; } } /** Options for initializing Reality Data Client * @beta */ export interface RealityDataClientOptions { /** The authorization client to use to get access token to Context Share API (authority: https://ims.bentley.com ) * When define it will ignore accessToken from API parameters and will get an access token from this client. */ authorizationClient?: AuthorizationClient; /** API Version. v1 by default */ version?: ApiVersion; /** API Url. Used to select environment. Defaults to "https://api.bentley.com/reality-management" */ baseUrl?: string; } /** Available Reality Management API Versions */ export enum ApiVersion { v1, } /** Criteria used to query for reality data associated with an iTwin context. * @see getRealityDatas * @beta */ export interface RealityDataQueryCriteria { /** If supplied, only reality data overlapping this range will be included. */ extent?: CartographicRange; /** If true, return all properties for every reality data found in query. * If false or undefined, return a minimal representation containing id, displayName and type, along with a url to get full reality data details. */ getFullRepresentation?: boolean; /** If supplied, queries a maximum number of first results Found. Max 500. If not supplied, the query should return the first 100 RealityData found.*/ top?: number; /** Continuation token to get current query's next results.*/ continuationToken?: string; /** Parameter that orders reality data in ascending or descending order. Default is ascending (asc). Can be used on any simple text, date or number property. Example : size desc */ orderBy?: string; /** Searches the given text (case insensitive) in reality data's text properties, such as in Group, DisplayName, Description, RootDocument, Acquirer, Tags. */ search?: string; /** Queries for reality data of specified types.*/ types?: string[]; /** Queries for reality data in which the acquisition is in given date range.*/ acquisitionDates?: DateRange; /** Queries for reality data where the creation date is in given date range.*/ createdDateTime?: DateRange; /** Queries for reality data where the modification date is in given date range.*/ modifiedDateTime?: DateRange; /** Queries for reality data where the last accessed date is in given date range.*/ lastAccessedDateTime?: DateRange; /** Queries for reality data owned by a specific user.*/ ownerId?: string; /** Queries for reality data stored in a specific data center.*/ dataCenter?: string; /** Queries for reality data with exact matching tag.*/ tag?: string; } /** Date range*/ export interface DateRange { startDateTime: Date; endDateTime: Date; } /** * Minimal interface for a cartographic range that provides a longitude/latitude bounding box. * Compatible with CartographicRange from @itwin/core-geometry. * @beta */ export interface CartographicRange { getLongitudeLatitudeBoundingBox(): { low: { x: number; y: number }; high: { x: number; y: number } }; } /** * Response object containing RealityData and continuation token */ export interface RealityDataResponse { realityDatas: ITwinRealityData[]; continuationToken?: string; } /** * Interface for accessing reality data from the Reality Management API. * @beta */ export interface RealityDataAccess { getRealityData: (accessToken: string, iTwinId: string | undefined, realityDataId: string) => Promise; getRealityDataUrl: (iTwinId: string | undefined, realityDataId: string) => Promise; } /** * Client wrapper to Reality Management API. * An instance of this class is used to extract reality data from the Reality Management API. * Most important methods enable to obtain a specific reality data, fetch all reality data associated with an iTwin and * all reality data of an iTwin within a provided spatial extent. * This class also implements extraction of the Azure blob address. * @beta */ export class RealityDataAccessClient implements RealityDataAccess { public readonly baseUrl: string = "https://api.bentley.com/reality-management/reality-data"; public readonly apiVersion: ApiVersion = ApiVersion.v1; public readonly authorizationClient: AuthorizationClient | undefined = undefined; private readonly _tokenHolder: TokenHolder; private readonly _rcService: RealityCaptureService; /** * Creates an instance of RealityDataAccessClient. */ public constructor(realityDataClientOptions?: RealityDataClientOptions) { // runtime config if (realityDataClientOptions) { if (realityDataClientOptions.version) this.apiVersion = realityDataClientOptions.version; if (realityDataClientOptions.baseUrl) this.baseUrl = this.setBaseUrl(realityDataClientOptions.baseUrl); if (realityDataClientOptions.authorizationClient) this.authorizationClient = realityDataClientOptions.authorizationClient; } this._tokenHolder = new TokenHolder(); this._rcService = new RealityCaptureService(this._tokenHolder, { env: this._getEnv() }); } /** * Ensures the reality data client points to Reality Management API, as many users hardcode the url to the deprecated Reality Data API. * @param baseUrl base url given by users of this client * @returns base url to Reality Management API */ private setBaseUrl(baseUrl: string): string { const url = new URL(baseUrl); switch (url.host) { case "dev-api.bentley.com": return "https://dev-api.bentley.com/reality-management/reality-data"; case "qa-api.bentley.com": return "https://qa-api.bentley.com/reality-management/reality-data"; case "api.bentley.com": return "https://api.bentley.com/reality-management/reality-data"; default: throw new Error("invalid host"); } } /** * Derives the environment identifier from the baseUrl so that * RealityCaptureService targets the same API environment. */ private _getEnv(): string | undefined { if (this.baseUrl.includes("dev-api")) return "dev"; if (this.baseUrl.includes("qa-api")) return "qa"; return undefined; } /** * Resolves the access token (using the authorizationClient if available) * and injects it into the internal TokenHolder so that the RealityCaptureService * can consume it transparently. */ private async _prepareToken(accessToken: string): Promise { const resolved = await this.resolveAccessToken(accessToken); this._tokenHolder.setToken(resolved); } /** * Checks a RealityCaptureService response and throws a BentleyError * when the response indicates an error. */ private _throwIfError(response: RCResponse): void { if (response.isError()) { throw new RealityDataClientError( response.status_code, response.error?.error?.message ?? "Unknown error", ); } } /** * Try to use authorizationClient in RealityDataClientOptions to get the access token * otherwise, will return the input token * This is a workaround to support different authorization client for the reality data client and iTwin-core. */ private async resolveAccessToken(accessToken: string): Promise { return this.authorizationClient ? this.authorizationClient.getAccessToken() : accessToken; } /** * This method returns the URL to obtain the Reality Data details. * Technically it should never be required as the RealityData object returned should have all the information to obtain the * data. * @param iTwinId the iTwin identifier * @param realityDataId realityData identifier * @returns string containing the URL to reality data for indicated tile. * @beta */ public async getRealityDataUrl( iTwinId: string | undefined, realityDataId: string, ): Promise { if (iTwinId) { return `${this.baseUrl}/${realityDataId}?iTwinId=${iTwinId}`; } return `${this.baseUrl}/${realityDataId}`; } /** * Gets reality data with all of its properties * @param accessToken The client request context. * @param iTwinId id of associated iTwin (or project) * @param realityDataId realityData identifier * @returns The requested reality data. * @throws [[BentleyError]] with code 401 when the request lacks valid authentication credentials * @throws [[BentleyError]] with code 404 when the specified reality data is not found * @throws [[BentleyError]] with code 422 when the request is invalid * @beta */ public async getRealityData( accessToken: string, iTwinId: string | undefined, realityDataId: string, ): Promise { await this._prepareToken(accessToken); try { const response = await this._rcService.getRealityData(realityDataId, iTwinId); this._throwIfError(response); return new ITwinRealityData(this, response.value, iTwinId); } catch (error) { return this.handleError(error); } } /** * Gets all reality data associated with the iTwin. * @param accessToken The client request context. * @param iTwinId id of associated iTwin * @param criteria Criteria by which to query. * @returns an array of RealityData that are associated to the iTwin. * @throws [[BentleyError]] with code 401 when the request lacks valid authentication credentials * @throws [[BentleyError]] with code 422 when the request is invalid * @beta */ public async getRealityDatas( accessToken: string, iTwinId: string | undefined, criteria: RealityDataQueryCriteria | undefined, ): Promise { try { const accessTokenResolved = await this.resolveAccessToken(accessToken); const url = new URL(this.baseUrl); if (iTwinId) url.searchParams.append("iTwinId", iTwinId); if (criteria) { if (criteria.continuationToken) { url.searchParams.append( "continuationToken", criteria.continuationToken, ); } if (criteria.top) { const top = criteria.top; if (top > 500) { throw new RealityDataClientError( 422, "Maximum value for top parameter is 500.", ); } url.searchParams.append("$top", top.toString()); } if (criteria.extent) { const iModelRange = criteria.extent.getLongitudeLatitudeBoundingBox(); const toDeg = (r: number) => r * (180 / Math.PI); const extent = `${toDeg(iModelRange.low.x)},${toDeg(iModelRange.low.y)},${toDeg(iModelRange.high.x)},${toDeg(iModelRange.high.y)}`; url.searchParams.append("extent", extent); } if (criteria.orderBy) { url.searchParams.append("$orderBy", criteria.orderBy); } if (criteria.search) { url.searchParams.append("$search", criteria.search); } if (criteria.types) { url.searchParams.append("types", criteria.types.join(",")); } if (criteria.acquisitionDates) { const startDateTime = this.formatIsoString( criteria.acquisitionDates.startDateTime, ); const endDateTime = this.formatIsoString( criteria.acquisitionDates.endDateTime, ); url.searchParams.append( "acquisitionDateTime", `${startDateTime}/${endDateTime}`, ); } if (criteria.createdDateTime) { const startDateTime = this.formatIsoString( criteria.createdDateTime.startDateTime, ); const endDateTime = this.formatIsoString( criteria.createdDateTime.endDateTime, ); url.searchParams.append( "createdDateTime", `${startDateTime}/${endDateTime}`, ); } if (criteria.modifiedDateTime) { const startDateTime = this.formatIsoString( criteria.modifiedDateTime.startDateTime, ); const endDateTime = this.formatIsoString( criteria.modifiedDateTime.endDateTime, ); url.searchParams.append( "modifiedDateTime", `${startDateTime}/${endDateTime}`, ); } if (criteria.lastAccessedDateTime) { const startDateTime = this.formatIsoString( criteria.lastAccessedDateTime.startDateTime, ); const endDateTime = this.formatIsoString( criteria.lastAccessedDateTime.endDateTime, ); url.searchParams.append( "lastAccessedDateTime", `${startDateTime}/${endDateTime}`, ); } if (criteria.ownerId) { url.searchParams.append("ownerId", criteria.ownerId); } if (criteria.dataCenter) { url.searchParams.append("dataCenter", criteria.dataCenter); } if (criteria.tag) { url.searchParams.append("tag", criteria.tag); } } const response = await fetch( url.href, getRequestConfig( accessTokenResolved, "GET", this.apiVersion, criteria?.getFullRepresentation === true, ), ); // fetch does not throw on 4XX/5XX; check status explicitly if (!response.ok) throw new RealityDataClientError( 422, iTwinId ? `Could not fetch reality data with iTwinId ${iTwinId}` : "Could not fetch reality data", ); const realityDatasResponseBody = await response.json(); const realityDataResponse: RealityDataResponse = { realityDatas: [], continuationToken: this.extractContinuationToken( realityDatasResponseBody._links?.next?.href, ), }; realityDatasResponseBody.realityData.forEach((realityData: any) => { realityDataResponse.realityDatas.push( new ITwinRealityData(this, realityData, iTwinId), ); }); return realityDataResponse; } catch (error) { return this.handleError(error); } } /** * trims milliseconds from date.toISOString() method to conform to for date parameters in the API. * See https://developer.bentley.com/apis/reality-management/operations/get-all-reality-data/#request-parameters * @param date date to format * @returns dateTime string in format YYYY-MM-DDTHH:mm:ssZ e.g. 2021-08-01T00:00:00Z */ private formatIsoString(date: Date): string { return `${date.toISOString().slice(0, -5)}Z`; } private extractContinuationToken( url: string | undefined, ): string | undefined { if (url) { try { // Parse as a full URL to correctly extract query parameters const searchParams = new URL(url).searchParams; const newParams = new URLSearchParams(); // Normalize to lowercase to handle case-sensitive API parameters (e.g. "ContinuationToken") for (const [name, value] of searchParams) { newParams.append(name.toLowerCase(), value); } const token = newParams.get("continuationtoken"); return token ?? undefined; } catch { return undefined; } } return undefined; } /** * Retrieves the list of Projects associated to the specified realityData. * @deprecated in 1.0.1, getRealityDataProjects is deprecated and no longer used as Projects API is deprecated. Use getRealityDatasITwins method. * @param accessToken The client request context. * @param realityDataId realityData identifier * @returns an array of Projects that are associated to the realityData. * @throws [[BentleyError]] with code 401 when the request lacks valid authentication credentials * @beta */ public async getRealityDataProjects( accessToken: string, realityDataId: string, ): Promise { try { const accessTokenResolved = await this.resolveAccessToken(accessToken); const url = `${this.baseUrl}/${realityDataId}/itwins`; // execute query const response = await fetch(url, getRequestConfig(accessTokenResolved, "GET", this.apiVersion)); if (!response.ok) throw new RealityDataClientError(response.status, "Could not fetch reality data iTwins."); const projectsResponseBody = await response.json(); const projectsResponse: Project[] = []; // make up projects details link manually const projectsBaseUrl = this.baseUrl.replace( "/reality-management/reality-data", "/projects", ); projectsResponseBody.iTwins.forEach((itwinValue: any) => { // build Project object with _links.self.href structure const href = new URL(`${projectsBaseUrl}/${itwinValue}`); const self = { href, }; const newProject = new Project({ id: itwinValue, // eslint-disable-next-line @typescript-eslint/naming-convention _links: { self, }, }); projectsResponse.push(newProject); }); return projectsResponse; } catch (error) { return this.handleError(error); } } /** * Retrieves the list of iTwins associated to the specified realityData. * @param accessToken The client request context. * @param realityDataId realityData identifier * @returns an array of iTwin identifiers that are associated to the realityData. * @throws [[BentleyError]] with code 401 when the request lacks valid authentication credentials * @beta */ public async getRealityDataITwins( accessToken: string, realityDataId: string, ): Promise { await this._prepareToken(accessToken); try { const response = await this._rcService.getRealityDataITwins(realityDataId); this._throwIfError(response); return response.value as string[]; } catch (error) { return this.handleError(error); } } /** * Creates a RealityData * @param accessToken The client request context. * @param iTwinId id of associated iTwin * @param iTwinRealityData the realityData to create * @throws [[BentleyError]] with code 401 when the request lacks valid authentication credentials * @throws [[BentleyError]] with code 403 when user does not have required permissions to create a reality data * @throws [[BentleyError]] with code 422 when the request is invalid * @beta */ public async createRealityData( accessToken: string, iTwinId: string | undefined, iTwinRealityData: ITwinRealityData, ): Promise { await this._prepareToken(accessToken); try { const realityDataToCreate: RCRealityDataCreate = { iTwinId: iTwinId ?? "", displayName: iTwinRealityData.displayName ?? "", type: (iTwinRealityData.type ?? "") as RCType, classification: iTwinRealityData.classification as any, dataset: iTwinRealityData.dataset, group: iTwinRealityData.group, description: iTwinRealityData.description, rootDocument: iTwinRealityData.rootDocument, tags: iTwinRealityData.tags, acquisition: iTwinRealityData.acquisition, authoring: iTwinRealityData.authoring, extent: iTwinRealityData.extent, crs: iTwinRealityData.crs, attribution: iTwinRealityData.attribution, termsOfUse: iTwinRealityData.termsOfUse, }; const response = await this._rcService.createRealityData(realityDataToCreate); this._throwIfError(response); return new ITwinRealityData(this, response.value, iTwinId); } catch (error) { return this.handleError(error); } } /** * Modifies an existing RealityData * @param accessToken The client request context. * @param iTwinId id of associated iTwin * @param iTwinRealityData the realityData to modify * @throws [[BentleyError]] with code 401 when the request lacks valid authentication credentials * @throws [[BentleyError]] with code 404 when the specified reality data was not found * @throws [[BentleyError]] with code 422 when the request is invalid * @beta */ public async modifyRealityData( accessToken: string, iTwinId: string | undefined, iTwinRealityData: ITwinRealityData, ): Promise { await this._prepareToken(accessToken); try { const realityDataToModify: RCRealityDataUpdate = { displayName: iTwinRealityData.displayName, classification: iTwinRealityData.classification as any, type: iTwinRealityData.type as RCType | undefined, iTwinId, dataset: iTwinRealityData.dataset, group: iTwinRealityData.group, description: iTwinRealityData.description, rootDocument: iTwinRealityData.rootDocument, tags: iTwinRealityData.tags, acquisition: iTwinRealityData.acquisition, authoring: iTwinRealityData.authoring, extent: iTwinRealityData.extent, crs: iTwinRealityData.crs, attribution: iTwinRealityData.attribution, termsOfUse: iTwinRealityData.termsOfUse, }; const response = await this._rcService.updateRealityData(realityDataToModify, iTwinRealityData.id); this._throwIfError(response); return new ITwinRealityData(this, response.value, iTwinId); } catch (error) { return this.handleError(error); } } /** * Deletes a RealityData * @param accessToken The client request context. * @param realityDataId the realityData to delete * @returns true if successful (204 response), false if not * @throws [[BentleyError]] with code 401 when the request lacks valid authentication credentials * @throws [[BentleyError]] with code 404 when the specified reality data was not found * @throws [[BentleyError]] with code 422 when the request is invalid * @beta */ public async deleteRealityData( accessToken: string, realityDataId: string, ): Promise { await this._prepareToken(accessToken); try { const response = await this._rcService.deleteRealityData(realityDataId); this._throwIfError(response); return true; } catch (error) { return this.handleError(error); } } /** * Associates a RealityData to an iTwin * @param accessToken The client request context. * @param iTwinId id of iTwin to associate the realityData to. * @param realityDataId id of the RealityData. * @returns true if successful (200 response) or false if not * @throws [[BentleyError]] with code 401 when the request lacks valid authentication credentials * @throws [[BentleyError]] with code 404 when the specified reality data or iTwin was not found * @throws [[BentleyError]] with code 422 when the request is invalid * @beta */ public async associateRealityData( accessToken: string, iTwinId: string, realityDataId: string, ): Promise { await this._prepareToken(accessToken); try { const response = await this._rcService.associateRealityData(iTwinId, realityDataId); this._throwIfError(response); return true; } catch (error) { return this.handleError(error); } } /** * Dissociates a RealityData from an iTwin * @param accessToken The client request context. * @param iTwinId id of iTwin to dissociate the realityData from. * @param realityDataId id of the RealityData. * @returns true if successful (204 response) or false if not * @throws [[BentleyError]] with code 401 when the request lacks valid authentication credentials * @throws [[BentleyError]] with code 404 when the association between the reality data and iTwin was not found * @throws [[BentleyError]] with code 422 when the request is invalid * @beta */ public async dissociateRealityData( accessToken: string, iTwinId: string, realityDataId: string, ): Promise { await this._prepareToken(accessToken); try { const response = await this._rcService.dissociateRealityData(iTwinId, realityDataId); this._throwIfError(response); return true; } catch (error) { return this.handleError(error); } } /** * Moves a RealityData to a different iTwin * @param accessToken The client request context. * @param realityDataId The id of the RealityData to move. * @param iTwinId The id of the iTwin to move the RealityData to. * @returns true if successful (204 response) or false if not * @throws [[BentleyError]] with code 401 when the request lacks valid authentication credentials * @throws [[BentleyError]] with code 404 when the specified reality data or iTwin was not found * @throws [[BentleyError]] with code 422 when the request is invalid * @throws [[BentleyError]] with code 409 when the reality data is already associated with the specified iTwin * @beta */ public async moveRealityData( accessToken: string, realityDataId: string, iTwinId: string, ): Promise { await this._prepareToken(accessToken); try { const response = await this._rcService.moveRealityData(realityDataId, iTwinId); this._throwIfError(response); return true; } catch (error) { return this.handleError(error); } } /** * Handle errors thrown. * @beta */ private handleError(error: any): any { // Default error let status = 422; let message = "Unknown error. Please ensure that the request is valid."; const clientError = error as RealityDataClientError; if (clientError?.errorNumber !== undefined) { status = clientError.errorNumber; message = clientError.message; } return Promise.reject(new RealityDataClientError(status, message)); } }