Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
49 changes: 46 additions & 3 deletions src/api/Base.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,23 @@ class Base {
*/
apiHost: string;

/**
* Optional regional metadata host.
*
* When set and distinct from `apiHost`, subclasses (currently `Metadata`)
* route metadata *instance* endpoints to this host while keeping
* templates, taxonomies, suggestions, options, and queries on `apiHost`.
* Empty values, or values equal to `apiHost`, are treated as "not set"
* and resolve to the same URLs as `apiHost` alone.
*
* Transitional: this field exists to support the `metadataApiHost`
* option while the global `apiHost` is not yet regionalized end-to-end.
* It is expected to be retired in a future major version.
*
* @property {?string}
*/
metadataApiHost: ?string;

/**
* @property {string}
*/
Expand Down Expand Up @@ -90,15 +107,26 @@ class Base {
* @param {string} [options.sharedLink] - Shared link
* @param {string} [options.sharedLinkPassword] - Shared link password
* @param {string} [options.apiHost] - Api host
* @param {string} [options.metadataApiHost] - Regional metadata API host
* used for metadata *instance* endpoints. Templates, taxonomies,
* suggestions, options, and queries continue to use `apiHost`. Falls
* back to `apiHost` when undefined, empty, or equal to `apiHost`.
* @param {string} [options.uploadHost] - Upload host name
* @return {Base} Base instance
*/
constructor(options: APIOptions) {
this.cache = options.cache || new Cache();
this.apiHost = options.apiHost || DEFAULT_HOSTNAME_API;
this.metadataApiHost = options.metadataApiHost;
this.uploadHost = options.uploadHost || DEFAULT_HOSTNAME_UPLOAD;
// @TODO: avoid keeping another copy of data in this.options
this.options = { ...options, apiHost: this.apiHost, uploadHost: this.uploadHost, cache: this.cache };
this.options = {
...options,
apiHost: this.apiHost,
metadataApiHost: this.metadataApiHost,
uploadHost: this.uploadHost,
cache: this.cache,
};
this.xhr = new Xhr(this.options);
this.destroyed = false;
this.consoleLog = !!options.consoleLog && !!window.console ? window.console.log || noop : noop;
Expand Down Expand Up @@ -144,14 +172,29 @@ class Base {
}
}

/**
* Builds an API base URL for an arbitrary host, appending the API
* version suffix (`/2.0`) and tolerating a trailing slash on the host.
*
* Shared helper used by `getBaseApiUrl()` and by subclasses that need
* to derive a `/2.0` URL from a host other than `this.apiHost` (e.g.
* `Metadata` when `metadataApiHost` is configured).
*
* @param {string} host - api host (e.g. "https://api.box.com")
* @return {string} base api url with `/2.0` suffix
*/
buildApiUrl(host: string): string {
const suffix: string = host.endsWith('/') ? '2.0' : '/2.0';
return `${host}${suffix}`;
}

/**
* Base URL for api
*
* @return {string} base url
*/
getBaseApiUrl(): string {
const suffix: string = this.apiHost.endsWith('/') ? '2.0' : '/2.0';
return `${this.apiHost}${suffix}`;
return this.buildApiUrl(this.apiHost);
}

/**
Expand Down
49 changes: 41 additions & 8 deletions src/api/Metadata.js
Original file line number Diff line number Diff line change
Expand Up @@ -108,29 +108,62 @@ class Metadata extends File {
}

/**
* API URL for metadata
* Base URL used for metadata *instance* endpoints (file/folder
* `/metadata/...`).
*
* Routes through the regional metadata host when `metadataApiHost` is
* set and distinct from `apiHost`; otherwise returns the same value as
* `getBaseApiUrl()`, so callers without `metadataApiHost` configured
* resolve to the global API host.
*
* Other metadata endpoints (templates, taxonomies, suggestions, options,
* queries) intentionally use `getBaseApiUrl()` instead and are not
* affected by `metadataApiHost`.
*
* @return {string} base url for metadata instance endpoints
*/
getMetadataInstanceBaseUrl(): string {
const { metadataApiHost, apiHost } = this;
if (!metadataApiHost || metadataApiHost === apiHost) {
return this.getBaseApiUrl();
}
return this.buildApiUrl(metadataApiHost);
}

/**
* API URL for a file's metadata *instance* endpoints.
*
* Routes through the regional metadata host (`metadataApiHost`) when set
* and distinct from `apiHost`; otherwise routes through `apiHost`.
* Templates, taxonomies, suggestions, options, and queries are not
* affected by this builder and continue to use `apiHost`.
*
* @param {string} id - a Box file id
* @param {string} field - metadata field
* @return {string} base url for files
* @param {string} [scope] - metadata scope (e.g. enterprise_xxx)
* @param {string} [template] - metadata template key
* @return {string} URL for the file's metadata instance endpoint
*/
getMetadataUrl(id: string, scope?: string, template?: string): string {
const baseUrl = `${this.getUrl(id)}/metadata`;
const baseUrl = `${this.getMetadataInstanceBaseUrl()}/files/${id}/metadata`;
if (scope && template) {
return `${baseUrl}/${scope}/${template}`;
}
return baseUrl;
}

/**
* API URL for metadata
* API URL for a folder's metadata *instance* endpoints.
*
* Routes through the regional metadata host (`metadataApiHost`) when set
* and distinct from `apiHost`; otherwise routes through `apiHost`.
*
* @param {string} id - a Box folder id
* @param {string} field - metadata field
* @return {string} base url for files
* @param {string} [scope] - metadata scope
* @param {string} [template] - metadata template key
* @return {string} URL for the folder's metadata instance endpoint
*/
getMetadataUrlForFolder(id: string, scope?: string, template?: string): string {
const baseUrl = `${this.getBaseApiUrl()}/folders/${id}/metadata`;
const baseUrl = `${this.getMetadataInstanceBaseUrl()}/folders/${id}/metadata`;
if (scope && template) {
return `${baseUrl}/${scope}/${template}`;
}
Expand Down
104 changes: 104 additions & 0 deletions src/api/__tests__/Metadata.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,55 @@ describe('api/Metadata', () => {
'https://api.box.com/2.0/files/foo/metadata/scope/template',
);
});
test('should route through metadataApiHost when distinct from apiHost', () => {
const regional = new Metadata({ metadataApiHost: 'https://api-jp.box.com' });
expect(regional.getMetadataUrl('foo')).toBe('https://api-jp.box.com/2.0/files/foo/metadata');
expect(regional.getMetadataUrl('foo', 'enterprise_123', 'template')).toBe(
'https://api-jp.box.com/2.0/files/foo/metadata/enterprise_123/template',
);
});
test('should fall back to apiHost when metadataApiHost is empty', () => {
const empty = new Metadata({ metadataApiHost: '' });
expect(empty.getMetadataUrl('foo')).toBe('https://api.box.com/2.0/files/foo/metadata');
});
test('should fall back to apiHost when metadataApiHost equals apiHost', () => {
const equal = new Metadata({
apiHost: 'https://api.box.com',
metadataApiHost: 'https://api.box.com',
});
expect(equal.getMetadataUrl('foo')).toBe('https://api.box.com/2.0/files/foo/metadata');
});
test('should normalize a trailing slash on metadataApiHost', () => {
const trailing = new Metadata({ metadataApiHost: 'https://api-jp.box.com/' });
expect(trailing.getMetadataUrl('foo')).toBe('https://api-jp.box.com/2.0/files/foo/metadata');
});
});

describe('getMetadataUrlForFolder()', () => {
test('should return base api url for folder when no template or scope', () => {
expect(metadata.getMetadataUrlForFolder('123')).toBe('https://api.box.com/2.0/folders/123/metadata');
});
test('should return correct api url for folder with scope and template', () => {
expect(metadata.getMetadataUrlForFolder('123', 'scope', 'template')).toBe(
'https://api.box.com/2.0/folders/123/metadata/scope/template',
);
});
test('should route through metadataApiHost when distinct from apiHost', () => {
const regional = new Metadata({ metadataApiHost: 'https://api-jp.box.com' });
expect(regional.getMetadataUrlForFolder('123', 'scope', 'template')).toBe(
'https://api-jp.box.com/2.0/folders/123/metadata/scope/template',
);
});
});

describe('getMetadataTemplateUrl()', () => {
test('should return correct base api url', () => {
expect(metadata.getMetadataTemplateUrl('scope')).toBe('https://api.box.com/2.0/metadata_templates');
});
test('should keep using apiHost even when metadataApiHost is set', () => {
const regional = new Metadata({ metadataApiHost: 'https://api-jp.box.com' });
expect(regional.getMetadataTemplateUrl()).toBe('https://api.box.com/2.0/metadata_templates');
});
});

describe('getMetadataTemplateUrlForInstance()', () => {
Expand Down Expand Up @@ -4202,4 +4245,65 @@ describe('api/Metadata', () => {
expect(metadata.errorCode).toBe(ERROR_CODE_FETCH_METADATA_TAXONOMY_NODE);
});
});

describe('metadataApiHost routing', () => {
// Verifies that a regional metadataApiHost affects ONLY metadata
// *instance* endpoints (file/folder /metadata/...). Templates,
// taxonomies, suggestions, and options always continue to use
// apiHost.
const apiHost = 'https://api.box.com';
const metadataApiHost = 'https://api-jp.box.com';

test('routes file instance endpoints to regional host', () => {
const api = new Metadata({ apiHost, metadataApiHost });
expect(api.getMetadataUrl('1', 'enterprise_1', 'tpl')).toBe(
'https://api-jp.box.com/2.0/files/1/metadata/enterprise_1/tpl',
);
});

test('routes folder instance endpoints to regional host', () => {
const api = new Metadata({ apiHost, metadataApiHost });
expect(api.getMetadataUrlForFolder('99', 'enterprise_1', 'tpl')).toBe(
'https://api-jp.box.com/2.0/folders/99/metadata/enterprise_1/tpl',
);
});

test('keeps templates / suggestions / options / taxonomy on apiHost', () => {
const api = new Metadata({ apiHost, metadataApiHost });
expect(api.getMetadataTemplateUrl()).toBe('https://api.box.com/2.0/metadata_templates');
expect(api.getMetadataTemplateUrlForScope('enterprise')).toBe(
'https://api.box.com/2.0/metadata_templates/enterprise',
);
expect(api.getMetadataTemplateSchemaUrl('tpl')).toBe(
'https://api.box.com/2.0/metadata_templates/enterprise/tpl/schema',
);
expect(api.getMetadataSuggestionsUrl()).toBe('https://api.box.com/2.0/metadata_instances/suggestions');
expect(api.getMetadataOptionsUrl('enterprise', 'tpl', 'field')).toBe(
'https://api.box.com/2.0/metadata_templates/enterprise/tpl/fields/field/options',
);
expect(api.getMetadataTaxonomyUrl('enterprise', 'taxKey')).toBe(
'https://api.box.com/2.0/metadata_taxonomies/enterprise/taxKey',
);
expect(api.getMetadataTaxonomyNodeUrl('enterprise', 'taxKey', 'node')).toBe(
'https://api.box.com/2.0/metadata_taxonomies/enterprise/taxKey/nodes/node',
);
expect(api.getTaxonomyLevelsForTemplatesUrl('metadata_taxonomies/ns/key')).toBe(
'https://api.box.com/2.0/metadata_taxonomies/ns/key',
);
});

test('full fallback when metadataApiHost is undefined (byte-identical URLs)', () => {
const fallback = new Metadata({ apiHost });
const baseline = new Metadata({});
// Fallback construction must produce URLs identical to the baseline (no metadataApiHost).
expect(fallback.getMetadataUrl('1', 'enterprise_1', 'tpl')).toBe(
baseline.getMetadataUrl('1', 'enterprise_1', 'tpl'),
);
expect(fallback.getMetadataUrlForFolder('1', 'enterprise_1', 'tpl')).toBe(
baseline.getMetadataUrlForFolder('1', 'enterprise_1', 'tpl'),
);
expect(fallback.getMetadataTemplateUrl()).toBe(baseline.getMetadataTemplateUrl());
expect(fallback.getMetadataSuggestionsUrl()).toBe(baseline.getMetadataSuggestionsUrl());
});
});
});
18 changes: 18 additions & 0 deletions src/common/types/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,24 @@ type APIOptions = {
consoleLog?: boolean,
id?: string,
language?: string,
/**
* Optional regional metadata API host (e.g. "https://api-jp.box.com").
*
* Affects ONLY metadata *instance* endpoints (file/folder
* `/metadata/...`). Templates, taxonomies, suggestions, options, and
* metadata queries always use `apiHost` regardless of this value.
*
* Forward-compatible by design: when undefined, empty, or equal to
* `apiHost`, the resulting URLs are identical to those produced when
* the option is not set. Dropping the option later is therefore a
* no-op for any consumer.
*
* Transitional: this option lets host applications pin metadata-
* instance traffic to a regional Box gateway while the global API host
* is not yet regionalized end-to-end. It is expected to be retired in
* a future major version.
*/
metadataApiHost?: string,
requestInterceptor?: Function,
responseInterceptor?: Function,
retryableStatusCodes?: Array<number>,
Expand Down
24 changes: 24 additions & 0 deletions src/elements/content-preview/ContentPreview.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,26 @@ type Props = {
userId: number,
},
apiHost: string,
/**
* Optional regional metadata API host (e.g. "https://api-jp.box.com")
* forwarded to the metadata API client and to the embedded
* ContentSidebar.
*
* Affects ONLY metadata *instance* endpoints (file/folder
* `/metadata/...`). Templates, taxonomies, suggestions, options, and
* metadata queries always use `apiHost` regardless of this value.
*
* Forward-compatible by design: when undefined, empty, or equal to
* `apiHost`, the resulting URLs are identical to those produced when
* the prop is not set. Dropping the prop later is therefore a no-op
* for any consumer.
*
* Transitional: this prop lets host applications pin metadata-instance
* traffic to a regional Box gateway while the global API host is not
* yet regionalized end-to-end. It is expected to be retired in a
* future major version.
*/
metadataApiHost?: string,
appHost: string,
autoFocus: boolean,
boxAnnotations?: Object,
Expand Down Expand Up @@ -352,6 +372,7 @@ class ContentPreview extends React.PureComponent<Props, State> {
fileId,
language,
loadingIndicatorDelayMs,
metadataApiHost,
requestInterceptor,
responseInterceptor,
sharedLink,
Expand All @@ -366,6 +387,7 @@ class ContentPreview extends React.PureComponent<Props, State> {
cache,
clientName: CLIENT_NAME_CONTENT_PREVIEW,
language,
metadataApiHost,
requestInterceptor,
responseInterceptor,
sharedLink,
Expand Down Expand Up @@ -1549,6 +1571,7 @@ class ContentPreview extends React.PureComponent<Props, State> {
token,
language,
messages,
metadataApiHost,
className,
contentAnswersProps,
contentOpenWithProps,
Expand Down Expand Up @@ -1692,6 +1715,7 @@ class ContentPreview extends React.PureComponent<Props, State> {
<LoadableSidebar
{...mergedContentSidebarProps}
apiHost={apiHost}
metadataApiHost={metadataApiHost}
token={token}
cache={this.api.getCache()}
fileId={currentFileId}
Expand Down
23 changes: 23 additions & 0 deletions src/elements/content-preview/__tests__/ContentPreview.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,29 @@ describe('elements/content-preview/ContentPreview', () => {
});
});

describe('metadataApiHost prop', () => {
// When the host app passes a regional metadataApiHost,
// ContentPreview forwards it both into its API client and down to
// the sidebar, so the metadata API client constructed there can
// route instance endpoints through the regional host.
test('should forward metadataApiHost to the API client constructed in ContentPreview', () => {
const wrapper = getWrapper({
token: 'token',
fileId: '123',
apiHost: 'https://api.box.com',
metadataApiHost: 'https://api-jp.box.com',
});
const instance = wrapper.instance();
expect(instance.api.options.metadataApiHost).toBe('https://api-jp.box.com');
});

test('should leave metadataApiHost unset on API client when prop is omitted', () => {
const wrapper = getWrapper({ token: 'token', fileId: '123' });
const instance = wrapper.instance();
expect(instance.api.options.metadataApiHost).toBeUndefined();
});
});

describe('componentDidUpdate()', () => {
test('should not reload preview if component updates but we should not load preview', async () => {
file = { id: '123' };
Expand Down
1 change: 1 addition & 0 deletions src/elements/content-preview/stories/ContentPreview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ var ContentPreview = require('./ContentPreview').default;
| hasHeader | boolean | `true` | Visually hides the preview header if this is set to `false`. |
| language | string | `en-US` | *See the [Internationalization](/docs/elements-usage--docs#internationalization-i18n) section* |
| messages | Map&lt;string, string&gt; | | *See the [Internationalization](/docs/elements-usage--docs#internationalization-i18n) section* |
| metadataApiHost | string | | Optional regional metadata API host (e.g. `https://api-jp.box.com`) used only for metadata *instance* endpoints (file/folder `/metadata/...`). Templates, taxonomies, suggestions, options, and metadata queries always use `apiHost` regardless. **Transitional**: this prop is intended to support cases where metadata data is regionalized but the global API host is not yet regionalized end-to-end; it is expected to be retired in a future major version. When undefined or equal to `apiHost`, the resulting URLs are identical to those produced when the prop is not set, so dropping it later is a no-op. |
| onClose | function | | Callback function for when the file preview closes. If absent, the close button will not render in the header. |
| onLoad | function | | Callback function for when a file preview loads. |
| requestInterceptor | function | | *See the [developer docs](https://developer.box.com/docs/box-content-preview#section-options).* |
Expand Down
Loading
Loading