diff --git a/packages/engine/Source/Core/IonResource.js b/packages/engine/Source/Core/IonResource.js index cf10e967c75..b134df15955 100644 --- a/packages/engine/Source/Core/IonResource.js +++ b/packages/engine/Source/Core/IonResource.js @@ -7,6 +7,14 @@ import Ion from "./Ion.js"; import Resource from "./Resource.js"; import RuntimeError from "./RuntimeError.js"; +/** + * A function that will be invoked when the access token is refreshed. + * @callback IonResourceRefreshCallback + * @param {IonResource} ionResource The root IonResource being refreshed. + * @param {object} endpoint The result of the Cesium ion asset endpoint service. This may be modified in place by the callback. + * @private + */ + /** * A {@link Resource} instance that encapsulates Cesium ion asset access. * This object is normally not instantiated directly, use {@link IonResource.fromAssetId}. @@ -16,7 +24,7 @@ import RuntimeError from "./RuntimeError.js"; * @augments Resource * * @param {object} endpoint The result of the Cesium ion asset endpoint service. - * @param {Resource} endpointResource The resource used to retrieve the endpoint. + * @param {Resource} endpointResource The original resource used to retrieve the endpoint. * * @see Ion * @see IonImageryProvider @@ -39,12 +47,6 @@ function IonResource(endpoint, endpointResource) { retryAttempts: 1, retryCallback: retryCallback, }; - } else if (["GOOGLE_2D_MAPS", "AZURE_MAPS"].includes(externalType)) { - options = { - url: endpoint.options.url, - retryAttempts: 1, - retryCallback: retryCallback, - }; } else if ( externalType === "3DTILES" || externalType === "STK_TERRAIN_SERVER" @@ -76,6 +78,13 @@ function IonResource(endpoint, endpointResource) { this._pendingPromise = undefined; this._credits = undefined; this._isExternal = isExternal; + + /** + * A function that, if defined, will be invoked when the access token is refreshed. + * @private + * @type {IonResourceRefreshCallback|undefined} + */ + this.refreshCallback = undefined; } if (defined(Object.create)) { @@ -205,7 +214,7 @@ IonResource.prototype._makeRequest = function (options) { return Resource.prototype._makeRequest.call(this, options); } - addClientHeaders(options); + options.headers = addClientHeaders(options.headers); options.headers.Authorization = `Bearer ${this._ionEndpoint.accessToken}`; return Resource.prototype._makeRequest.call(this, options); @@ -239,20 +248,26 @@ IonResource._createEndpointResource = function (assetId, options) { }; } - addClientHeaders(resourceOptions); + resourceOptions.headers = addClientHeaders(resourceOptions.headers); return server.getDerivedResource(resourceOptions); }; -function addClientHeaders(options) { - if (!defined(options.headers)) { - options.headers = {}; - } - options.headers["X-Cesium-Client"] = "CesiumJS"; +/** + * Adds CesiumJS client headers to the provided headers object. + * @private + * @param {object} [headers={}] The headers to modify. + * @returns {object} The modified headers. + */ +function addClientHeaders(headers = {}) { + headers["X-Cesium-Client"] = "CesiumJS"; + /* global CESIUM_VERSION */ if (typeof CESIUM_VERSION !== "undefined") { - options.headers["X-Cesium-Client-Version"] = CESIUM_VERSION; + headers["X-Cesium-Client-Version"] = CESIUM_VERSION; } + + return headers; } function retryCallback(that, error) { @@ -280,20 +295,13 @@ function retryCallback(that, error) { ionRoot._pendingPromise = endpointResource .fetchJson() .then(function (newEndpoint) { + const refreshCallback = that.refreshCallback ?? ionRoot.refreshCallback; + if (defined(refreshCallback)) { + refreshCallback(ionRoot, newEndpoint); + } + // Set the token for root resource so new derived resources automatically pick it up ionRoot._ionEndpoint = newEndpoint; - // Reset the session token for Google 2D imagery - if (newEndpoint.externalType === "GOOGLE_2D_MAPS") { - ionRoot.setQueryParameters({ - session: newEndpoint.options.session, - key: newEndpoint.options.key, - }); - } - if (newEndpoint.externalType === "AZURE_MAPS") { - ionRoot.setQueryParameters({ - "subscription-key": newEndpoint.options["subscription-key"], - }); - } return ionRoot._ionEndpoint; }) .finally(function (newEndpoint) { diff --git a/packages/engine/Source/Core/Resource.js b/packages/engine/Source/Core/Resource.js index 7e4416e89ac..74063f180c2 100644 --- a/packages/engine/Source/Core/Resource.js +++ b/packages/engine/Source/Core/Resource.js @@ -917,7 +917,7 @@ Resource.prototype.fetchImage = function (options) { this.isBlobUri || (!this.hasHeaders && !preferBlob) ) { - return fetchImage({ + return this._fetchImage({ resource: this, flipY: flipY, skipColorSpaceConversion: skipColorSpaceConversion, @@ -957,8 +957,7 @@ Resource.prototype.fetchImage = function (options) { url: blobUrl, }); - return fetchImage({ - resource: generatedBlobResource, + return generatedBlobResource._fetchImage({ flipY: flipY, skipColorSpaceConversion: skipColorSpaceConversion, preferImageBitmap: false, @@ -997,16 +996,15 @@ Resource.prototype.fetchImage = function (options) { /** * Fetches an image and returns a promise to it. - * * @param {object} [options] An object with the following properties. - * @param {Resource} [options.resource] Resource object that points to an image to fetch. * @param {boolean} [options.preferImageBitmap] If true, image will be decoded during fetch and an ImageBitmap is returned. * @param {boolean} [options.flipY] If true, image will be vertically flipped during decode. Only applies if the browser supports createImageBitmap. * @param {boolean} [options.skipColorSpaceConversion=false] If true, any custom gamma or color profiles in the image will be ignored. Only applies if the browser supports createImageBitmap. + * @returns {Promise|undefined} a promise that will resolve to the requested data when loaded. Returns undefined if the request has been throttled and cannot be made at this time. * @private */ -function fetchImage(options) { - const resource = options.resource; +Resource.prototype._fetchImage = function (options) { + const resource = this; const flipY = options.flipY; const skipColorSpaceConversion = options.skipColorSpaceConversion; const preferImageBitmap = options.preferImageBitmap; @@ -1050,8 +1048,7 @@ function fetchImage(options) { request.state = RequestState.UNISSUED; request.deferred = undefined; - return fetchImage({ - resource: resource, + return resource._fetchImage({ flipY: flipY, skipColorSpaceConversion: skipColorSpaceConversion, preferImageBitmap: preferImageBitmap, @@ -1060,7 +1057,7 @@ function fetchImage(options) { return Promise.reject(e); }); }); -} +}; /** * Creates a Resource and calls fetchImage() on it. @@ -1962,6 +1959,7 @@ Resource._Implementations.createImage = function ( flipY, skipColorSpaceConversion, preferImageBitmap, + headers, ) { const url = request.url; // Passing an Image to createImageBitmap will force it to run on the main thread @@ -1985,7 +1983,7 @@ Resource._Implementations.createImage = function ( responseType, method, undefined, - undefined, + headers, xhrDeferred, undefined, undefined, diff --git a/packages/engine/Source/Scene/Azure2DImageryProvider.js b/packages/engine/Source/Scene/Azure2DImageryProvider.js index 25d43e1cbbe..24a9d3f453a 100644 --- a/packages/engine/Source/Scene/Azure2DImageryProvider.js +++ b/packages/engine/Source/Scene/Azure2DImageryProvider.js @@ -14,7 +14,7 @@ const trailingSlashRegex = /\/$/; * * @property {object} options Object with the following properties: * @property {string} [options.url="https://atlas.microsoft.com/"] The Azure server url. - * @property {string} [options.tilesetId="microsoft.imagery"] The Azure tileset ID. Valid options are {@link microsoft.imagery}, {@link microsoft.base.road}, and {@link microsoft.base.labels.road} + * @property {string} options.tilesetId="microsoft.imagery" The Azure tileset ID. Valid options are {@link microsoft.imagery}, {@link microsoft.base.road}, and {@link microsoft.base.labels.road} * @property {string} options.subscriptionKey The public subscription key for the imagery. * @property {Ellipsoid} [options.ellipsoid=Ellipsoid.default] The ellipsoid. If not specified, the default ellipsoid is used. * @property {number} [options.minimumLevel=0] The minimum level-of-detail supported by the imagery provider. Take care when specifying @@ -41,13 +41,13 @@ const trailingSlashRegex = /\/$/; */ function Azure2DImageryProvider(options) { options = options ?? {}; - options.maximumLevel = options.maximumLevel ?? 22; - options.minimumLevel = options.minimumLevel ?? 0; + const maximumLevel = options.maximumLevel ?? 22; + const minimumLevel = options.minimumLevel ?? 0; + const tilesetId = options.tilesetId ?? "microsoft.imagery"; const subscriptionKey = options.subscriptionKey ?? options["subscription-key"]; //>>includeStart('debug', pragmas.debug); - Check.defined("options.tilesetId", options.tilesetId); Check.defined("options.subscriptionKey", subscriptionKey); //>>includeEnd('debug'); @@ -66,7 +66,7 @@ function Azure2DImageryProvider(options) { resource.setQueryParameters({ "api-version": "2024-04-01", - tilesetId: options.tilesetId, + tilesetId: tilesetId, zoom: `{z}`, x: `{x}`, y: `{y}`, @@ -83,6 +83,8 @@ function Azure2DImageryProvider(options) { const provider = new UrlTemplateImageryProvider({ ...options, + maximumLevel, + minimumLevel, url: resource, credit: credit, }); diff --git a/packages/engine/Source/Scene/Google2DImageryProvider.js b/packages/engine/Source/Scene/Google2DImageryProvider.js index cba241de418..27a812311c3 100644 --- a/packages/engine/Source/Scene/Google2DImageryProvider.js +++ b/packages/engine/Source/Scene/Google2DImageryProvider.js @@ -5,8 +5,9 @@ import DeveloperError from "../Core/DeveloperError.js"; import Resource from "../Core/Resource.js"; import IonResource from "../Core/IonResource.js"; import Check from "../Core/Check.js"; -import UrlTemplateImageryProvider from "./UrlTemplateImageryProvider.js"; import GoogleMaps from "../Core/GoogleMaps.js"; +import { GOOGLE_2D_MAPS as createFromIonEndpoint } from "./IonImageryProviderFactory.js"; +import UrlTemplateImageryProvider from "./UrlTemplateImageryProvider.js"; const trailingSlashRegex = /\/$/; @@ -124,8 +125,6 @@ function Google2DImageryProvider(options) { // This will be defined for ion resources this._tileCredits = resource.credits; this._attributionsByLevel = undefined; - // Asynchronously request and populate _attributionsByLevel - this.getViewportCredits(); } Object.defineProperties(Google2DImageryProvider.prototype, { @@ -353,10 +352,6 @@ Google2DImageryProvider.fromIonAssetId = async function (options) { }, ); - const endpoint = await endpointResource.fetchJson(); - const endpointOptions = { ...endpoint.options }; - delete endpointOptions.url; - const providerOptions = { language: options.language, region: options.region, @@ -367,11 +362,15 @@ Google2DImageryProvider.fromIonAssetId = async function (options) { credit: options.credit, }; - return new Google2DImageryProvider({ - ...endpointOptions, + const endpoint = await endpointResource.fetchJson(); + const url = endpoint.options.url; + delete endpoint.options.url; + endpoint.options = { + ...endpoint.options, ...providerOptions, - url: new IonResource(endpoint, endpointResource), - }); + }; + + return createFromIonEndpoint(url, endpoint, endpointResource); }; /** @@ -485,7 +484,21 @@ Google2DImageryProvider.prototype.requestImage = function ( level, request, ) { - return this._imageryProvider.requestImage(x, y, level, request); + const promise = this._imageryProvider.requestImage(x, y, level, request); + + // If the requestImage call returns undefined, it couldn't be scheduled this frame. Make sure to return undefined so this can be handled upstream. + if (!defined(promise)) { + return undefined; + } + + // Asynchronously request and populate _attributionsByLevel if it hasn't been already. We do this here so that the promise can be properly awaited. + if (promise && !defined(this._attributionsByLevel)) { + return Promise.all([promise, this.getViewportCredits()]).then( + (results) => results[0], + ); + } + + return promise; }; /** diff --git a/packages/engine/Source/Scene/IonImageryProvider.js b/packages/engine/Source/Scene/IonImageryProvider.js index ba30b7962a8..6c4e95f2937 100644 --- a/packages/engine/Source/Scene/IonImageryProvider.js +++ b/packages/engine/Source/Scene/IonImageryProvider.js @@ -1,72 +1,11 @@ import Check from "../Core/Check.js"; +import clone from "../Core/clone.js"; import Frozen from "../Core/Frozen.js"; import defined from "../Core/defined.js"; import Event from "../Core/Event.js"; import IonResource from "../Core/IonResource.js"; import RuntimeError from "../Core/RuntimeError.js"; -import ArcGisMapServerImageryProvider from "./ArcGisMapServerImageryProvider.js"; -import BingMapsImageryProvider from "./BingMapsImageryProvider.js"; -import TileMapServiceImageryProvider from "./TileMapServiceImageryProvider.js"; -import GoogleEarthEnterpriseMapsProvider from "./GoogleEarthEnterpriseMapsProvider.js"; -import MapboxImageryProvider from "./MapboxImageryProvider.js"; -import SingleTileImageryProvider from "./SingleTileImageryProvider.js"; -import UrlTemplateImageryProvider from "./UrlTemplateImageryProvider.js"; -import WebMapServiceImageryProvider from "./WebMapServiceImageryProvider.js"; -import WebMapTileServiceImageryProvider from "./WebMapTileServiceImageryProvider.js"; -import Google2DImageryProvider from "./Google2DImageryProvider.js"; -import Azure2DImageryProvider from "./Azure2DImageryProvider.js"; - -// These values are the list of supported external imagery -// assets in the Cesium ion beta. They are subject to change. -const ImageryProviderAsyncMapping = { - ARCGIS_MAPSERVER: ArcGisMapServerImageryProvider.fromUrl, - BING: async (url, options) => { - return BingMapsImageryProvider.fromUrl(url, options); - }, - GOOGLE_EARTH: async (url, options) => { - const channel = options.channel; - delete options.channel; - return GoogleEarthEnterpriseMapsProvider.fromUrl(url, channel, options); - }, - MAPBOX: (url, options) => { - return new MapboxImageryProvider({ - url: url, - ...options, - }); - }, - SINGLE_TILE: SingleTileImageryProvider.fromUrl, - TMS: TileMapServiceImageryProvider.fromUrl, - URL_TEMPLATE: (url, options) => { - return new UrlTemplateImageryProvider({ - url: url, - ...options, - }); - }, - WMS: (url, options) => { - return new WebMapServiceImageryProvider({ - url: url, - ...options, - }); - }, - WMTS: (url, options) => { - return new WebMapTileServiceImageryProvider({ - url: url, - ...options, - }); - }, - GOOGLE_2D_MAPS: (ionResource, options) => { - return new Google2DImageryProvider({ - ...options, - url: ionResource, - }); - }, - AZURE_MAPS: (ionResource, options) => { - return new Azure2DImageryProvider({ - ...options, - url: ionResource, - }); - }, -}; +import IonImageryProviderFactory from "./IonImageryProviderFactory.js"; /** * @typedef {object} IonImageryProvider.ConstructorOptions @@ -297,41 +236,33 @@ IonImageryProvider.fromAssetId = async function (assetId, options) { IonImageryProvider._endpointCache[cacheKey] = promise; } - const endpoint = await promise; + let endpoint = await promise; if (endpoint.type !== "IMAGERY") { throw new RuntimeError( `Cesium ion asset ${assetId} is not an imagery asset.`, ); } - let imageryProvider; const externalType = endpoint.externalType; - if (!defined(externalType)) { - imageryProvider = await TileMapServiceImageryProvider.fromUrl( - new IonResource(endpoint, endpointResource), - ); - } else { - const factory = ImageryProviderAsyncMapping[externalType]; + let factory = IonImageryProviderFactory.defaultFactoryCallback; + + // Make a copy before editing since this object reference is cached; + endpoint = clone(endpoint, true); + endpoint.options = endpoint.options ?? {}; + const url = endpoint.options?.url; + delete options.url; + + if (defined(externalType)) { + factory = IonImageryProviderFactory[externalType]; if (!defined(factory)) { throw new RuntimeError( `Unrecognized Cesium ion imagery type: ${externalType}`, ); } - // Make a copy before editing since this object reference is cached; - const options = { ...endpoint.options }; - const url = options.url; - delete options.url; - if (["GOOGLE_2D_MAPS", "AZURE_MAPS"].includes(endpoint.externalType)) { - imageryProvider = await factory( - new IonResource(endpoint, endpointResource), - options, - ); - } else { - imageryProvider = await factory(url, options); - } } + const imageryProvider = await factory(url, endpoint, endpointResource); const provider = new IonImageryProvider(options); imageryProvider.errorEvent.addEventListener(function (tileProviderError) { diff --git a/packages/engine/Source/Scene/IonImageryProviderFactory.js b/packages/engine/Source/Scene/IonImageryProviderFactory.js new file mode 100644 index 00000000000..88f91a5df7b --- /dev/null +++ b/packages/engine/Source/Scene/IonImageryProviderFactory.js @@ -0,0 +1,194 @@ +import IonResource from "../Core/IonResource.js"; +import ArcGisMapServerImageryProvider from "./ArcGisMapServerImageryProvider.js"; +import BingMapsImageryProvider from "./BingMapsImageryProvider.js"; +import TileMapServiceImageryProvider from "./TileMapServiceImageryProvider.js"; +import GoogleEarthEnterpriseMapsProvider from "./GoogleEarthEnterpriseMapsProvider.js"; +import MapboxImageryProvider from "./MapboxImageryProvider.js"; +import SingleTileImageryProvider from "./SingleTileImageryProvider.js"; +import UrlTemplateImageryProvider from "./UrlTemplateImageryProvider.js"; +import WebMapServiceImageryProvider from "./WebMapServiceImageryProvider.js"; +import WebMapTileServiceImageryProvider from "./WebMapTileServiceImageryProvider.js"; +import Google2DImageryProvider from "./Google2DImageryProvider.js"; +import Azure2DImageryProvider from "./Azure2DImageryProvider.js"; + +/** + * An asynchronous callback function that creates an ImageryProvider from + * a Cesium ion asset endpoint. + * @template {ImageryProvider} T + * @callback IonImageryProviderFactoryCallback + * @param {string} url The URL of the imagery service. + * @param {object} endpoint The result of the Cesium ion asset endpoint service. + * @param {Resource} endpointResource The original resource used to retrieve the endpoint. + * @returns {Promise} A promise that resolves to an instance of an ImageryProvider. + * @private + */ + +/** + * @private + * @type {IonImageryProviderFactoryCallback} + */ +export const defaultFactoryCallback = async (url, endpoint, endpointResource) => + TileMapServiceImageryProvider.fromUrl( + new IonResource(endpoint, endpointResource), + ); + +/** + * @private + * @type {IonImageryProviderFactoryCallback} + */ +export const ARCGIS_MAPSERVER = async (url, { options }) => + ArcGisMapServerImageryProvider.fromUrl(url, options); + +/** + * @private + * @type {IonImageryProviderFactoryCallback} + */ +export const BING = async (url, { options }) => { + return BingMapsImageryProvider.fromUrl(url, options); +}; + +/** + * @private + * @type {IonImageryProviderFactoryCallback} + */ +export const GOOGLE_EARTH = async (url, { options }) => { + const channel = options.channel; + delete options.channel; + return GoogleEarthEnterpriseMapsProvider.fromUrl(url, channel, options); +}; + +/** + * @private + * @type {IonImageryProviderFactoryCallback} + */ +export const MAPBOX = async (url, { options }) => { + return new MapboxImageryProvider({ + url: url, + ...options, + }); +}; + +/** + * @private + * @type {IonImageryProviderFactoryCallback} + */ +export const SINGLE_TILE = async (url, { options }) => + SingleTileImageryProvider.fromUrl(url, options); + +/** + * @private + * @type {IonImageryProviderFactoryCallback} + */ +export const TMS = async (url, { options }) => + TileMapServiceImageryProvider.fromUrl(url, options); + +/** + * @private + * @type {IonImageryProviderFactoryCallback} + */ +export const URL_TEMPLATE = async (url, { options }) => { + return new UrlTemplateImageryProvider({ + url: url, + ...options, + }); +}; + +/** + * @private + * @type {IonImageryProviderFactoryCallback} + */ +export const WMS = async (url, { options }) => { + return new WebMapServiceImageryProvider({ + url: url, + ...options, + }); +}; + +/** + * @private + * @type {IonImageryProviderFactoryCallback} + */ +export const WMTS = async (url, { options }) => { + return new WebMapTileServiceImageryProvider({ + url: url, + ...options, + }); +}; + +/** + * @private + * @type {IonImageryProviderFactoryCallback} + */ +export const GOOGLE_2D_MAPS = async (url, endpoint, endpointResource) => { + delete endpoint.externalType; + endpoint.url = url; + + const ionResource = new IonResource(endpoint, endpointResource); + + const callback = (ionRoot, endpoint) => { + delete endpoint.externalType; + endpoint.url = url; + + const { options } = endpoint; + ionRoot.setQueryParameters({ + session: options.session, + key: options.key, + }); + }; + + ionResource.refreshCallback = callback; + return new Google2DImageryProvider({ + ...endpoint.options, + url: ionResource, + }); +}; + +/** + * @private + * @type {IonImageryProviderFactoryCallback} + */ +export const AZURE_MAPS = async (url, endpoint, endpointResource) => { + delete endpoint.externalType; + endpoint.url = url; + + const ionResource = new IonResource(endpoint, endpointResource); + + const callback = (ionRoot, endpoint) => { + delete endpoint.externalType; + endpoint.url = url; + + const { options } = endpoint; + ionRoot.setQueryParameters({ + "subscription-key": options["subscription-key"], + }); + }; + + ionResource.refreshCallback = callback; + return new Azure2DImageryProvider({ + ...endpoint.options, + url: ionResource, + }); +}; + +/** + * Mapping of supported external imagery asset types returned from Cesium ion to their + * corresponding ImageryProvider constructors. + * @private + * @type {object} + */ +const IonImageryProviderFactory = { + ARCGIS_MAPSERVER, + BING, + GOOGLE_EARTH, + MAPBOX, + SINGLE_TILE, + TMS, + URL_TEMPLATE, + WMS, + WMTS, + GOOGLE_2D_MAPS, + AZURE_MAPS, + defaultFactoryCallback, +}; + +export default Object.freeze(IonImageryProviderFactory); diff --git a/packages/engine/Specs/Core/IonResourceSpec.js b/packages/engine/Specs/Core/IonResourceSpec.js index fe073840afc..3a2cbfcd10d 100644 --- a/packages/engine/Specs/Core/IonResourceSpec.js +++ b/packages/engine/Specs/Core/IonResourceSpec.js @@ -1,5 +1,4 @@ import { - defer, Ion, IonResource, RequestErrorEvent, @@ -87,50 +86,55 @@ describe("Core/IonResource", function () { }); function testNonImageryExternalResource(externalEndpoint) { - const resourceEndpoint = IonResource._createEndpointResource(123890213); - spyOn(IonResource, "_createEndpointResource").and.returnValue( - resourceEndpoint, - ); - spyOn(resourceEndpoint, "fetchJson").and.returnValue( - Promise.resolve(externalEndpoint), - ); - - return IonResource.fromAssetId(123890213).then(function (resource) { + it(`fromAssetId returns basic Resource for external type "${externalEndpoint.externalType}"`, async function () { + const resourceEndpoint = IonResource._createEndpointResource(123890213); + spyOn(IonResource, "_createEndpointResource").and.returnValue( + resourceEndpoint, + ); + spyOn(resourceEndpoint, "fetchJson").and.returnValue( + Promise.resolve(externalEndpoint), + ); + + const resource = await IonResource.fromAssetId(123890213); expect(resource.url).toEqual(externalEndpoint.options.url); expect(resource.headers.Authorization).toBeUndefined(); expect(resource.retryCallback).toBeUndefined(); }); } - it("fromAssetId returns basic Resource for external 3D tilesets", function () { - return testNonImageryExternalResource({ - type: "3DTILES", - externalType: "3DTILES", - options: { url: "http://test.invalid/tileset.json" }, - attributions: [], - }); + testNonImageryExternalResource({ + type: "3DTILES", + externalType: "3DTILES", + options: { url: "http://test.invalid/tileset.json" }, + attributions: [], }); - it("fromAssetId returns basic Resource for external 3D tilesets", function () { - return testNonImageryExternalResource({ - type: "TERRAIN", - externalType: "STK_TERRAIN_SERVER", - options: { url: "http://test.invalid/world" }, - attributions: [], - }); + testNonImageryExternalResource({ + type: "TERRAIN", + externalType: "STK_TERRAIN_SERVER", + options: { url: "http://test.invalid/world" }, + attributions: [], }); - it("fromAssetId rejects for external imagery", function () { - return testNonImageryExternalResource({ + it("fromAssetId rejects for external imagery", async function () { + const externalEndpoint = { type: "IMAGERY", externalType: "URL_TEMPLATE", url: "http://test.invalid/world", attributions: [], - }) - .then(fail) - .catch(function (e) { - expect(e).toBeInstanceOf(RuntimeError); - }); + }; + + const resourceEndpoint = IonResource._createEndpointResource(123890213); + spyOn(IonResource, "_createEndpointResource").and.returnValue( + resourceEndpoint, + ); + spyOn(resourceEndpoint, "fetchJson").and.returnValue( + Promise.resolve(externalEndpoint), + ); + + await expectAsync(IonResource.fromAssetId(123890213)).toBeRejectedWithError( + RuntimeError, + ); }); it("createEndpointResource creates expected values with default parameters", function () { @@ -278,7 +282,7 @@ describe("Core/IonResource", function () { describe("retryCallback", function () { let endpointResource; - let resource; + let originalResource; let retryCallback; beforeEach(function () { @@ -286,76 +290,111 @@ describe("Core/IonResource", function () { url: "https://api.test.invalid", access_token: "not_the_token", }); - resource = new IonResource(endpoint, endpointResource); - retryCallback = resource.retryCallback; + originalResource = new IonResource(endpoint, endpointResource); + retryCallback = originalResource.retryCallback; }); it("returns false when error is undefined", function () { - return retryCallback(resource, undefined).then(function (result) { + return retryCallback(originalResource, undefined).then(function (result) { expect(result).toBe(false); }); }); it("returns false when error is non-401", function () { const error = new RequestErrorEvent(404); - return retryCallback(resource, error).then(function (result) { + return retryCallback(originalResource, error).then(function (result) { expect(result).toBe(false); }); }); it("returns false when error is event with non-Image target", function () { const event = { target: {} }; - return retryCallback(resource, event).then(function (result) { + return retryCallback(originalResource, event).then(function (result) { expect(result).toBe(false); }); }); - function testCallback(resource, event) { - const deferred = defer(); - spyOn(endpointResource, "fetchJson").and.returnValue(deferred.promise); + function testCallback(eventName, resourceCallback, eventCallback) { + it(`works with ${eventName}`, async function () { + const resource = resourceCallback(); + const newEndpoint = { + type: "3DTILES", + url: `https://assets.cesium.com/${assetId}`, + accessToken: "not_not_really_a_refresh_token", + }; + + spyOn(endpointResource, "fetchJson").and.returnValue( + Promise.resolve(newEndpoint), + ); + + const promise = retryCallback(resource, eventCallback()); - const newEndpoint = { - type: "3DTILES", - url: `https://assets.cesium.com/${assetId}`, - accessToken: "not_not_really_a_refresh_token", - }; + // A concurrent second retry should re-use the same pending promise + const promise2 = retryCallback(resource, eventCallback()); + expect(promise._pendingPromise).toBe(promise2._pendingPromise); - const promise = retryCallback(resource, event); - const resultPromise = promise.then(function (result) { + const result = await promise; expect(result).toBe(true); expect(resource._ionEndpoint).toBe(newEndpoint); + + // Updates root endpoint + expect(originalResource._ionEndpoint).toBe(resource._ionEndpoint); + expect(originalResource.headers.Authorization).toEqual( + resource.headers.Authorization, + ); + + expect(endpointResource.fetchJson).toHaveBeenCalled(); + await expectAsync(promise2).not.toBePending(); }); - expect(endpointResource.fetchJson).toHaveBeenCalledWith(); + it(`works with refresh callback and ${eventName}`, async function () { + const resource = resourceCallback(); + const newEndpoint = { + type: "3DTILES", + url: `https://assets.cesium.com/${assetId}`, + accessToken: "not_not_really_a_refresh_token", + }; + + const refreshCallbackSpy = jasmine.createSpy("refreshCallback"); + resource.refreshCallback = (ionRoot, endpoint) => { + refreshCallbackSpy(); + expect(ionRoot).toBe(originalResource); + expect(endpoint).toEqual(newEndpoint); + }; + + spyOn(endpointResource, "fetchJson").and.returnValue( + Promise.resolve(newEndpoint), + ); - //A second retry should re-use the same pending promise - const promise2 = retryCallback(resource, event); - expect(promise._pendingPromise).toBe(promise2._pendingPromise); + const promise = retryCallback(resource, eventCallback()); - deferred.resolve(newEndpoint); + const result = await promise; + expect(result).toBe(true); + expect(resource._ionEndpoint).toBe(newEndpoint); - return resultPromise; + expect(endpointResource.fetchJson).toHaveBeenCalled(); + expect(refreshCallbackSpy).toHaveBeenCalled(); + }); } - it("works when error is a 401", function () { - const error = new RequestErrorEvent(401); - return testCallback(resource, error); - }); + testCallback( + "401 response", + () => originalResource, + () => new RequestErrorEvent(401), + ); - it("works when error is event with Image target", function () { - const event = { target: new Image() }; - return testCallback(resource, event); - }); + testCallback( + "Image target event", + () => originalResource, + () => ({ + target: new Image(), + }), + ); - it("works with derived resource and sets root access_token", function () { - const derived = resource.getDerivedResource("1"); - const error = new RequestErrorEvent(401); - return testCallback(derived, error).then(function () { - expect(derived._ionEndpoint).toBe(resource._ionEndpoint); - expect(derived.headers.Authorization).toEqual( - resource.headers.Authorization, - ); - }); - }); + testCallback( + "derrived resource", + () => originalResource.getDerivedResource("1"), + () => new RequestErrorEvent(401), + ); }); }); diff --git a/packages/engine/Specs/Scene/Azure2DImageryProviderSpec.js b/packages/engine/Specs/Scene/Azure2DImageryProviderSpec.js index 395cb7b801b..bef5c5d85dc 100644 --- a/packages/engine/Specs/Scene/Azure2DImageryProviderSpec.js +++ b/packages/engine/Specs/Scene/Azure2DImageryProviderSpec.js @@ -34,16 +34,6 @@ describe("Scene/Azure2DImageryProvider", function () { ); }); - it("requires tilesetId to be specified", function () { - expect(function () { - return new Azure2DImageryProvider({ - subscriptionKey: "a-subscription-key", - }); - }).toThrowDeveloperError( - "options.tilesetId is required, actual value was undefined", - ); - }); - it("requestImage returns a promise for an image and loads it for cross-origin use", function () { const provider = new Azure2DImageryProvider({ subscriptionKey: "test-subscriptionKey", diff --git a/packages/engine/Specs/Scene/IonImageryProviderSpec.js b/packages/engine/Specs/Scene/IonImageryProviderSpec.js index 7c117ea36f4..00345f1018e 100644 --- a/packages/engine/Specs/Scene/IonImageryProviderSpec.js +++ b/packages/engine/Specs/Scene/IonImageryProviderSpec.js @@ -14,43 +14,48 @@ import { UrlTemplateImageryProvider, WebMapServiceImageryProvider, WebMapTileServiceImageryProvider, + Google2DImageryProvider, + Azure2DImageryProvider, } from "../../index.js"; describe("Scene/IonImageryProvider", function () { - async function createTestProviderAsync(endpointData) { - endpointData = endpointData ?? { - type: "IMAGERY", - url: "http://test.invalid/layer", - accessToken: "not_really_a_refresh_token", - attributions: [], - }; + let defaultAssetId; + let defaultEndpoint; + let defaultConstructorOptions; - const assetId = 12335; - const options = {}; + function setUpTestEndpoint({ + assetId = defaultAssetId, + endpoint = defaultEndpoint, + constructorOptions = defaultConstructorOptions, + } = {}) { const endpointResource = IonResource._createEndpointResource( assetId, - options, + constructorOptions, ); + spyOn(IonResource, "_createEndpointResource").and.returnValue( endpointResource, ); spyOn(endpointResource, "fetchJson").and.returnValue( - Promise.resolve(endpointData), + Promise.resolve(endpoint), ); - const provider = await IonImageryProvider.fromAssetId(assetId, options); - - expect(IonResource._createEndpointResource).toHaveBeenCalledWith( - assetId, - options, - ); - return provider; + return endpointResource; } beforeEach(function () { RequestScheduler.clearForSpecs(); IonImageryProvider._endpointCache = {}; + + defaultAssetId = 12335; + defaultEndpoint = { + type: "IMAGERY", + url: "http://test.invalid/layer", + accessToken: "not_really_a_refresh_token", + attributions: [], + }; + defaultConstructorOptions = {}; }); it("conforms to ImageryProvider interface", function () { @@ -64,13 +69,17 @@ describe("Scene/IonImageryProvider", function () { }); it("fromAssetId throws with non-imagery asset", async function () { - await expectAsync( - createTestProviderAsync({ + setUpTestEndpoint({ + endpoint: { type: "3DTILES", url: "http://test.invalid/layer", accessToken: "not_really_a_refresh_token", attributions: [], - }), + }, + }); + + await expectAsync( + IonImageryProvider.fromAssetId(defaultAssetId, defaultConstructorOptions), ).toBeRejectedWithError( RuntimeError, "Cesium ion asset 12335 is not an imagery asset.", @@ -78,13 +87,17 @@ describe("Scene/IonImageryProvider", function () { }); it("fromAssetId rejects with unknown external asset type", async function () { - await expectAsync( - createTestProviderAsync({ + setUpTestEndpoint({ + endpoint: { type: "IMAGERY", externalType: "TUBELCANE", options: { url: "http://test.invalid/layer" }, attributions: [], - }), + }, + }); + + await expectAsync( + IonImageryProvider.fromAssetId(defaultAssetId, defaultConstructorOptions), ).toBeRejectedWithError( RuntimeError, "Unrecognized Cesium ion imagery type: TUBELCANE", @@ -92,54 +105,31 @@ describe("Scene/IonImageryProvider", function () { }); it("fromAssetId resolves to created provider", async function () { - const provider = await createTestProviderAsync(); + setUpTestEndpoint(); + + const provider = await IonImageryProvider.fromAssetId( + defaultAssetId, + defaultConstructorOptions, + ); expect(provider).toBeInstanceOf(IonImageryProvider); expect(provider.errorEvent).toBeDefined(); expect(provider._imageryProvider).toBeInstanceOf( UrlTemplateImageryProvider, ); - }); - it("Uses previously fetched endpoint cache", async function () { - const endpointData = { - type: "IMAGERY", - url: "http://test.invalid/layer", - accessToken: "not_really_a_refresh_token", - attributions: [], - }; - - const assetId = 12335; - const options = { - accessToken: "token", - server: "http://test.invalid", - }; - const endpointResource = IonResource._createEndpointResource( - assetId, - options, - ); - spyOn(IonResource, "_createEndpointResource").and.returnValue( - endpointResource, - ); - spyOn(endpointResource, "fetchJson").and.returnValue( - Promise.resolve(endpointData), + expect(IonResource._createEndpointResource).toHaveBeenCalledWith( + defaultAssetId, + defaultConstructorOptions, ); - - expect(endpointResource.fetchJson.calls.count()).toBe(0); - await IonImageryProvider.fromAssetId(assetId, options); - expect(endpointResource.fetchJson.calls.count()).toBe(1); - - // Same as options but in a different order to verify cache is order independant. - const options2 = { - accessToken: "token", - server: "http://test.invalid", - }; - await IonImageryProvider.fromAssetId(assetId, options2); - //Since the data is cached, fetchJson is not called again. - expect(endpointResource.fetchJson.calls.count()).toBe(1); }); it("propagates called to underlying imagery provider resolves when ready", async function () { - const provider = await createTestProviderAsync(); + setUpTestEndpoint(); + + const provider = await IonImageryProvider.fromAssetId( + defaultAssetId, + defaultConstructorOptions, + ); const internalProvider = provider._imageryProvider; expect(provider.rectangle).toBe(internalProvider.rectangle); expect(provider.tileWidth).toBe(internalProvider.tileWidth); @@ -178,165 +168,220 @@ describe("Scene/IonImageryProvider", function () { expect(credits).toContain(innerCredit); }); - it("handles server-sent credits", async function () { - const serverCredit = { - html: 'Text', - collapsible: false, - }; - const provider = await createTestProviderAsync({ - type: "IMAGERY", - url: "http://test.invalid/layer", - accessToken: "not_really_a_refresh_token", - attributions: [serverCredit], + async function testExternalImagery(type, ImageryClass, options) { + it(`works with type "${type}"`, async function () { + setUpTestEndpoint({ + endpoint: { + type: "IMAGERY", + externalType: type, + options: options, + attributions: [], + }, + }); + + const provider = await IonImageryProvider.fromAssetId( + defaultAssetId, + defaultConstructorOptions, + ); + + expect(provider._imageryProvider).toBeInstanceOf(ImageryClass); }); - const credits = provider.getTileCredits(0, 0, 0); - const credit = credits[0]; - expect(credit).toBeInstanceOf(Credit); - expect(credit.html).toEqual(serverCredit.html); - expect(credit.showOnScreen).toEqual(!serverCredit.collapsible); - }); + it(`works with cached type "${type}"`, async function () { + const endpointResource = setUpTestEndpoint({ + endpoint: { + type: "IMAGERY", + externalType: type, + options: options, + attributions: [], + }, + }); - async function testExternalImagery(type, options, ImageryClass) { - const provider = await createTestProviderAsync({ - type: "IMAGERY", - externalType: type, - options: options, - attributions: [], + const providerA = await IonImageryProvider.fromAssetId( + defaultAssetId, + defaultConstructorOptions, + ); + expect(providerA._imageryProvider).toBeInstanceOf(ImageryClass); + + expect(endpointResource.fetchJson.calls.count()).toBe(1); + + const providerB = await IonImageryProvider.fromAssetId( + defaultAssetId, + // Same options, but different reference + { ...defaultConstructorOptions }, + ); + expect(providerB._imageryProvider).toBeInstanceOf(ImageryClass); + + expect(endpointResource.fetchJson.calls.count()).toBe(1); + }); + + it(`works with type "${type}" and server-sent credits`, async function () { + const serverCredit = { + html: 'Text', + collapsible: false, + }; + + setUpTestEndpoint({ + endpoint: { + type: "IMAGERY", + externalType: type, + options: options, + attributions: [serverCredit], + }, + }); + + const provider = await IonImageryProvider.fromAssetId( + defaultAssetId, + defaultConstructorOptions, + ); + + const credits = provider.getTileCredits(0, 0, 0); + const credit = credits[0]; + expect(credit).toBeInstanceOf(Credit); + expect(credit.html).toEqual(serverCredit.html); + expect(credit.showOnScreen).toEqual(!serverCredit.collapsible); }); - expect(provider._imageryProvider).toBeInstanceOf(ImageryClass); } - it("createImageryProvider works with ARCGIS_MAPSERVER", function () { - spyOn(Resource._Implementations, "loadWithXhr").and.callFake( - function ( - url, - responseType, - method, - data, - headers, - deferred, - overrideMimeType, - ) { - deferred.resolve( - JSON.stringify({ imageUrl: "", imageUrlSubdomains: [], zoomMax: 0 }), - ); - }, - ); - return testExternalImagery( - "ARCGIS_MAPSERVER", - { url: "http://test.invalid" }, - ArcGisMapServerImageryProvider, - ); + describe("ARCGIS_MAPSERVER", function () { + beforeEach(function () { + spyOn(Resource._Implementations, "loadWithXhr").and.callFake( + function ( + url, + responseType, + method, + data, + headers, + deferred, + overrideMimeType, + ) { + deferred.resolve( + JSON.stringify({ + imageUrl: "", + imageUrlSubdomains: [], + zoomMax: 0, + }), + ); + }, + ); + }); + + testExternalImagery("ARCGIS_MAPSERVER", ArcGisMapServerImageryProvider, { + url: "http://test.invalid", + }); }); - it("createImageryProvider works with BING", function () { - spyOn(Resource._Implementations, "loadWithXhr").and.callFake( - function ( - url, - responseType, - method, - data, - headers, - deferred, - overrideMimeType, - ) { - deferred.resolve( - JSON.stringify({ - resourceSets: [ - { - resources: [ - { imageUrl: "", imageUrlSubdomains: [], zoomMax: 0 }, - ], - }, - ], - }), - ); - }, - ); - return testExternalImagery( - "BING", - { url: "http://test.invalid", key: "" }, - BingMapsImageryProvider, - ); + describe("BING", function () { + beforeEach(function () { + spyOn(Resource._Implementations, "loadWithXhr").and.callFake( + function ( + url, + responseType, + method, + data, + headers, + deferred, + overrideMimeType, + ) { + deferred.resolve( + JSON.stringify({ + resourceSets: [ + { + resources: [ + { imageUrl: "", imageUrlSubdomains: [], zoomMax: 0 }, + ], + }, + ], + }), + ); + }, + ); + }); + + testExternalImagery("BING", BingMapsImageryProvider, { + url: "http://test.invalid", + key: "", + }); }); - it("createImageryProvider works with GOOGLE_EARTH", function () { - spyOn(Resource._Implementations, "loadWithXhr").and.callFake( - function ( - url, - responseType, - method, - data, - headers, - deferred, - overrideMimeType, - ) { - deferred.resolve(JSON.stringify({ layers: [{ id: 0, version: "" }] })); - }, - ); + describe("GOOGLE_EARTH", function () { + beforeEach(function () { + spyOn(Resource._Implementations, "loadWithXhr").and.callFake( + function ( + url, + responseType, + method, + data, + headers, + deferred, + overrideMimeType, + ) { + deferred.resolve( + JSON.stringify({ layers: [{ id: 0, version: "" }] }), + ); + }, + ); + }); - return testExternalImagery( - "GOOGLE_EARTH", - { url: "http://test.invalid", channel: 0 }, - GoogleEarthEnterpriseMapsProvider, - ); + testExternalImagery("GOOGLE_EARTH", GoogleEarthEnterpriseMapsProvider, { + url: "http://test.invalid", + channel: 0, + }); }); - it("createImageryProvider works with MAPBOX", function () { - return testExternalImagery( - "MAPBOX", - { accessToken: "test-token", url: "http://test.invalid", mapId: 1 }, - MapboxImageryProvider, - ); + testExternalImagery("MAPBOX", MapboxImageryProvider, { + accessToken: "test-token", + url: "http://test.invalid", + mapId: 1, }); - it("createImageryProvider works with SINGLE_TILE", function () { - spyOn(Resource._Implementations, "createImage").and.callFake( - function (request, crossOrigin, deferred) { - deferred.resolve({ - height: 16, - width: 16, - }); - }, - ); + describe("SINGLE_TILE", function () { + beforeEach(function () { + spyOn(Resource._Implementations, "createImage").and.callFake( + function (request, crossOrigin, deferred) { + deferred.resolve({ + height: 16, + width: 16, + }); + }, + ); + }); - return testExternalImagery( - "SINGLE_TILE", - { url: "http://test.invalid" }, - SingleTileImageryProvider, - ); + testExternalImagery("SINGLE_TILE", SingleTileImageryProvider, { + url: "http://test.invalid", + }); }); - it("createImageryProvider works with TMS", function () { - return testExternalImagery( - "TMS", - { url: "http://test.invalid" }, - UrlTemplateImageryProvider, - ); + testExternalImagery("TMS", UrlTemplateImageryProvider, { + url: "http://test.invalid", }); - it("createImageryProvider works with URL_TEMPLATE", function () { - return testExternalImagery( - "URL_TEMPLATE", - { url: "http://test.invalid" }, - UrlTemplateImageryProvider, - ); + testExternalImagery("URL_TEMPLATE", UrlTemplateImageryProvider, { + url: "http://test.invalid", }); - it("createImageryProvider works with WMS", function () { - return testExternalImagery( - "WMS", - { url: "http://test.invalid", layers: [] }, - WebMapServiceImageryProvider, - ); + testExternalImagery("WMS", WebMapServiceImageryProvider, { + url: "http://test.invalid", + layers: [], }); - it("createImageryProvider works with WMTS", function () { - return testExternalImagery( - "WMTS", - { url: "http://test.invalid", layer: "", style: "", tileMatrixSetID: 1 }, - WebMapTileServiceImageryProvider, - ); + testExternalImagery("WMTS", WebMapTileServiceImageryProvider, { + url: "http://test.invalid", + layer: "", + style: "", + tileMatrixSetID: 1, + }); + + testExternalImagery("GOOGLE_2D_MAPS", Google2DImageryProvider, { + url: "http://test.invalid", + key: "", + session: "", + tileWidth: 256, + tileHeight: 256, + }); + + testExternalImagery("AZURE_MAPS", Azure2DImageryProvider, { + url: "http://test.invalid", + subscriptionKey: "", }); });