diff --git a/changelog.d/1324-street-level-provider-exports.md b/changelog.d/1324-street-level-provider-exports.md new file mode 100644 index 000000000..a3dc4cb6b --- /dev/null +++ b/changelog.d/1324-street-level-provider-exports.md @@ -0,0 +1,8 @@ +- Export the KartaView and Panoramax Street Level providers, so a host composing + its own provider list can offer the keyless providers without Mapillary + (samehkhamis, #1324). +- A Mapillary source can take a `graphEndpoint`, a Graph API base that adds the + token itself (such as a same-origin proxy). Its sequence lookups go there + through the source's transport, and the photo viewer uses MapillaryJS's + Graph provider at that endpoint, so no client token is needed in the page + (samehkhamis, #1324). diff --git a/package.json b/package.json index 241c59e59..d6f74bc0f 100644 --- a/package.json +++ b/package.json @@ -272,6 +272,8 @@ "./layers/street-level/mapillary": "./src/layers/streetLevel/providers/mapillary/index.js", "./layers/street-level/mapillary/source": "./src/layers/streetLevel/providers/mapillary/source.js", "./layers/street-level/google": "./src/layers/streetLevel/providers/google/index.js", + "./layers/street-level/kartaview": "./src/layers/streetLevel/providers/kartaview/index.js", + "./layers/street-level/panoramax": "./src/layers/streetLevel/providers/panoramax/index.js", "./tools": "./src/tools/index.js", "./tools/mcp": "./src/tools/mcp/index.js", "./tools/panel": "./src/tools/panel.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 80d1126e9..4a756f656 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1430,7 +1430,9 @@ "./layers/street-level", "./layers/street-level/mapillary", "./layers/street-level/mapillary/source", - "./layers/street-level/google" + "./layers/street-level/google", + "./layers/street-level/kartaview", + "./layers/street-level/panoramax" ], "modules": [ "src/layers/streetLevel/index.js", diff --git a/src/app/layers/streetLevel.test.mjs b/src/app/layers/streetLevel.test.mjs index e82e762e5..d7f2c6125 100644 --- a/src/app/layers/streetLevel.test.mjs +++ b/src/app/layers/streetLevel.test.mjs @@ -72,3 +72,17 @@ test('the defaults are Mapillary (given its source), Street View, KartaView and // Without the application's Street Level source there are none. assert.deepEqual(createApplicationStreetLevel({}).providerIds, []); }); + +test('every built-in provider is exported, so hosts can choose their own list', async () => { + const google = await import('gods-eye-view/layers/street-level/google'); + const kartaview = await import('gods-eye-view/layers/street-level/kartaview'); + const panoramax = await import('gods-eye-view/layers/street-level/panoramax'); + const layer = createApplicationStreetLevel({ + providers: [ + google.createGoogleProvider({ getApiKey: () => null }), + kartaview.createKartaViewProvider(), + panoramax.createPanoramaxProvider(), + ], + }); + assert.deepEqual(layer.providerIds, ['google', 'kartaview', 'panoramax']); +}); diff --git a/src/layers/streetLevel/providers/mapillary/source.js b/src/layers/streetLevel/providers/mapillary/source.js index 008697b1b..38a8b9c39 100644 --- a/src/layers/streetLevel/providers/mapillary/source.js +++ b/src/layers/streetLevel/providers/mapillary/source.js @@ -36,16 +36,29 @@ async function readJsonOrThrow(response, label) { return payload; } +/** `endpoint` with exactly one trailing slash, or '' when unset. */ +function endpointBase(endpoint) { + const text = String(endpoint || '').trim(); + return text ? text.replace(/\/*$/, '/') : ''; +} + /** * Mapillary network access: tiles via the local proxy, which adds the token * and caches; Graph API lookups direct with the client token, as MapillaryJS. - * @param {{token?: string, fetchImpl?: Function, endpoints?: object}} options + * With `graphEndpoint` (a Graph API base that adds the token itself, such as + * a same-origin proxy), the source's Graph lookups go there through + * `fetchImpl` instead, and the photo viewer sends its own lookups there too; + * no token is sent or needed. + * @param {{token?: string, fetchImpl?: Function, endpoints?: object, + * graphEndpoint?: string}} options */ export function createMapillarySource({ token = '', fetchImpl = (...args) => globalThis.fetch(...args), endpoints = {}, + graphEndpoint = '', } = {}) { + const graphBase = endpointBase(graphEndpoint); const urls = { status: '/api/mapillary/status', tiles: '/api/mapillary/tiles', @@ -54,6 +67,13 @@ export function createMapillarySource({ }; async function graph(path, params = {}, { signal } = {}) { + if (graphBase) { + const search = new URLSearchParams(params); + const response = await fetchImpl(`${graphBase}${path}?${search}`, { + signal, + }); + return readJsonOrThrow(response, 'Mapillary graph'); + } if (!token) throw new MapillarySourceError('Mapillary token not configured', { status: 503, @@ -70,6 +90,10 @@ export function createMapillarySource({ get token() { return token; }, + /** The Graph API base that adds the token itself, or '' (direct). */ + get graphEndpoint() { + return graphBase; + }, hasToken: () => Boolean(token), async getStatus({ signal } = {}) { diff --git a/src/layers/streetLevel/providers/mapillary/source.test.mjs b/src/layers/streetLevel/providers/mapillary/source.test.mjs index d744e499d..b502a7fd8 100644 --- a/src/layers/streetLevel/providers/mapillary/source.test.mjs +++ b/src/layers/streetLevel/providers/mapillary/source.test.mjs @@ -125,3 +125,35 @@ test('a rejected key and a rate limit are told apart from other tile errors', as 'the Retry-After header wins over the body', ); }); + +test('with a Graph endpoint, sequence lookups go there without a token', async () => { + const { calls, fetchImpl } = fakeFetch(() => + jsonResponse({ data: [{ id: '1', geometry: { coordinates: [1, 2] } }] }), + ); + const source = createMapillarySource({ + graphEndpoint: '/api/mapillary/graph', + fetchImpl, + }); + assert.equal(source.graphEndpoint, '/api/mapillary/graph/'); + assert.equal(source.hasToken(), false); + const images = await source.getSequenceImages('seq-1'); + assert.equal(images.length, 1); + const url = new URL(calls[0].url, 'http://localhost'); + assert.equal(url.pathname, '/api/mapillary/graph/images'); + assert.equal(url.searchParams.get('sequence_ids'), 'seq-1'); + assert.equal(url.searchParams.has('access_token'), false); +}); + +test('Graph endpoint errors keep their status', async () => { + const { fetchImpl } = fakeFetch(() => + jsonResponse({ error: 'no_key', keyRequired: true }, 503), + ); + const source = createMapillarySource({ + graphEndpoint: '/api/mapillary/graph/', + fetchImpl, + }); + await assert.rejects( + () => source.getSequenceImages('1'), + (error) => error.status === 503 && error.keyRequired === true, + ); +}); diff --git a/src/layers/streetLevel/providers/mapillary/viewer.js b/src/layers/streetLevel/providers/mapillary/viewer.js index d36580321..4ec2654e4 100644 --- a/src/layers/streetLevel/providers/mapillary/viewer.js +++ b/src/layers/streetLevel/providers/mapillary/viewer.js @@ -13,6 +13,20 @@ import { mapillaryImageUrl } from './policy.js'; * @property {string} externalUrl Deep link to the image on mapillary.com. */ +/** + * The source's Graph endpoint as an absolute URL ending in `/`, as MapillaryJS + * resolves its lookup paths against it; '' when the source has none. + */ +export function graphEndpointUrl(source) { + if (!source?.graphEndpoint) return ''; + const url = new URL( + source.graphEndpoint, + globalThis.location?.href || 'http://localhost/', + ); + url.pathname = url.pathname.replace(/\/*$/, '/'); + return url.href; +} + /** * The panel's photo viewer: loads MapillaryJS on the first `mount` and emits * a pose on every image, view or position change. @@ -115,12 +129,18 @@ export function createMapillaryViewer({ source, render } = {}) { async function buildViewer(host) { const built = generation; destroyViewer(); - const { Viewer } = await ensureLibrary(); + const { Viewer, GraphDataProvider } = await ensureLibrary(); // Unmounted (layer off) while the library loaded: a viewer built now // would hold a WebGL context nobody releases. if (built !== generation) throw new Error('Mapillary viewer was unmounted'); + const endpoint = graphEndpointUrl(source); viewer = new Viewer({ - accessToken: source.token, + // A source with its own Graph endpoint (which adds the token itself) + // answers the viewer's lookups, sent without a token; otherwise + // MapillaryJS calls the Graph API with the client token. + ...(endpoint + ? { dataProvider: new GraphDataProvider({ endpoint }) } + : { accessToken: source.token }), container: host, component: { cover: false, diff --git a/src/layers/streetLevel/viewerHost.test.mjs b/src/layers/streetLevel/viewerHost.test.mjs index 4700acf14..fdcb74e21 100644 --- a/src/layers/streetLevel/viewerHost.test.mjs +++ b/src/layers/streetLevel/viewerHost.test.mjs @@ -22,13 +22,16 @@ registerHooks({ const source = url.includes('/library?') ? 'const lib = await globalThis.__gevMapillaryLibrary();\n' + 'export const Viewer = lib.Viewer;\n' + - 'export const RenderMode = lib.RenderMode;\n' + 'export const RenderMode = lib.RenderMode;\n' + + 'export const GraphDataProvider = lib.GraphDataProvider;\n' : 'export default {};\n'; return { format: 'module', shortCircuit: true, source }; }, }); -const { createMapillaryViewer } = +const { createMapillaryViewer, graphEndpointUrl } = await import('./providers/mapillary/viewer.js'); +const { createMapillarySource } = + await import('./providers/mapillary/source.js'); /** * A viewer host over `adapter`. `marker` records where the position marker @@ -345,7 +348,7 @@ function fakeLibrary() { } globalThis.__gevMapillaryLibrary = async () => { await gate.promise; - return { Viewer, RenderMode: {} }; + return { Viewer, RenderMode: {}, GraphDataProvider: FakeGraphDataProvider }; }; return { gate, viewers }; } @@ -565,3 +568,52 @@ test('a pose’s licence reaches the caption state, and a closed viewer forgets host.close(); assert.equal(state.street.license, null); }); + +/** MapillaryJS 4's Graph provider, recording what it was built with. */ +class FakeGraphDataProvider { + constructor(options = {}) { + this.options = options; + } +} + +test('without a Graph endpoint the viewer gets the client token, as before', async () => { + const library = fakeLibrary(); + library.gate.resolve(); + const adapter = createMapillaryViewer({ source: { token: 't' } }); + await adapter.mount({}); + const [viewer] = library.viewers.instances; + assert.equal(viewer.options.accessToken, 't'); + assert.equal(viewer.options.dataProvider, undefined); +}); + +test("with a Graph endpoint the viewer uses MapillaryJS's Graph provider there, without a token", async () => { + const library = fakeLibrary(); + library.gate.resolve(); + const source = createMapillarySource({ + graphEndpoint: 'https://app.example/api/mapillary/graph', + fetchImpl: async () => { + throw new Error('the viewer does not use the source transport'); + }, + }); + const adapter = createMapillaryViewer({ source }); + await adapter.mount({}); + const [viewer] = library.viewers.instances; + assert.equal(viewer.options.accessToken, undefined); + const provider = viewer.options.dataProvider; + assert.ok(provider instanceof FakeGraphDataProvider); + assert.deepEqual(provider.options, { + endpoint: 'https://app.example/api/mapillary/graph/', + }); +}); + +test('a relative Graph endpoint resolves against the page, with one trailing slash', () => { + assert.equal( + graphEndpointUrl({ graphEndpoint: '/api/mapillary/graph' }), + 'http://localhost/api/mapillary/graph/', + ); + assert.equal( + graphEndpointUrl({ graphEndpoint: '/api/mapillary/graph//' }), + 'http://localhost/api/mapillary/graph/', + ); + assert.equal(graphEndpointUrl({ token: 't' }), ''); +});