mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
feat(street-level): provider exports and a Mapillary Graph endpoint for hosts (#1324)
* feat(street-level): export the KartaView and Panoramax providers A host that composes Street Level with its own provider list can already import the Mapillary and Google Street View providers; KartaView and Panoramax were reachable only through the default list, which needs a Mapillary source. Exporting them lets a host offer the keyless providers without Mapillary. * chore: changelog entry for the Street Level provider exports * feat(street-level): send Mapillary Graph lookups to a source endpoint A Mapillary source can take a graphEndpoint, a Graph API base that adds the token itself. Its sequence lookups go there through the source's transport, and the photo viewer uses MapillaryJS's stock GraphDataProvider at that endpoint, so a host that proxies Mapillary needs no client token in the page. Without it, behaviour is unchanged: direct Graph API calls with the client token.
This commit is contained in:
1 parent
f19a6da5b8
commit
8cdd184a90
8 files changed
+161
-7
No files matched your search
@@ -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).
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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 } = {}) {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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' }), '');
|
||||
});
|
||||
Reference in new issue
Block a user