From c2eefa8712d4449d1bdf9c1c677905665b5197dc Mon Sep 17 00:00:00 2001
From: "Michael B. Klein" <mbklein@gmail.com>
Date: Thu, 21 Nov 2024 18:12:57 +0000
Subject: [PATCH] Add more flexibility to `pathPrefix` constructor option

---
 CHANGELOG.md                 |  3 ++
 README.md                    | 10 ++++++-
 examples/tiny-iiif/config.js |  3 +-
 examples/tiny-iiif/iiif.js   |  4 +--
 package.json                 |  2 +-
 src/processor.js             | 56 +++++++++++++++---------------------
 tests/v2/integration.test.js |  4 +--
 tests/v2/processor.test.js   |  7 +++--
 tests/v3/integration.test.js |  4 +--
 tests/v3/processor.test.js   |  4 +--
 10 files changed, 50 insertions(+), 47 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 93571ed..8ca67bf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,9 @@
 
 Only features and major fixes are listed. Everything else can be considered a minor bugfix or maintenance release.
 
+##### v5.1.0
+- Update `pathPrefix` constructor option to accept a `{{version}}` placeholder and RegExp elements (default: `/iiif/{{version}}/`)
+
 ##### v5.0.0
 - Export `Calculator`
 - Make `sharp` an optional dependency for those who just want to use `Calculator`
diff --git a/README.md b/README.md
index 95c4f2f..dfb3cb6 100644
--- a/README.md
+++ b/README.md
@@ -33,7 +33,7 @@ const processor = new IIIF.Processor(url, streamResolver, opts);
   * `density` (integer) – the pixel density to be included in the result image in pixels per inch
     * This has no effect whatsoever on the size of the image that gets returned; it's simply for convenience when using
       the resulting image in software that calculates a default print size based on the height, width, and density
-  * `pathPrefix` (string) – the default prefix that precedes the `id` part of the URL path (default: `/iiif/2/`)
+  * `pathPrefix` (string) – the template used to extract the IIIF version and API parameters from the URL path (default: `/iiif/{{version}}/`) ([see below](#path-prefix))
   * `version` (number) – the major version (`2` or `3`) of the IIIF Image API to use (default: inferred from `/iiif/{version}/`)
 
 ## Examples
@@ -109,6 +109,14 @@ async function dimensionFunction({ id, baseUrl }) {
 }
 ```
 
+### Path Prefix
+
+The `pathPrefix` constructor option provides a tremendous amount of flexibility in how IIIF URLs are structured. The prefix includes one placeholder `{{version}}`, indicating the major version of the IIIF Image API to use when interpreting the rest of the path.
+
+* The `pathPrefix` _must_ start and end with `/`.
+* The `pathPrefix` _must_ include the `{{version}}` placeholder _unless_ the `version` constructor option is specified. If both are present, the constructor option will take precedence.
+* To allow for maximum flexibility, the `pathPrefix` is interpreted as a [JavaScript regular expression](https://www.w3schools.com/jsref/jsref_obj_regexp.asp). For example, `/.+?/iiif/{{version}}/` would allow your path to have arbitrary path elements before `/iiif/`. Be careful when including greedy quantifiers (e.g., `+` as opposed to `+?`), as they may produce unexpected results. `/` characters are treated as literal path separators, not regular expression delimiters as they would be in JavaScript code.
+
 ### Processing
 
 #### Promise
diff --git a/examples/tiny-iiif/config.js b/examples/tiny-iiif/config.js
index 70a6772..619e76f 100644
--- a/examples/tiny-iiif/config.js
+++ b/examples/tiny-iiif/config.js
@@ -1,5 +1,6 @@
 const iiifImagePath = process.env.IIIF_IMAGE_PATH;
+const iiifpathPrefix = process.env.IIIF_PATH_TEMPLATE;
 const fileTemplate = process.env.IMAGE_FILE_TEMPLATE || '{{id}}.tif';
 const port = process.env.PORT || 3000;
 
-export { iiifImagePath, fileTemplate, port };
+export { iiifImagePath, iiifpathPrefix, fileTemplate, port };
diff --git a/examples/tiny-iiif/iiif.js b/examples/tiny-iiif/iiif.js
index 6fe57bb..2382c5c 100644
--- a/examples/tiny-iiif/iiif.js
+++ b/examples/tiny-iiif/iiif.js
@@ -2,7 +2,7 @@ import { App } from '@tinyhttp/app';
 import { Processor } from 'iiif-processor';
 import fs from 'fs';
 import path from 'path';
-import { iiifImagePath, fileTemplate } from './config.js';
+import { iiifImagePath, iiifpathPrefix, fileTemplate } from './config.js';
 
 function createRouter(version) {
   const streamImageFromFile = ({ id }) => {
@@ -21,7 +21,7 @@ function createRouter(version) {
 
     try {
       const iiifUrl = `${req.protocol}://${req.get("host")}${req.path}`;
-      const iiifProcessor = new Processor(iiifUrl, streamImageFromFile);
+      const iiifProcessor = new Processor(iiifUrl, streamImageFromFile, { pathPrefix: iiifpathPrefix });
       const result = await iiifProcessor.execute();
       return res
         .set("Content-Type", result.contentType)
diff --git a/package.json b/package.json
index f8c33a4..adc626b 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "iiif-processor",
-  "version": "5.0.0",
+  "version": "5.1.0",
   "description": "IIIF 2.1 & 3.0 Image API modules for NodeJS",
   "main": "./src",
   "repository": "https://github.com/samvera/node-iiif",
diff --git a/src/processor.js b/src/processor.js
index 44d036e..1e13f42 100644
--- a/src/processor.js
+++ b/src/processor.js
@@ -6,29 +6,25 @@ const { Operations } = require('./transform');
 const IIIFError = require('./error');
 const IIIFVersions = require('./versions');
 
-const fixupSlashes = (path, leaveOne) => {
-  const replacement = leaveOne ? '/' : '';
-  return path?.replace(/^\/*/, replacement).replace(/\/*$/, replacement);
-};
-
-const getIIIFVersion = (url, opts = {}) => {
-  const uri = new URL(url);
-  try {
-    let { iiifVersion, pathPrefix } = opts;
-    if (!iiifVersion) {
-      const match = /^\/iiif\/(?<v>\d)\//.exec(uri.pathname);
-      iiifVersion = match.groups.v;
-    }
-    if (!pathPrefix) pathPrefix = `iiif/${iiifVersion}/`;
-    return { iiifVersion, pathPrefix };
-  } catch {
-    throw new IIIFError(`Cannot determine IIIF version from path ${uri.path}`);
+const defaultpathPrefix = '/iiif/{{version}}/';
+
+function getIiifVersion (url, template) {
+  const { origin, pathname } = new URL(url);
+  const templateMatcher = template.replace(/\{\{version\}\}/, '(?<iiifVersion>2|3)');
+  const pathMatcher = `^(?<prefix>${templateMatcher})(?<request>.+)$`;
+  const re = new RegExp(pathMatcher);
+  const parsed = re.exec(pathname);
+  if (parsed) {
+    parsed.groups.prefix = origin + parsed.groups.prefix;
+    return { ...parsed.groups };
+  } else {
+    throw new IIIFError('Invalid IIIF path');
   }
 };
 
 class Processor {
   constructor (url, streamResolver, opts = {}) {
-    const { iiifVersion, pathPrefix } = getIIIFVersion(url, opts);
+    const { prefix, iiifVersion, request } = getIiifVersion(url, opts.pathPrefix || defaultpathPrefix);
 
     if (typeof streamResolver !== 'function') {
       throw new IIIFError('streamResolver option must be specified');
@@ -44,8 +40,8 @@ class Processor {
     };
 
     this
-      .setOpts({ ...defaults, ...opts, pathPrefix, iiifVersion })
-      .initialize(url, streamResolver);
+      .setOpts({ ...defaults, iiifVersion, ...opts, prefix, request })
+      .initialize(streamResolver);
   }
 
   setOpts (opts) {
@@ -54,29 +50,21 @@ class Processor {
     this.max = { ...opts.max };
     this.includeMetadata = !!opts.includeMetadata;
     this.density = opts.density;
-    this.pathPrefix = fixupSlashes(opts.pathPrefix, true);
+    this.baseUrl = opts.prefix;
     this.sharpOptions = { ...opts.sharpOptions };
     this.version = opts.iiifVersion;
+    this.request = opts.request;
 
     return this;
   }
 
-  parseUrl (url) {
-    const parser = new RegExp(`(?<baseUrl>https?://[^/]+${this.pathPrefix})(?<path>.+)$`);
-    const { baseUrl, path } = parser.exec(url).groups;
-    const result = this.Implementation.Calculator.parsePath(path);
-    result.baseUrl = baseUrl;
-
-    return result;
-  }
-
-  initialize (url, streamResolver) {
+  initialize (streamResolver) {
     this.Implementation = IIIFVersions[this.version];
     if (!this.Implementation) {
       throw new IIIFError(`No implementation found for IIIF Image API v${this.version}`);
     }
 
-    const params = this.parseUrl(url);
+    const params = this.Implementation.Calculator.parsePath(this.request);
     debug('Parsed URL: %j', params);
     Object.assign(this, params);
     this.streamResolver = streamResolver;
@@ -143,7 +131,9 @@ class Processor {
       sizes.push({ width: size[0], height: size[1] });
     }
 
-    const id = [fixupSlashes(this.baseUrl), fixupSlashes(this.id)].join('/');
+    const uri = new URL(this.baseUrl);
+    uri.pathname = path.join(uri.pathname, this.id);
+    const id = uri.toString();
     const doc = this.Implementation.infoDoc({ id, ...dim, sizes, max: this.max });
     for (const prop in doc) {
       if (doc[prop] === null || doc[prop] === undefined) delete doc[prop];
diff --git a/tests/v2/integration.test.js b/tests/v2/integration.test.js
index c165fa9..b9b5970 100644
--- a/tests/v2/integration.test.js
+++ b/tests/v2/integration.test.js
@@ -14,7 +14,7 @@ let consoleWarnMock;
 
 describe('info.json', () => {
   it('produces a valid info.json', async () => {
-    subject = new Processor(`${base}/info.json`, streamResolver, { pathPrefix: 'iiif/2/ab/cd/ef/gh' });
+    subject = new Processor(`${base}/info.json`, streamResolver, { pathPrefix: '/iiif/{{version}}/ab/cd/ef/gh/' });
     const result = await subject.execute();
     const info = JSON.parse(result.body);
     assert.strictEqual(info['@id'], 'https://example.org/iiif/2/ab/cd/ef/gh/i');
@@ -24,7 +24,7 @@ describe('info.json', () => {
   });
 
   it('respects the maxWidth option', async () => {
-    subject = new Processor(`${base}/info.json`, streamResolver, { pathPrefix: 'iiif/2/ab/cd/ef/gh', max: { width: 600  }});
+    subject = new Processor(`${base}/info.json`, streamResolver, { pathPrefix: '/iiif/{{version}}/ab/cd/ef/gh/', max: { width: 600  }});
     const result = await subject.execute();
     const info = JSON.parse(result.body);
     assert.strictEqual(info.profile[1].maxWidth, 600);
diff --git a/tests/v2/processor.test.js b/tests/v2/processor.test.js
index bd831eb..a905cec 100644
--- a/tests/v2/processor.test.js
+++ b/tests/v2/processor.test.js
@@ -178,7 +178,8 @@ describe('constructor', () => {
         { iiifVersion: 3, pathPrefix: '/iiif/III/' }
       );
     assert.strictEqual(subject.version, 3);
-    assert.strictEqual(subject.pathPrefix, '/iiif/III/');
+    assert.strictEqual(subject.id, 'ab/cd/ef/gh/i');
+    assert.strictEqual(subject.baseUrl, 'https://example.org/iiif/III/');
   });
 });
 
@@ -221,7 +222,7 @@ describe('stream processor', () => {
       });
     }
 
-    const subject = new Processor(`https://example.org/iiif/2/ab/cd/ef/gh/i/10,20,30,40/pct:50/45/default.png`, streamResolver, {pathPrefix: 'iiif/2/ab/cd/ef/gh'});
+    const subject = new Processor(`https://example.org/iiif/2/ab/cd/ef/gh/i/10,20,30,40/pct:50/45/default.png`, streamResolver, {pathPrefix: '/iiif/{{version}}/ab/cd/ef/gh/'});
     subject.execute();
   })
 })
@@ -245,7 +246,7 @@ describe('dimension function', () => {
     const subject = new Processor( 
       `https://example.org/iiif/2/ab/cd/ef/gh/i/10,20,30,40/pct:50/45/default.png`,
       streamResolver,
-      { dimensionFunction, pathPrefix: 'iiif/2/ab/cd/ef/gh' }
+      { dimensionFunction, pathPrefix: '/iiif/{{version}}/ab/cd/ef/gh/' }
     );
     subject.execute();
   })
diff --git a/tests/v3/integration.test.js b/tests/v3/integration.test.js
index 38a2795..dfca886 100644
--- a/tests/v3/integration.test.js
+++ b/tests/v3/integration.test.js
@@ -14,7 +14,7 @@ let consoleWarnMock;
 
 describe('info.json', () => {
   it('produces a valid info.json', async () => {
-    subject = new Processor(`${base}/info.json`, streamResolver, { pathPrefix: 'iiif/3/ab/cd/ef/gh' });
+    subject = new Processor(`${base}/info.json`, streamResolver, { pathPrefix: '/iiif/{{version}}/ab/cd/ef/gh/' });
     const result = await subject.execute();
     const info = JSON.parse(result.body);
     assert.strictEqual(info.id, 'https://example.org/iiif/3/ab/cd/ef/gh/i');
@@ -24,7 +24,7 @@ describe('info.json', () => {
   });
 
   it('respects max size options', async () => {
-    subject = new Processor(`${base}/info.json`, streamResolver, { pathPrefix: 'iiif/3/ab/cd/ef/gh', max: { width: 600 } });
+    subject = new Processor(`${base}/info.json`, streamResolver, { pathPrefix: '/iiif/{{version}}/ab/cd/ef/gh/', max: { width: 600 } });
     const result = await subject.execute();
     const info = JSON.parse(result.body);
     assert.strictEqual(info.maxWidth, 600);
diff --git a/tests/v3/processor.test.js b/tests/v3/processor.test.js
index c2814d2..fdcfe5f 100644
--- a/tests/v3/processor.test.js
+++ b/tests/v3/processor.test.js
@@ -220,7 +220,7 @@ describe('stream processor', () => {
       });
     }
 
-    const subject = new Processor(`https://example.org/iiif/3/ab/cd/ef/gh/i/10,20,30,40/pct:50/45/default.png`, streamResolver, {pathPrefix: 'iiif/3/ab/cd/ef/gh'});
+    const subject = new Processor(`https://example.org/iiif/3/ab/cd/ef/gh/i/10,20,30,40/pct:50/45/default.png`, streamResolver, {pathPrefix: '/iiif/{{version}}/ab/cd/ef/gh/'});
     subject.execute();
   })
 })
@@ -244,7 +244,7 @@ describe('dimension function', () => {
     const subject = new Processor(
       `https://example.org/iiif/3/ab/cd/ef/gh/i/10,20,30,40/pct:50/45/default.png`,
       streamResolver,
-      { dimensionFunction, pathPrefix: 'iiif/3/ab/cd/ef/gh' }
+      { dimensionFunction, pathPrefix: '/iiif/{{version}}/ab/cd/ef/gh/' }
     );
     subject.execute();
   })