Skip to content

Commit

Permalink
[Extensions] ExtensionAdmin registerHost should accept hostnames and …
Browse files Browse the repository at this point in the history
…urls (#3631)

* ExtensionAdmin should accept hostnames and urls

* Remove the "_exists" method from the remote and extension service providers
* make iTwinId default to public on Service Extensions
* remove any cast and just log the error msg as is
* remove 'ftp' since its something we don't expect as input
* documentation fixes
* ServiceExtension props should not require a version - defaults to the latest
* attempt to parse the text response if body is null (happened during testing)

(cherry picked from commit 9b3ac9b)
  • Loading branch information
johnnyd710 authored and mergify[bot] committed Jun 14, 2022
1 parent 34d6e2e commit 1ccfddf
Show file tree
Hide file tree
Showing 7 changed files with 141 additions and 64 deletions.
4 changes: 2 additions & 2 deletions common/api/core-frontend.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -9583,9 +9583,9 @@ export class ServiceExtensionProvider implements ExtensionProvider {
export interface ServiceExtensionProviderProps {
// @internal (undocumented)
getAccessToken?: () => Promise<AccessToken>;
iTwinId: string;
iTwinId?: string;
name: string;
version: string;
version?: string;
}

// @public
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@itwin/core-frontend",
"comment": "",
"type": "none"
}
],
"packageName": "@itwin/core-frontend"
}
30 changes: 24 additions & 6 deletions core/frontend/src/extension/ExtensionAdmin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export class ExtensionAdmin {
if (manifest.activationEvents.includes("onStartup"))
provider.execute(); // eslint-disable-line @typescript-eslint/no-floating-promises
} catch (e) {
throw new Error(`Failed to get manifest from extension: ${e}`);
throw new Error(`Failed to get extension manifest ${provider.hostname ? `at ${provider.hostname}` : ""}: ${e}`);
}
}

Expand All @@ -96,13 +96,31 @@ export class ExtensionAdmin {
}

/**
* Register a url (hostname) for extension hosting (i.e. https://localhost:3000, https://www.yourdomain.com, etc.)
* @param hostUrl
* Registers a hostname for an extension.
* Once a hostname has been registered, only remote extensions from registered hosts are permitted to be added.
* @param hostUrl (string) Accepts both URLs and hostnames (e.g., http://localhost:3000, yourdomain.com, https://www.yourdomain.com, etc.).
*/
public registerHost(hostUrl: string) {
const url = new URL(hostUrl).hostname.replace("www", "");
if (this._hosts.indexOf(url) < 0) {
this._hosts.push(url);
const hostname = this.getHostName(hostUrl);
if (this._hosts.indexOf(hostname) < 0) {
this._hosts.push(hostname);
}
}

/** Returns the hostname of an input string. Throws an error if input is not a valid hostname (or URL). */
private getHostName(inputUrl: string): string {
// inputs without a protocol (e.g., http://) will throw an error in URL constructor
const inputWithProtocol = /(http|https):\/\//.test(inputUrl) ?
inputUrl :
`https://${inputUrl}`;
try {
const hostname = new URL(inputWithProtocol).hostname.replace("www.", "");
return hostname;
} catch (e) {
if (e instanceof TypeError) {
throw new Error("Argument hostUrl should be a valid URL or hostname (i.e. http://localhost:3000, yourdomain.com, etc.).");
}
throw e;
}
}

Expand Down
16 changes: 9 additions & 7 deletions core/frontend/src/extension/providers/ExtensionServiceClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* Copyright (c) Bentley Systems, Incorporated. All rights reserved.
* See LICENSE.md in the project root for license terms and full copyright notice.
*--------------------------------------------------------------------------------------------*/
import { AccessToken } from "@itwin/core-bentley";
import { AccessToken, Guid } from "@itwin/core-bentley";

import { request, RequestOptions } from "../../request/Request";

Expand Down Expand Up @@ -72,12 +72,13 @@ export class ExtensionClient {
}

/**
* Gets information on extensions. If extensionName is undefined, will return all extensions in the context.
* If it's defined, will return all versions of that extension.
* @param iTwinId Context Id
* Gets information on extensions. If extensionName is undefined, will return all extensions in the iTwin.
* If extensionName is defined, will return all versions of that extension.
* If iTwinId is undefined, will default to the public extensions.
* @param extensionName Extension name (optional)
* @param iTwinId iTwin Id (optional)
*/
public async getExtensions(accessToken: AccessToken, iTwinId: string, extensionName?: string): Promise<ExtensionMetadata[]> {
public async getExtensions(accessToken: AccessToken, extensionName?: string, iTwinId = Guid.empty): Promise<ExtensionMetadata[]> {
const options: RequestOptions = { method: "GET" };
options.headers = { authorization: accessToken };
const response = await request(`${this._endpoint}${iTwinId}/IModelExtension/${extensionName ?? ""}`, options);
Expand All @@ -92,11 +93,12 @@ export class ExtensionClient {

/**
* Gets information about an extension's specific version
* @param iTwinId iTwin Id
* If iTwinId is undefined, will assume the extension was published publicly.
* @param extensionName Extension name
* @param version Extension version
* @param iTwinId iTwin Id (optional)
*/
public async getExtensionMetadata(accessToken: AccessToken, iTwinId: string, extensionName: string, version: string): Promise<ExtensionMetadata | undefined> {
public async getExtensionMetadata(accessToken: AccessToken, extensionName: string, version: string, iTwinId = Guid.empty): Promise<ExtensionMetadata | undefined> {

const options: RequestOptions = { method: "GET" };
options.headers = { authorization: accessToken };
Expand Down
30 changes: 9 additions & 21 deletions core/frontend/src/extension/providers/RemoteExtensionProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
ExtensionManifest,
ExtensionProvider,
} from "../Extension";
import { request, RequestOptions } from "../../request/Request";
import { loadScript } from "./ExtensionLoadScript";

/**
Expand Down Expand Up @@ -38,10 +39,6 @@ export class RemoteExtensionProvider implements ExtensionProvider {
* Throws an error if the provided jsUrl is not accessible.
*/
public async execute(): Promise<string> {
const doesUrlExist = await this._exists(this._props.jsUrl);
if (!doesUrlExist) {
throw new Error(`Extension at ${this._props.jsUrl} could not be found.`);
}
return loadScript(this._props.jsUrl);
}

Expand All @@ -50,23 +47,14 @@ export class RemoteExtensionProvider implements ExtensionProvider {
* Throws an error if the provided manifestUrl is not accessible.
*/
public async getManifest(): Promise<ExtensionManifest> {
const doesUrlExist = await this._exists(this._props.manifestUrl);
if (!doesUrlExist) {
throw new Error(`Manifest at ${this._props.manifestUrl} could not be found.`);
}
return (await fetch(this._props.manifestUrl)).json();
const options: RequestOptions = { method: "GET" };
const response = await request(this._props.manifestUrl, options);
const data = response.body || (() => {
if (!response.text)
throw new Error("Manifest file was empty.");
return JSON.parse(response.text);
})();
return data;
}

/** Checks if url actually exists */
private async _exists(url: string): Promise<boolean> {
let exists = false;
try {
const response = await fetch(url, { method: "HEAD" });
if (response.status === 200)
exists = true;
} catch (error) {
exists = false;
}
return exists;
}
}
43 changes: 15 additions & 28 deletions core/frontend/src/extension/providers/ServiceExtensionProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import { rcompare } from "semver";

import { IModelApp } from "../../IModelApp";
import { request, RequestOptions } from "../../request/Request";
import { loadScript } from "./ExtensionLoadScript";
import { ExtensionClient, ExtensionMetadata } from "./ExtensionServiceClient";

Expand All @@ -20,10 +21,10 @@ import type { AccessToken } from "@itwin/core-bentley";
export interface ServiceExtensionProviderProps {
/** Name of the uploaded extension */
name: string;
/** Version number (Semantic Versioning) */
version: string;
/** iTwin Id */
iTwinId: string;
/** Version number (optional - if undefined, assumes the latest version) */
version?: string;
/** iTwin Id (optional - if undefined, assumes the extension is public) */
iTwinId?: string;
/** @internal */
getAccessToken?: () => Promise<AccessToken>;
}
Expand All @@ -46,11 +47,14 @@ export class ServiceExtensionProvider implements ExtensionProvider {
if (!loadedExtensionProps)
throw new Error(`Error loading manifest for Extension ${this._props.name}.`);

const doesUrlExist = await this._exists(loadedExtensionProps.manifest.url);
if (!doesUrlExist)
throw new Error(`Manifest at ${loadedExtensionProps.manifest.url} could not be found.`);

return (await fetch(loadedExtensionProps.manifest.url)).json();
const options: RequestOptions = { method: "GET" };
const response = await request(loadedExtensionProps.manifest.url, options);
const data = response.body || (() => {
if (!response.text)
throw new Error("Manifest file was empty.");
return JSON.parse(response.text);
})();
return data;
}

/** Executes the javascript main file (the bundled index.js) of an extension from the Extension Service.
Expand All @@ -61,26 +65,9 @@ export class ServiceExtensionProvider implements ExtensionProvider {
if (!loadedExtensionProps)
throw new Error(`Error executing Extension ${this._props.name}.`);

const doesUrlExist = await this._exists(loadedExtensionProps.main.url);
if (!doesUrlExist)
throw new Error(`Main javascript file at ${loadedExtensionProps.main.url} could not be found.`);

return loadScript(loadedExtensionProps.main.url);
}

/** Checks if url actually exists */
private async _exists(url: string): Promise<boolean> {
let exists = false;
try {
const response = await fetch(url, { method: "HEAD" });
if (response.status === 200)
exists = true;
} catch (error) {
exists = false;
}
return exists;
}

/** Fetches the extension from the ExtensionService.
*/
private async _getExtensionFiles(props: ServiceExtensionProviderProps) {
Expand All @@ -92,9 +79,9 @@ export class ServiceExtensionProvider implements ExtensionProvider {

let extensionProps: ExtensionMetadata | undefined;
if (props.version !== undefined)
extensionProps = await extensionClient.getExtensionMetadata(accessToken, props.iTwinId, props.name, props.version);
extensionProps = await extensionClient.getExtensionMetadata(accessToken, props.name, props.version, props.iTwinId);
else {
const propsArr = await extensionClient.getExtensions(accessToken, props.iTwinId, props.name);
const propsArr = await extensionClient.getExtensions(accessToken, props.name, props.iTwinId);
extensionProps = propsArr.sort((ext1, ext2) => rcompare(ext1.version, ext2.version, true))[0];
}

Expand Down
72 changes: 72 additions & 0 deletions core/frontend/src/test/ExtensionAdmin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Bentley Systems, Incorporated. All rights reserved.
* See LICENSE.md in the project root for license terms and full copyright notice.
*--------------------------------------------------------------------------------------------*/
import chai, { expect } from "chai";
import { ExtensionManifest, RemoteExtensionProvider } from "../core-frontend";
import { ExtensionAdmin } from "../extension/ExtensionAdmin";
import chaiAsPromised from "chai-as-promised";
import sinon from "sinon";

describe("ExtensionAdmin", () => {
const extensions = [
new RemoteExtensionProvider({
jsUrl: "http://localhost:3000/index.js",
manifestUrl: "http://localhost:3000/package.json",
}),
new RemoteExtensionProvider({
jsUrl: "https://somedomain:3001/index.js",
manifestUrl: "https://somedomain:3001/package.json",
}),
new RemoteExtensionProvider({
jsUrl: "https://anotherdomain.com/index.js",
manifestUrl: "https://anotherdomain.com/package.json",
}),
];
const stubManifest: Promise<ExtensionManifest> = new Promise((res) => res({
name: "mock-extension",
version: "1.0.0",
main: "index.js",
activationEvents: [],
}));

before(async () => {
chai.use(chaiAsPromised);
sinon.stub(RemoteExtensionProvider.prototype, "getManifest").returns(stubManifest);
});

it("ExtensionAdmin can register a url", async () => {
const extensionAdmin = new ExtensionAdmin();
extensionAdmin.registerHost(extensions[0].hostname);
extensionAdmin.registerHost("https://somedomain:3001");
extensionAdmin.registerHost("https://anotherdomain.com/dist/index.js");
for (const extension of extensions) {
await expect(extensionAdmin.addExtension(extension)).to.eventually.be.fulfilled;
}
});

it("ExtensionAdmin can register a hostname", async () => {
const extensionAdmin = new ExtensionAdmin();
extensionAdmin.registerHost(extensions[0].hostname);
extensionAdmin.registerHost("www.somedomain");
extensionAdmin.registerHost("anotherdomain.com");

for (const extension of extensions) {
await expect(extensionAdmin.addExtension(extension)).to.eventually.be.fulfilled;
}
});

it("ExtensionAdmin will fail if remote extension hostname was not registered", async () => {
const extensionAdmin = new ExtensionAdmin();
extensionAdmin.registerHost("aDifferentHostname");
for (const extension of extensions) {
await expect(extensionAdmin.addExtension(extension)).to.eventually.be.rejectedWith(/not registered/);
}
});

it("ExtensionAdmin will reject invalid URLs or hostnames", () => {
const extensionAdmin = new ExtensionAdmin();
expect(() => extensionAdmin.registerHost("3001:invalidUrl")).to.throw(/should be a valid URL or hostname/);
expect(() => extensionAdmin.registerHost("invalidUrl342!@#")).to.throw(/should be a valid URL or hostname/);
});
});

0 comments on commit 1ccfddf

Please sign in to comment.