Skip to content

Proxy Endpoints ​

The browser never talks to collection-api, Cantaloupe or the storage API directly. Everything goes through the client's GraphQL service — the Node/Express app that also hosts Apollo Server — over same-origin /api/... URLs.

The reason is authentication. Those upstream services require a bearer token, and the browser must not hold one. A proxy endpoint takes a plain, credential-free request carrying only the session cookie, re-issues it server-side with a valid token attached (refreshing the token first if it has expired), and returns the result.

The endpoints exist for anything a plain GraphQL query cannot carry: file bytes, streams, CSV and XLSX exports, third-party viewer libraries that build their own URLs. If your data fits in a GraphQL response, use a resolver instead — a proxy endpoint is the escape hatch, not the default.

Registration order ​

start() in main.ts mounts handlers in a fixed order:

  1. defaultElodyEndpointMapping — the base endpoints every client gets: auth, upload, download, export, XLSX export, health, version, app configs.
  2. fullElodyConfig.endpoints — module endpoints, contributed by whichever modules the client composes (for example mediafileElodyConfig.endpoints).
  3. SEO (only if features.SEO) and Prometheus (only if promUrl is not no-prom).
  4. customEndpoints — the client's own.
  5. configureFrontendForEnvironment(app, viteServer) — the frontend handler.

The frontend handler is mounted last, and it catches everything

Anything not claimed by an earlier handler is answered by the SPA. A proxy registered outside this flow, or one whose path the frontend also claims, silently returns index.html — you get a 200 with HTML where you expected JSON or bytes, which is a confusing failure mode. If a new endpoint "does nothing", check that it is registered in one of the four slots above.

Express is first-match-wins, so two handlers on overlapping paths mean the earlier registration shadows the later one. Note that this is about route paths, not filenames: baseGraphql and mediafileModule both have an uploadEndpoint.ts, and those are a deliberate split (CSV/XML versus media) serving different paths, not a conflict. Compare the paths.

Setting one up ​

Both registration paths use the same signature:

ts
(app: Express, environment: Environment) => void

As a client endpoint ​

Write the handler, then pass it to start() in your client's inuits-dams-graphql-service/src/main.ts:

ts
start({
  customModuleConfig: assetEngineElodyConfig,
  appConfig: assetEngineAppConfig,
  customTranslations: assetEngineTranslations,
  customEndpoints: [redirectEndpoint, genericApiRedirectEndpoint],
  // ...
});

As a module endpoint ​

Module authors add to the endpoints array on their ElodyModuleConfig, as mediafileModule.ts does:

ts
export const mediafileElodyConfig: ElodyModuleConfig = {
  modules: [mediafileModule],
  dataSources: { /* ... */ },
  endpoints: [
    (app) => applyUploadEndpoint(app),
    (app, env) => applyDownloadZipEndpoint(app),
    (app, env) => applyMediaFileEndpoint(app, env),
  ],
};

A client that composes the module gets the endpoints along with it — but only if it forwards them. Digipolis, for instance, builds its own module config and has to pass endpoints: mediafileElodyConfig.endpoints through explicitly.

Three patterns — pick by response type ​

JSON in, JSON out → AuthRESTDataSource ​

The default choice. AuthRESTDataSource is the same Apollo RESTDataSource subclass the resolvers use, so its willSendRequest hook attaches everything an upstream call needs: the bearer token from AuthTokenManager.getValidToken(), an X-request-id for tracing, and X-tenant-id when a tenant is in context. Retries are handled for you.

ts
export const applyDownloadEndpoint = (app: Express) => {
  app.post('/api/download/csv', async (request, response) => {
    try {
      const environment = getCurrentEnvironment();
      const datasource = new AuthRESTDataSource({
        environment,
        session: request.session,
        clientIp: request.headers['x-forwarded-for'] as string,
        clientOrigin: getClientOrigin(request.headers),
      });
      const result = await datasource.post(/* url */, { method: 'GET' });
      response.status(200).setHeader('Content-Type', 'text/csv').end(result);
    } catch (exception: any) {
      /* ... */
    }
  });
};

You must pass session — that is where the token lives. clientIp and clientOrigin are what make IP and domain whitelisting work, so pass them too unless you know the endpoint is session-only.

Subclass it when you want a fixed baseURL and named methods, as Digipolis's genericApiRedirectEndpoint does to forward /api/entities/* straight to collection-api:

ts
export class EntityCollectionAPI extends AuthRESTDataSource {
  public baseURL = `${this.environment.api.collectionApiUrl}/`;
  async proxyRequest(method: string, path: string, body?: any, query?: any) {
    /* ... */
  }
}

Binary or streamed → fetchWithTokenRefresh ​

For file bytes, AuthRESTDataSource is the wrong tool — it parses responses. Use fetchWithTokenRefresh and stream the body, as /api/iiif/* does:

ts
app.use('/api/iiif/*', async (req, res) => {
  const target = `${environment.api.iiifUrl}${req.originalUrl.replace('/api', '')}`;
  try {
    const incomingAuth = req.headers.authorization as string | undefined;
    const response = incomingAuth
      ? await fetch(target, { method: 'GET', headers: { Authorization: incomingAuth } })
      : await fetchWithTokenRefresh(target, { method: 'GET' }, req);

    if (!response.ok) throw response;

    const blob = await response.blob();
    res.setHeader('Content-Type', blob.type);
    pump(blob.stream().getReader(), res);
  } catch (error: any) {
    res
      .status(extractErrorCode(error))
      .set({ 'Cache-Control': 'no-store', Pragma: 'no-cache' })
      .end(JSON.stringify(error));
  }
});

Three things this shows that are worth copying:

  • The third argument is mandatory. fetchWithTokenRefresh(url, options, req) throws immediately if req.session is missing — it needs the session to build an AuthTokenManager. The error message states the signature, so this fails loudly rather than silently sending an unauthenticated request.
  • Pass through an incoming Authorization header. If the caller already has its own token — a service-to-service client such as the canopy generator, using a scoped JWT — use it as-is instead of the dashboard session. This keeps the proxy generic, with no tenant flags or referer sniffing.
  • Cache-Control: no-store on the error path. Without it, a 401 served where an image was expected can be cached by the browser, and the image stays broken after the user's session recovers.

Rewriting the upstream body → createProxyMiddleware ​

Reach for http-proxy-middleware when you need to mutate the upstream response rather than relay it. The /api/iiif*.json endpoint exists purely for that: a IIIF info.json contains an absolute id pointing at Cantaloupe's own origin, and OpenSeadragon would take it at face value and request tiles from a host the browser cannot reach. So the id is rewritten to an /api/iiif/... URL on the way through:

ts
app.use(
  '/api/iiif*.json',
  createProxyMiddleware({
    target: environment.api.iiifUrl,
    changeOrigin: true,
    selfHandleResponse: true,
    pathRewrite: (path, req) => `${req.originalUrl.replace('/api', '')}`,
    onProxyRes: responseInterceptor(async (responseBuffer) => {
      /* parse, rewrite response.id, re-serialize */
    }),
    onError: (err, req, res) => res.status(500).send(err),
  })
);

selfHandleResponse: true is what enables responseInterceptor; without it the body is piped straight through and the interceptor never runs. Note this buffers the whole response in memory — fine for a small JSON document, wrong for a media file.

Errors ​

extractErrorCode is the shared helper for turning whatever an upstream threw into a status code, checking in order:

error.extensions.statusCode → error.extensions.response.status → error.status → 500

Use it rather than hand-rolling the chain — the shape differs depending on whether the failure came from a RESTDataSource (extensions) or a bare fetch (status).

Existing endpoints ​

Verified while writing this page; the list is not exhaustive.

PathModulePatternPurpose
/api/mediafile/*elody-mediafile-modulefetchWithTokenRefresh + piperesolves download URLs, serves original or transcode
/api/iiif*.jsonelody-mediafile-modulecreateProxyMiddlewareIIIF info.json with rewritten id
/api/iiif/*elody-mediafile-modulefetchWithTokenRefresh + streamtiles, crops, scaled downloads
/api/download/csvelody-base-graphqlAuthRESTDataSourceordered CSV of an entity's children
/api/entities/*digipolis-dams (client)AuthRESTDataSource subclassgeneric method-preserving collection-api passthrough

For how the media viewers consume the first three, see Media Viewers.

The pieces ​

FileRepoRole
main.tselody-base-graphqlstart(), registration order, applyCustomEndpoints
sources/defaultElodyEndpointMapping.tselody-base-graphqlthe base endpoint set
endpoints/fetchWithToken.tselody-base-graphqlfetchWithTokenRefresh
auth/AuthRESTDataSource.tselody-base-graphqltoken, X-request-id, X-tenant-id, retries
auth/authTokenManager.tselody-base-graphqlsession / whitelist / anonymous token resolution
helpers/helpers.tselody-base-graphqlextractErrorCode, getClientOrigin
endpoints/downloadEndpoint.tselody-base-graphqlminimal AuthRESTDataSource example
endpoints/mediafilesEndpoint.tselody-mediafile-moduleall three patterns in one file
mediafileModule.tselody-mediafile-modulemodule-level endpoints registration

Powered by Elody - Open Source Semantic Data Platform