From 601160e9fb4a6a52b95f945fa7d1eccf7c851a5d Mon Sep 17 00:00:00 2001
From: John Reagan <john.reagan@pathai.com>
Date: Tue, 29 Nov 2022 15:00:24 -0500
Subject: [PATCH] Add support for dynamic urls from tile source. Fixes #1970

---
 src/tile.js                            |  36 ++++-
 src/tiledimage.js                      |  18 +--
 src/tilesource.js                      |   1 +
 test/modules/tilesource-dynamic-url.js | 184 +++++++++++++++++++++++++
 test/test.html                         |   1 +
 5 files changed, 226 insertions(+), 14 deletions(-)
 create mode 100644 test/modules/tilesource-dynamic-url.js

diff --git a/src/tile.js b/src/tile.js
index 81bc8999..33e0cee4 100644
--- a/src/tile.js
+++ b/src/tile.js
@@ -44,7 +44,7 @@
  *      coordinates.
  * @param {Boolean} exists Is this tile a part of a sparse image? ( Also has
  *      this tile failed to load? )
- * @param {String} url The URL of this tile's image.
+ * @param {String|() => String} url The URL of this tile's image or a function that returns a url.
  * @param {CanvasRenderingContext2D} context2D The context2D of this tile if it
  *      is provided directly by the tile source.
  * @param {Boolean} loadWithAjax Whether this tile image should be loaded with an AJAX request .
@@ -95,11 +95,13 @@ $.Tile = function(level, x, y, bounds, exists, url, context2D, loadWithAjax, aja
      */
     this.exists  = exists;
     /**
-     * The URL of this tile's image.
-     * @member {String} url
+     * Private property to hold string url or url retriever function.
+     * Consumers should access via Tile.getUrl()
+     * @private
+     * @member {String|() => String} url
      * @memberof OpenSeadragon.Tile#
      */
-    this.url     = url;
+    this._url     = url;
     /**
      * Post parameters for this tile. For example, it can be an URL-encoded string
      * in k1=v1&k2=v2... format, or a JSON, or a FormData instance... or null if no POST request used
@@ -269,7 +271,7 @@ $.Tile.prototype = {
     _hasTransparencyChannel: function() {
         console.warn("Tile.prototype._hasTransparencyChannel() has been " +
             "deprecated and will be removed in the future. Use TileSource.prototype.hasTransparency() instead.");
-        return !!this.context2D || this.url.match('.png');
+        return !!this.context2D || this.getUrl().match('.png');
     },
 
     /**
@@ -342,6 +344,18 @@ $.Tile.prototype = {
         return this.getImage();
     },
 
+    /**
+     * The URL of this tile's image.
+     * @member {String} url
+     * @memberof OpenSeadragon.Tile#
+     * @deprecated
+     * @returns {String}
+     */
+    get url() {
+        $.console.error("[Tile.url] property has been deprecated. Use [Tile.prototype.getUrl] instead.");
+        return this.getUrl();
+    },
+
     /**
      * Get the Image object for this tile.
      * @returns {Image}
@@ -350,6 +364,18 @@ $.Tile.prototype = {
         return this.cacheImageRecord.getImage();
     },
 
+    /**
+     * Get the url string for this tile.
+     * @returns {String}
+     */
+    getUrl: function() {
+        if (typeof this._url === 'function') {
+            return this._url();
+        }
+
+        return this._url;
+    },
+
     /**
      * Get the CanvasRenderingContext2D instance for tile image data drawn
      * onto Canvas if enabled and available
diff --git a/src/tiledimage.js b/src/tiledimage.js
index 2715730a..cbcdfc92 100644
--- a/src/tiledimage.js
+++ b/src/tiledimage.js
@@ -1480,7 +1480,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag
             bounds,
             sourceBounds,
             exists,
-            url,
+            urlOrGetter,
             post,
             ajaxHeaders,
             context2D,
@@ -1501,7 +1501,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag
             bounds  = this.getTileBounds( level, x, y );
             sourceBounds = tileSource.getTileBounds( level, xMod, yMod, true );
             exists  = tileSource.tileExists( level, xMod, yMod );
-            url     = tileSource.getTileUrl( level, xMod, yMod );
+            urlOrGetter     = tileSource.getTileUrl( level, xMod, yMod );
             post    = tileSource.getTilePostData( level, xMod, yMod );
 
             // Headers are only applicable if loadTilesWithAjax is set
@@ -1524,13 +1524,13 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag
                 y,
                 bounds,
                 exists,
-                url,
+                urlOrGetter,
                 context2D,
                 this.loadTilesWithAjax,
                 ajaxHeaders,
                 sourceBounds,
                 post,
-                tileSource.getTileHashKey(level, xMod, yMod, url, ajaxHeaders, post)
+                tileSource.getTileHashKey(level, xMod, yMod, urlOrGetter, ajaxHeaders, post)
             );
 
             if (this.getFlip()) {
@@ -1569,7 +1569,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag
         var _this = this;
         tile.loading = true;
         this._imageLoader.addJob({
-            src: tile.url,
+            src: tile.getUrl(),
             tile: tile,
             source: this.source,
             postData: tile.postData,
@@ -1598,7 +1598,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag
      */
     _onTileLoad: function( tile, time, data, errorMsg, tileRequest ) {
         if ( !data ) {
-            $.console.error( "Tile %s failed to load: %s - error: %s", tile, tile.url, errorMsg );
+            $.console.error( "Tile %s failed to load: %s - error: %s", tile, tile.getUrl(), errorMsg );
             /**
              * Triggered when a tile fails to load.
              *
@@ -1624,7 +1624,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag
         }
 
         if ( time < this.lastResetTime ) {
-            $.console.warn( "Ignoring tile %s loaded before reset: %s", tile, tile.url );
+            $.console.warn( "Ignoring tile %s loaded before reset: %s", tile, tile.getUrl() );
             tile.loading = false;
             return;
         }
@@ -1669,7 +1669,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag
                 tile.loading = false;
                 tile.loaded = true;
                 tile.hasTransparency = _this.source.hasTransparency(
-                    tile.context2D, tile.url, tile.ajaxHeaders, tile.postData
+                    tile.context2D, tile.getUrl(), tile.ajaxHeaders, tile.postData
                 );
                 if (!tile.context2D) {
                     _this._tileCache.cacheTile({
@@ -1852,7 +1852,7 @@ $.extend($.TiledImage.prototype, $.EventSource.prototype, /** @lends OpenSeadrag
             useSketch = this.opacity < 1 ||
                 (this.compositeOperation && this.compositeOperation !== 'source-over') ||
                 (!this._isBottomItem() &&
-                    this.source.hasTransparency(tile.context2D, tile.url, tile.ajaxHeaders, tile.postData));
+                    this.source.hasTransparency(tile.context2D, tile.getUrl(), tile.ajaxHeaders, tile.postData));
         }
 
         var sketchScale;
diff --git a/src/tilesource.js b/src/tilesource.js
index 604308ff..7875d23a 100644
--- a/src/tilesource.js
+++ b/src/tilesource.js
@@ -618,6 +618,7 @@ $.TileSource.prototype = {
      * @param {Number} level
      * @param {Number} x
      * @param {Number} y
+     * @returns {String|() => string} url - A string for the url or a function that returns a url string.
      * @throws {Error}
      */
     getTileUrl: function( level, x, y ) {
diff --git a/test/modules/tilesource-dynamic-url.js b/test/modules/tilesource-dynamic-url.js
new file mode 100644
index 00000000..d6216928
--- /dev/null
+++ b/test/modules/tilesource-dynamic-url.js
@@ -0,0 +1,184 @@
+/* global QUnit, testLog, Util */
+
+//Testing of TileSource with getTileUrl that returns a function
+
+(function() {
+    var ASSERT = null;
+    var DYNAMIC_URL = "";
+    var viewer = null;
+    var OriginalAjax = OpenSeadragon.makeAjaxRequest;
+    var OriginalTile = OpenSeadragon.Tile;
+
+    /**
+     * Set up shared variables for test
+     */
+    var configure = function(assert, url) {
+        ASSERT = assert;
+        DYNAMIC_URL = url;
+    };
+
+    QUnit.module('TileSourceDynamicUrl', {
+        beforeEach: function () {
+            testLog.reset();
+            $("#qunit-fixture").html("<div id='example'></div>");
+
+            // Add test tile source to OSD
+            OpenSeadragon.DynamicUrlTestTileSource = function( options ) {
+                OpenSeadragon.TileSource.apply( this, [ options ] );
+            };
+
+            OpenSeadragon.extend( OpenSeadragon.DynamicUrlTestTileSource.prototype, OpenSeadragon.TileSource.prototype, {
+                supports: function( data, url ){
+                    return url.indexOf('dynamic') !== -1;
+                },
+
+                configure: function( _data, url, postData ){
+                    //some default data to trigger painting
+                    return {
+                        postData: postData,
+                        tilesUrl: url,
+                        fileFormat: "jpg",
+                        sizeData: {Width: 55, Height: 55},
+                        width: 55,
+                        height: 55,
+                        tileSize: 55,
+                        tileOverlap: 55,
+                        minLevel: 1,
+                        maxLevel: 1,
+                        displayRects: []
+                    };
+                },
+
+                // getTileUrl return a function that must be called by Tile.getUrl
+                getTileUrl: function( _level, _x, _y ) {
+                    // Assert that custom tile source is called correctly
+                    ASSERT.ok(true, 'DynamicUrlTileSource.getTileUrl called');
+                    return () => DYNAMIC_URL;
+                },
+
+                tileExists: function ( _level, _x, _y ) {
+                    return true;
+                }
+            });
+
+            var hasCompletedImageInfoRequest = false;
+            OpenSeadragon.makeAjaxRequest = function( url, onSuccess, onError ) {
+                // Note that our preferred API is that you pass in a single object; the named
+                // arguments are for legacy support.
+                if( $.isPlainObject( url ) ){
+                    onSuccess = url.success;
+                    onError = url.error;
+                    withCredentials = url.withCredentials;
+                    headers = url.headers;
+                    responseType = url.responseType || null;
+                    postData = url.postData || null;
+                    options = url; //save original stuff
+                    url = url.url;
+                }
+
+                //first AJAX firing is the image info getter, second is the first tile request: can exit
+                if (hasCompletedImageInfoRequest) {
+                    // Assert dynamic url from tileSource is called
+                    ASSERT.equal(url, DYNAMIC_URL, 'Called dynamic url correctly');
+                    viewer.close();
+                    return null;
+                }
+
+                hasCompletedImageInfoRequest = true;
+
+                var request = Promise.resolve(url);
+                //some required properties to pass through processResponse(...)
+                request.responseText = "some text";
+                request.status = 200;
+
+                onSuccess(request);
+                return request;
+            };
+
+            // Override Tile to ensure getUrl is called successfully.
+            var Tile = function(...params) {
+                OriginalTile.apply(this, params);
+            };
+
+            OpenSeadragon.extend( Tile.prototype, OpenSeadragon.Tile.prototype, {
+                getUrl: function() {
+                    ASSERT.ok(true, 'Tile.getUrl called');
+                    return OriginalTile.prototype.getUrl.apply(this);
+                }
+            });
+            OpenSeadragon.Tile = Tile;
+        },
+
+        afterEach: function () {
+            ASSERT = null;
+
+            if (viewer && viewer.close) {
+                viewer.close();
+            }
+            viewer = null;
+
+            OpenSeadragon.makeAjaxRequest = OriginalAjax;
+            OpenSeadragon.Tile = OriginalTile;
+        }
+    });
+
+
+    /**
+     * Create viewer for test
+     */
+    var testUrlCall = function(tileSourceUrl) {
+        var timeWatcher = Util.timeWatcher(ASSERT, 7000);
+
+        viewer = OpenSeadragon({
+            id:            'example',
+            prefixUrl:     '/build/openseadragon/images/',
+            tileSources:   tileSourceUrl,
+            loadTilesWithAjax: true,
+        });
+
+        var failHandler = function (event) {
+            testPostData(event.postData, "event: 'open-failed'");
+            viewer.removeHandler('open-failed', failHandler);
+            viewer.close();
+        };
+        viewer.addHandler('open-failed', failHandler);
+
+        var readyHandler = function (event) {
+            //relies on Tilesource contructor extending itself with options object
+            testPostData(event.postData, "event: 'ready'");
+            viewer.removeHandler('ready', readyHandler);
+        };
+        viewer.addHandler('ready', readyHandler);
+
+
+        var openHandler = function(event) {
+            viewer.removeHandler('open', openHandler);
+            ASSERT.ok(true, 'Open event was sent');
+            viewer.addHandler('close', closeHandler);
+            viewer.world.draw();
+        };
+
+        var closeHandler = function(event) {
+            viewer.removeHandler('close', closeHandler);
+            $('#example').empty();
+            ASSERT.ok(true, 'Close event was sent');
+            timeWatcher.done();
+        };
+        viewer.addHandler('open', openHandler);
+    };
+
+    // ----------
+    QUnit.test('TileSource.getTileUrl supports returning a function', function(assert) {
+        /**
+         * Expect 5 assertions to be called:
+         * 1. Open event was sent
+         * 2. DynamicUrlTileSource.getTileUrl called
+         * 3. Tile.getUrl called
+         * 4. Called dynamic url correctly
+         * 5. Close event was sent
+         */
+        assert.expect(5);
+        configure(assert, 'dynamicUrl');
+        testUrlCall('dynamicUrl');
+    });
+})();
diff --git a/test/test.html b/test/test.html
index b113626d..8b85a123 100644
--- a/test/test.html
+++ b/test/test.html
@@ -47,6 +47,7 @@
     <script src="/test/modules/ajax-tiles.js"></script>
     <script src="/test/modules/ajax-post-data.js"></script>
     <script src="/test/modules/imageloader.js"></script>
+    <script src="/test/modules/tilesource-dynamic-url.js"></script>
     <!--The navigator tests are the slowest (for now; hopefully they can be sped up)
     so we put them last. -->
     <script src="/test/modules/navigator.js"></script>