Skip to content
Draft
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

/**
* Normalises backend-supplied URL fields so the frontend speaks one shape
* (router-relative paths) regardless of whether Superset is deployed at the
* web root or under a subdirectory.
*
* The backend renders absolute paths that include the application root, e.g.
* `/superset/explore/?slice_id=1`. Channel-3 helpers (window.open, redirect,
* AppLink) and channel-2 (`SupersetClient`) re-apply the root themselves;
* leaving the prefix on a backend value would double it. So we strip the
* configured root on the way in and let the consumers re-add it.
*
* # Why this is conservative by design
*
* The normaliser **only touches fields whose name appears in
* `NORMALIZED_URL_FIELDS`**. It does not heuristically detect URLs by content
* — a `description` field containing `/looks/like/a/path` is left alone.
* Adding a new URL field to the backend therefore requires an explicit
* one-line change here. Drift requires intentional opt-in.
*
* Exact-segment prefix matching prevents false positives where a value
* happens to share a prefix with the application root (e.g.
* `/superset-public/...` is not stripped when the root is `/superset`).
*
* Absolute URLs (`https://...`, `mailto:`, protocol-relative `//cdn`) and
* already-router-relative paths are passed through unchanged.
*/

/**
* Field names whose values are router-relative URLs to this Superset
* deployment and therefore safe to normalise.
*
* Curated, not heuristic. Add a field here only after confirming:
*
* 1. The backend always sets it to a path within this Superset instance
* (never an external URL or a path with a different prefix).
* 2. Every consumer expects to feed the value to a channel-3 helper or
* `SupersetClient`, both of which re-apply the application root.
*
* Fields that have been *deliberately excluded* are listed in
* `NORMALIZER_EXCLUSIONS` below with the reason — keep that list in sync.
*/
export const NORMALIZED_URL_FIELDS = new Set<string>([
// Initial set — extended by follow-up commits as each endpoint is audited.
// `explore_url` is the highest-traffic field and the one Layer 3 tests pin.
'explore_url',
]);

/**
* URL-shaped field names that we have deliberately *not* added to
* `NORMALIZED_URL_FIELDS`, with the reason. The negative tests in
* `normalizeBackendUrls.test.ts` assert that values for these names are
* passed through unchanged even when the value happens to begin with the
* configured application root.
*
* This list is informational — code does not read it. Its purpose is to
* preserve institutional knowledge so a future contributor doesn't add an
* exclusion to the allow-list by mistake.
*/
export const NORMALIZER_EXCLUSIONS: ReadonlyArray<{
field: string;
reason: string;
}> = [
{ field: 'bug_report_url', reason: 'External (GitHub)' },
{ field: 'documentation_url', reason: 'External (docs site)' },
{ field: 'external_url', reason: 'External by name' },
{
field: 'bundle_url',
reason: 'CDN / static asset host, not a Superset route',
},
{ field: 'tracking_url', reason: 'External (analytics)' },
{ field: 'user_login_url', reason: 'OAuth / SSO endpoints, may be external' },
{
field: 'user_logout_url',
reason: 'OAuth / SSO endpoints, may be external',
},
{ field: 'user_info_url', reason: 'OAuth / SSO endpoints, may be external' },
{
field: 'thumbnail_url',
reason:
'Storage host varies (S3 / local) — needs per-endpoint audit before normalising',
},
{
field: 'creator_url',
reason: 'User-profile destination varies by deployment',
},
];

export interface NormalizeOptions {
/**
* Application root to strip. Pass an empty string to disable normalisation.
* Trailing slash is tolerated; the strip logic compares whole path segments.
*/
applicationRoot: string;
}

/**
* Matches the same safe-scheme set used by `pathUtils.ensureAppRoot`. We
* deliberately keep this list in sync — the normaliser and the prefix helper
* must agree on what counts as "absolute, leave alone".
*/
const SAFE_ABSOLUTE_URL_RE = /^(?:https?|ftp|mailto|tel):/i;

/**
* Strip a trailing slash from the configured application root so segment
* comparisons are consistent. Bootstrap data may render the root either way
* (`/superset` or `/superset/`); `applicationRoot()` already trims, but
* callers passing the value through configuration may not.
*/
function stripTrailingSlash(root: string): string {
return root.endsWith('/') ? root.slice(0, -1) : root;
}

/**
* Decide whether `value` is a plain object that the walker should descend
* into. Class instances, Dates, Maps, etc. are returned by reference — we
* never mutate or replace those.
*/
function isPlainObject(value: unknown): value is Record<string, unknown> {
if (value === null || typeof value !== 'object') return false;
const proto = Object.getPrototypeOf(value);
return proto === Object.prototype || proto === null;
}

/**
* Normalise a single URL string. Exposed for use cases that read a URL
* directly (e.g. bootstrap data) without going through the recursive walker.
*/
export function normalizeBackendUrlString(
value: string,
options: NormalizeOptions,
): string {
const root = stripTrailingSlash(options.applicationRoot);
if (!root) return value;
if (SAFE_ABSOLUTE_URL_RE.test(value)) return value;
if (value.startsWith('//')) return value;
if (value === root) return '/';
if (value.startsWith(`${root}/`)) {
return value.slice(root.length);
}
return value;
}

function walk(value: unknown, root: string): unknown {
if (Array.isArray(value)) {
let changed = false;
const out: unknown[] = [];
for (let index = 0; index < value.length; index += 1) {
const item = value[index];
const next = walk(item, root);
if (next !== item) changed = true;
out.push(next);
}
return changed ? out : value;
}

if (isPlainObject(value)) {
let changed = false;
const out: Record<string, unknown> = {};
for (const key of Object.keys(value)) {
const fieldValue = value[key];
let nextValue: unknown;
if (NORMALIZED_URL_FIELDS.has(key) && typeof fieldValue === 'string') {
nextValue = normalizeBackendUrlString(fieldValue, {
applicationRoot: root,
});
} else {
nextValue = walk(fieldValue, root);
}
if (nextValue !== fieldValue) changed = true;
out[key] = nextValue;
}
return changed ? out : value;
}

return value;
}

/**
* Recursively normalise URL fields in a JSON-shaped value.
*
* Returns a new value when normalisation changed anything; otherwise returns
* the input by reference so consumers can compare with `===`.
*/
export function normalizeBackendUrls<T>(
value: T,
options: NormalizeOptions,
): T {
const root = stripTrailingSlash(options.applicationRoot);
if (!root) return value;
return walk(value, root) as T;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { SupersetClientClass } from '@superset-ui/core';

// =============================================================================
// Layer 4 example: SupersetClient × applicationRoot contract
// =============================================================================
//
// Layer 4 pins down the contract between the channel-2 client and the
// application root. The channel rule is "callers pass router-relative paths;
// the client adds the prefix exactly once." This file proves that property in
// isolation so the rest of the codebase can rely on it.
//
// The full PR adds parallel tests for the React Router channel
// (`<MemoryRouter basename>` × `<Link to>`) and a composition test that
// drives `redirect()` and `<Link>` together. This file ships the
// SupersetClient half as the template.
// =============================================================================

describe('SupersetClient applies the application root exactly once', () => {
test('endpoint without leading slash is concatenated correctly under a non-empty appRoot', () => {
const client = new SupersetClientClass({
protocol: 'https:',
host: 'config_host',
appRoot: '/superset',
});
expect(client.getUrl({ endpoint: 'api/v1/chart' })).toBe(
'https://config_host/superset/api/v1/chart',
);
});

test('endpoint with leading slash is normalised to a single root segment', () => {
const client = new SupersetClientClass({
protocol: 'https:',
host: 'config_host',
appRoot: '/superset',
});
expect(client.getUrl({ endpoint: '/api/v1/chart' })).toBe(
'https://config_host/superset/api/v1/chart',
);
});

test('does not double-apply the application root when caller pre-prefixes', () => {
// This documents the bug class the helpers protect against. Pre-prefixing
// is forbidden by the channel rule, but we record the current behaviour
// here so anyone debugging a double-prefix issue lands on this assertion
// and reads the comment.
const client = new SupersetClientClass({
protocol: 'https:',
host: 'config_host',
appRoot: '/superset',
});
expect(client.getUrl({ endpoint: '/superset/api/v1/chart' })).toBe(
'https://config_host/superset/superset/api/v1/chart',
);
// ^ The duplicated `/superset` is exactly the symptom developers see when
// they wrap a SupersetClient endpoint in `ensureAppRoot`. The static
// invariant test in `navigationUtils.invariants.test.ts` catches that
// pattern before it reaches runtime.
});

test('empty application root produces no prefix segment', () => {
const client = new SupersetClientClass({
protocol: 'https:',
host: 'config_host',
});
expect(client.getUrl({ endpoint: '/api/v1/chart' })).toBe(
'https://config_host/api/v1/chart',
);
});
});
Loading
Loading