integrations: move common integration concerns to a separate package (#3295)
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/backend-common': patch
|
||||
'@backstage/integration': patch
|
||||
---
|
||||
|
||||
Added the integration package
|
||||
@@ -32,6 +32,7 @@
|
||||
"@backstage/cli-common": "^0.1.1",
|
||||
"@backstage/config": "^0.1.1",
|
||||
"@backstage/config-loader": "^0.2.0",
|
||||
"@backstage/integration": "^0.1.0",
|
||||
"@backstage/test-utils": "^0.1.2",
|
||||
"@types/cors": "^2.8.6",
|
||||
"@types/express": "^4.17.6",
|
||||
|
||||
@@ -14,49 +14,27 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
AzureIntegrationConfig,
|
||||
readAzureIntegrationConfigs,
|
||||
} from '@backstage/integration';
|
||||
import fetch from 'cross-fetch';
|
||||
import { Config } from '@backstage/config';
|
||||
import { NotFoundError } from '../errors';
|
||||
import { ReaderFactory, ReadTreeResponse, UrlReader } from './types';
|
||||
|
||||
type Options = {
|
||||
// TODO: added here for future support, but we only allow dev.azure.com for now
|
||||
host: string;
|
||||
token?: string;
|
||||
};
|
||||
|
||||
function readConfig(config: Config): Options[] {
|
||||
const optionsArr = Array<Options>();
|
||||
|
||||
const providerConfigs =
|
||||
config.getOptionalConfigArray('integrations.azure') ?? [];
|
||||
|
||||
for (const providerConfig of providerConfigs) {
|
||||
const host = providerConfig.getOptionalString('host') ?? 'dev.azure.com';
|
||||
const token = providerConfig.getOptionalString('token');
|
||||
|
||||
optionsArr.push({ host, token });
|
||||
}
|
||||
|
||||
// As a convenience we always make sure there's at least an unauthenticated
|
||||
// reader for public azure repos.
|
||||
if (!optionsArr.some(p => p.host === 'dev.azure.com')) {
|
||||
optionsArr.push({ host: 'dev.azure.com' });
|
||||
}
|
||||
|
||||
return optionsArr;
|
||||
}
|
||||
|
||||
export class AzureUrlReader implements UrlReader {
|
||||
static factory: ReaderFactory = ({ config }) => {
|
||||
return readConfig(config).map(options => {
|
||||
const configs = readAzureIntegrationConfigs(
|
||||
config.getOptionalConfigArray('integrations.azure') ?? [],
|
||||
);
|
||||
return configs.map(options => {
|
||||
const reader = new AzureUrlReader(options);
|
||||
const predicate = (url: URL) => url.host === options.host;
|
||||
return { reader, predicate };
|
||||
});
|
||||
};
|
||||
|
||||
constructor(private readonly options: Options) {
|
||||
constructor(private readonly options: AzureIntegrationConfig) {
|
||||
if (options.host !== 'dev.azure.com') {
|
||||
throw Error(
|
||||
`Azure integration currently only supports 'dev.azure.com', tried to use host '${options.host}'`,
|
||||
|
||||
@@ -14,24 +14,22 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { BitbucketIntegrationConfig } from '@backstage/integration';
|
||||
import {
|
||||
BitbucketUrlReader,
|
||||
getApiRequestOptions,
|
||||
getApiUrl,
|
||||
ProviderConfig,
|
||||
readConfig,
|
||||
} from './BitbucketUrlReader';
|
||||
|
||||
describe('BitbucketUrlReader', () => {
|
||||
describe('getApiRequestOptions', () => {
|
||||
it('inserts a token when needed', () => {
|
||||
const withToken: ProviderConfig = {
|
||||
const withToken: BitbucketIntegrationConfig = {
|
||||
host: '',
|
||||
apiBaseUrl: '',
|
||||
token: 'A',
|
||||
};
|
||||
const withoutToken: ProviderConfig = {
|
||||
const withoutToken: BitbucketIntegrationConfig = {
|
||||
host: '',
|
||||
apiBaseUrl: '',
|
||||
};
|
||||
@@ -44,13 +42,13 @@ describe('BitbucketUrlReader', () => {
|
||||
});
|
||||
|
||||
it('insert basic auth when needed', () => {
|
||||
const withUsernameAndPassword: ProviderConfig = {
|
||||
const withUsernameAndPassword: BitbucketIntegrationConfig = {
|
||||
host: '',
|
||||
apiBaseUrl: '',
|
||||
username: 'some-user',
|
||||
appPassword: 'my-secret',
|
||||
};
|
||||
const withoutUsernameAndPassword: ProviderConfig = {
|
||||
const withoutUsernameAndPassword: BitbucketIntegrationConfig = {
|
||||
host: '',
|
||||
apiBaseUrl: '',
|
||||
};
|
||||
@@ -67,11 +65,11 @@ describe('BitbucketUrlReader', () => {
|
||||
|
||||
describe('getApiUrl', () => {
|
||||
it('rejects targets that do not look like URLs', () => {
|
||||
const config: ProviderConfig = { host: '', apiBaseUrl: '' };
|
||||
const config: BitbucketIntegrationConfig = { host: '', apiBaseUrl: '' };
|
||||
expect(() => getApiUrl('a/b', config)).toThrow(/Incorrect URL: a\/b/);
|
||||
});
|
||||
it('happy path for Bitbucket Cloud', () => {
|
||||
const config: ProviderConfig = {
|
||||
const config: BitbucketIntegrationConfig = {
|
||||
host: 'bitbucket.org',
|
||||
apiBaseUrl: 'https://api.bitbucket.org/2.0',
|
||||
};
|
||||
@@ -87,7 +85,7 @@ describe('BitbucketUrlReader', () => {
|
||||
);
|
||||
});
|
||||
it('happy path for Bitbucket Server', () => {
|
||||
const config: ProviderConfig = {
|
||||
const config: BitbucketIntegrationConfig = {
|
||||
host: 'bitbucket.mycompany.net',
|
||||
apiBaseUrl: 'https://bitbucket.mycompany.net/rest/api/1.0',
|
||||
};
|
||||
@@ -104,66 +102,6 @@ describe('BitbucketUrlReader', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('readConfig', () => {
|
||||
function config(
|
||||
providers: {
|
||||
host: string;
|
||||
apiBaseUrl?: string;
|
||||
token?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
}[],
|
||||
) {
|
||||
return ConfigReader.fromConfigs([
|
||||
{
|
||||
context: '',
|
||||
data: {
|
||||
integrations: { bitbucket: providers },
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
it('adds a default Bitbucket Cloud entry when missing', () => {
|
||||
const output = readConfig(config([]));
|
||||
expect(output).toEqual([
|
||||
{
|
||||
host: 'bitbucket.org',
|
||||
apiBaseUrl: 'https://api.bitbucket.org/2.0',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('injects the correct Bitbucket Cloud API base URL when missing', () => {
|
||||
const output = readConfig(config([{ host: 'bitbucket.org' }]));
|
||||
expect(output).toEqual([
|
||||
{
|
||||
host: 'bitbucket.org',
|
||||
apiBaseUrl: 'https://api.bitbucket.org/2.0',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects custom targets with no base URLs', () => {
|
||||
expect(() =>
|
||||
readConfig(config([{ host: 'bitbucket.mycompany.net' }])),
|
||||
).toThrow(
|
||||
"Bitbucket integration for 'bitbucket.mycompany.net' must configure an explicit apiBaseUrl",
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects funky configs', () => {
|
||||
expect(() => readConfig(config([{ host: 7 } as any]))).toThrow(/host/);
|
||||
expect(() => readConfig(config([{ token: 7 } as any]))).toThrow(/token/);
|
||||
expect(() =>
|
||||
readConfig(config([{ host: 'bitbucket.org', apiBaseUrl: 7 } as any])),
|
||||
).toThrow(/apiBaseUrl/);
|
||||
expect(() =>
|
||||
readConfig(config([{ host: 'bitbucket.org', token: 7 } as any])),
|
||||
).toThrow(/token/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('implementation', () => {
|
||||
it('rejects unknown targets', async () => {
|
||||
const processor = new BitbucketUrlReader({
|
||||
|
||||
@@ -14,57 +14,18 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Config } from '@backstage/config';
|
||||
import parseGitUri from 'git-url-parse';
|
||||
import {
|
||||
BitbucketIntegrationConfig,
|
||||
readBitbucketIntegrationConfigs,
|
||||
} from '@backstage/integration';
|
||||
import fetch from 'cross-fetch';
|
||||
import parseGitUri from 'git-url-parse';
|
||||
import { NotFoundError } from '../errors';
|
||||
import { ReaderFactory, ReadTreeResponse, UrlReader } from './types';
|
||||
|
||||
const DEFAULT_BASE_URL = 'https://api.bitbucket.org/2.0';
|
||||
|
||||
/**
|
||||
* The configuration parameters for a single Bitbucket API provider.
|
||||
*/
|
||||
export type ProviderConfig = {
|
||||
/**
|
||||
* The host of the target that this matches on, e.g. "bitbucket.com"
|
||||
*/
|
||||
host: string;
|
||||
|
||||
/**
|
||||
* The base URL of the API of this provider, e.g. "https://api.bitbucket.org/2.0",
|
||||
* with no trailing slash.
|
||||
*
|
||||
* May be omitted specifically for Bitbucket Cloud; then it will be deduced.
|
||||
*
|
||||
* The API will always be preferred if both its base URL and a token are
|
||||
* present.
|
||||
*/
|
||||
apiBaseUrl?: string;
|
||||
|
||||
/**
|
||||
* The authorization token to use for requests to a Bitbucket Server provider.
|
||||
*
|
||||
* See https://confluence.atlassian.com/bitbucketserver/personal-access-tokens-939515499.html
|
||||
*
|
||||
* If no token is specified, anonymous access is used.
|
||||
*/
|
||||
token?: string;
|
||||
|
||||
/**
|
||||
* The username to use for requests to Bitbucket Cloud (bitbucket.org).
|
||||
*/
|
||||
username?: string;
|
||||
|
||||
/**
|
||||
* Authentication with Bitbucket Cloud (bitbucket.org) is done using app passwords.
|
||||
*
|
||||
* See https://support.atlassian.com/bitbucket-cloud/docs/app-passwords/
|
||||
*/
|
||||
appPassword?: string;
|
||||
};
|
||||
|
||||
export function getApiRequestOptions(provider: ProviderConfig): RequestInit {
|
||||
export function getApiRequestOptions(
|
||||
provider: BitbucketIntegrationConfig,
|
||||
): RequestInit {
|
||||
const headers: HeadersInit = {};
|
||||
|
||||
if (provider.token) {
|
||||
@@ -84,7 +45,10 @@ export function getApiRequestOptions(provider: ProviderConfig): RequestInit {
|
||||
// Converts for example
|
||||
// from: https://bitbucket.org/orgname/reponame/src/master/file.yaml
|
||||
// to: https://api.bitbucket.org/2.0/repositories/orgname/reponame/src/master/file.yaml
|
||||
export function getApiUrl(target: string, provider: ProviderConfig): URL {
|
||||
export function getApiUrl(
|
||||
target: string,
|
||||
provider: BitbucketIntegrationConfig,
|
||||
): URL {
|
||||
try {
|
||||
const { owner, name, ref, filepathtype, filepath } = parseGitUri(target);
|
||||
if (
|
||||
@@ -115,74 +79,39 @@ export function getApiUrl(target: string, provider: ProviderConfig): URL {
|
||||
}
|
||||
}
|
||||
|
||||
export function readConfig(config: Config): ProviderConfig[] {
|
||||
const providers: ProviderConfig[] = [];
|
||||
|
||||
const providerConfigs =
|
||||
config.getOptionalConfigArray('integrations.bitbucket') ?? [];
|
||||
|
||||
// First read all the explicit providers
|
||||
for (const providerConfig of providerConfigs) {
|
||||
const host = providerConfig.getOptionalString('host') ?? 'bitbucket.org';
|
||||
let apiBaseUrl = providerConfig.getOptionalString('apiBaseUrl');
|
||||
const token = providerConfig.getOptionalString('token');
|
||||
const username = providerConfig.getOptionalString('username');
|
||||
const appPassword = providerConfig.getOptionalString('appPassword');
|
||||
|
||||
if (apiBaseUrl) {
|
||||
apiBaseUrl = apiBaseUrl.replace(/\/+$/, '');
|
||||
} else if (host === 'bitbucket.org') {
|
||||
apiBaseUrl = DEFAULT_BASE_URL;
|
||||
}
|
||||
|
||||
if (!apiBaseUrl) {
|
||||
throw new Error(
|
||||
`Bitbucket integration for '${host}' must configure an explicit apiBaseUrl`,
|
||||
);
|
||||
}
|
||||
if (!token && username && !appPassword) {
|
||||
throw new Error(
|
||||
`Bitbucket integration for '${host}' has configured a username but is missing a required appPassword.`,
|
||||
);
|
||||
}
|
||||
|
||||
providers.push({
|
||||
host,
|
||||
apiBaseUrl,
|
||||
token,
|
||||
username,
|
||||
appPassword,
|
||||
});
|
||||
}
|
||||
|
||||
// If no explicit bitbucket.org provider was added, put one in the list as
|
||||
// a convenience
|
||||
if (!providers.some(p => p.host === 'bitbucket.org')) {
|
||||
providers.push({
|
||||
host: 'bitbucket.org',
|
||||
apiBaseUrl: DEFAULT_BASE_URL,
|
||||
});
|
||||
}
|
||||
|
||||
return providers;
|
||||
}
|
||||
|
||||
/**
|
||||
* A processor that adds the ability to read files from Bitbucket v1 and v2 APIs, such as
|
||||
* the one exposed by Bitbucket Cloud itself.
|
||||
*/
|
||||
export class BitbucketUrlReader implements UrlReader {
|
||||
private config: ProviderConfig;
|
||||
private readonly config: BitbucketIntegrationConfig;
|
||||
|
||||
static factory: ReaderFactory = ({ config }) => {
|
||||
return readConfig(config).map(provider => {
|
||||
const configs = readBitbucketIntegrationConfigs(
|
||||
config.getOptionalConfigArray('integrations.bitbucket') ?? [],
|
||||
);
|
||||
return configs.map(provider => {
|
||||
const reader = new BitbucketUrlReader(provider);
|
||||
const predicate = (url: URL) => url.host === provider.host;
|
||||
return { reader, predicate };
|
||||
});
|
||||
};
|
||||
|
||||
constructor(config: ProviderConfig) {
|
||||
constructor(config: BitbucketIntegrationConfig) {
|
||||
const { host, apiBaseUrl, token, username, appPassword } = config;
|
||||
|
||||
if (!apiBaseUrl) {
|
||||
throw new Error(
|
||||
`Bitbucket integration for '${host}' must configure an explicit apiBaseUrl`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!token && username && !appPassword) {
|
||||
throw new Error(
|
||||
`Bitbucket integration for '${host}' has configured a username but is missing a required appPassword.`,
|
||||
);
|
||||
}
|
||||
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,20 +15,19 @@
|
||||
*/
|
||||
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { GitHubIntegrationConfig } from '@backstage/integration';
|
||||
import { msw } from '@backstage/test-utils';
|
||||
import fs from 'fs';
|
||||
import { rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import path from 'path';
|
||||
import {
|
||||
getApiRequestOptions,
|
||||
getApiUrl,
|
||||
getRawRequestOptions,
|
||||
getRawUrl,
|
||||
GithubUrlReader,
|
||||
ProviderConfig,
|
||||
readConfig,
|
||||
} from './GithubUrlReader';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { ReadTreeResponseFactory } from './tree';
|
||||
|
||||
const treeResponseFactory = ReadTreeResponseFactory.create({
|
||||
@@ -38,19 +37,19 @@ const treeResponseFactory = ReadTreeResponseFactory.create({
|
||||
describe('GithubUrlReader', () => {
|
||||
describe('getApiRequestOptions', () => {
|
||||
it('sets the correct API version', () => {
|
||||
const config: ProviderConfig = { host: '', apiBaseUrl: '' };
|
||||
const config: GitHubIntegrationConfig = { host: '', apiBaseUrl: '' };
|
||||
expect((getApiRequestOptions(config).headers as any).Accept).toEqual(
|
||||
'application/vnd.github.v3.raw',
|
||||
);
|
||||
});
|
||||
|
||||
it('inserts a token when needed', () => {
|
||||
const withToken: ProviderConfig = {
|
||||
const withToken: GitHubIntegrationConfig = {
|
||||
host: '',
|
||||
apiBaseUrl: '',
|
||||
token: 'A',
|
||||
};
|
||||
const withoutToken: ProviderConfig = {
|
||||
const withoutToken: GitHubIntegrationConfig = {
|
||||
host: '',
|
||||
apiBaseUrl: '',
|
||||
};
|
||||
@@ -65,12 +64,12 @@ describe('GithubUrlReader', () => {
|
||||
|
||||
describe('getRawRequestOptions', () => {
|
||||
it('inserts a token when needed', () => {
|
||||
const withToken: ProviderConfig = {
|
||||
const withToken: GitHubIntegrationConfig = {
|
||||
host: '',
|
||||
rawBaseUrl: '',
|
||||
token: 'A',
|
||||
};
|
||||
const withoutToken: ProviderConfig = {
|
||||
const withoutToken: GitHubIntegrationConfig = {
|
||||
host: '',
|
||||
rawBaseUrl: '',
|
||||
};
|
||||
@@ -85,12 +84,12 @@ describe('GithubUrlReader', () => {
|
||||
|
||||
describe('getApiUrl', () => {
|
||||
it('rejects targets that do not look like URLs', () => {
|
||||
const config: ProviderConfig = { host: '', apiBaseUrl: '' };
|
||||
const config: GitHubIntegrationConfig = { host: '', apiBaseUrl: '' };
|
||||
expect(() => getApiUrl('a/b', config)).toThrow(/Incorrect URL: a\/b/);
|
||||
});
|
||||
|
||||
it('happy path for github', () => {
|
||||
const config: ProviderConfig = {
|
||||
const config: GitHubIntegrationConfig = {
|
||||
host: 'github.com',
|
||||
apiBaseUrl: 'https://api.github.com',
|
||||
};
|
||||
@@ -117,7 +116,7 @@ describe('GithubUrlReader', () => {
|
||||
});
|
||||
|
||||
it('happy path for ghe', () => {
|
||||
const config: ProviderConfig = {
|
||||
const config: GitHubIntegrationConfig = {
|
||||
host: 'ghe.mycompany.net',
|
||||
apiBaseUrl: 'https://ghe.mycompany.net/api/v3',
|
||||
};
|
||||
@@ -136,12 +135,12 @@ describe('GithubUrlReader', () => {
|
||||
|
||||
describe('getRawUrl', () => {
|
||||
it('rejects targets that do not look like URLs', () => {
|
||||
const config: ProviderConfig = { host: '', apiBaseUrl: '' };
|
||||
const config: GitHubIntegrationConfig = { host: '', apiBaseUrl: '' };
|
||||
expect(() => getRawUrl('a/b', config)).toThrow(/Incorrect URL: a\/b/);
|
||||
});
|
||||
|
||||
it('happy path for github', () => {
|
||||
const config: ProviderConfig = {
|
||||
const config: GitHubIntegrationConfig = {
|
||||
host: 'github.com',
|
||||
rawBaseUrl: 'https://raw.githubusercontent.com',
|
||||
};
|
||||
@@ -158,7 +157,7 @@ describe('GithubUrlReader', () => {
|
||||
});
|
||||
|
||||
it('happy path for ghe', () => {
|
||||
const config: ProviderConfig = {
|
||||
const config: GitHubIntegrationConfig = {
|
||||
host: 'ghe.mycompany.net',
|
||||
rawBaseUrl: 'https://ghe.mycompany.net/raw',
|
||||
};
|
||||
@@ -173,60 +172,6 @@ describe('GithubUrlReader', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('readConfig', () => {
|
||||
function config(
|
||||
providers: { host: string; apiBaseUrl?: string; token?: string }[],
|
||||
) {
|
||||
return ConfigReader.fromConfigs([
|
||||
{
|
||||
context: '',
|
||||
data: {
|
||||
integrations: { github: providers },
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
it('adds a default GitHub entry when missing', () => {
|
||||
const output = readConfig(config([]));
|
||||
expect(output).toEqual([
|
||||
{
|
||||
host: 'github.com',
|
||||
apiBaseUrl: 'https://api.github.com',
|
||||
rawBaseUrl: 'https://raw.githubusercontent.com',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('injects the correct GitHub API base URL when missing', () => {
|
||||
const output = readConfig(config([{ host: 'github.com' }]));
|
||||
expect(output).toEqual([
|
||||
{
|
||||
host: 'github.com',
|
||||
apiBaseUrl: 'https://api.github.com',
|
||||
rawBaseUrl: 'https://raw.githubusercontent.com',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects custom targets with no base URLs', () => {
|
||||
expect(() => readConfig(config([{ host: 'ghe.company.com' }]))).toThrow(
|
||||
"GitHub integration for 'ghe.company.com' must configure an explicit apiBaseUrl and rawBaseUrl",
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects funky configs', () => {
|
||||
expect(() => readConfig(config([{ host: 7 } as any]))).toThrow(/host/);
|
||||
expect(() => readConfig(config([{ token: 7 } as any]))).toThrow(/token/);
|
||||
expect(() =>
|
||||
readConfig(config([{ host: 'github.com', apiBaseUrl: 7 } as any])),
|
||||
).toThrow(/apiBaseUrl/);
|
||||
expect(() =>
|
||||
readConfig(config([{ host: 'github.com', token: 7 } as any])),
|
||||
).toThrow(/token/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('implementation', () => {
|
||||
it('rejects unknown targets', async () => {
|
||||
const processor = new GithubUrlReader(
|
||||
@@ -271,6 +216,7 @@ describe('GithubUrlReader', () => {
|
||||
const processor = new GithubUrlReader(
|
||||
{
|
||||
host: 'github.com',
|
||||
apiBaseUrl: 'https://api.github.com',
|
||||
},
|
||||
{ treeResponseFactory },
|
||||
);
|
||||
@@ -293,6 +239,7 @@ describe('GithubUrlReader', () => {
|
||||
const processor = new GithubUrlReader(
|
||||
{
|
||||
host: 'github.com',
|
||||
apiBaseUrl: 'https://api.github.com',
|
||||
},
|
||||
{ treeResponseFactory },
|
||||
);
|
||||
@@ -308,6 +255,7 @@ describe('GithubUrlReader', () => {
|
||||
const processor = new GithubUrlReader(
|
||||
{
|
||||
host: 'github.com',
|
||||
apiBaseUrl: 'https://api.github.com',
|
||||
},
|
||||
{ treeResponseFactory },
|
||||
);
|
||||
|
||||
@@ -14,59 +14,25 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Config } from '@backstage/config';
|
||||
import parseGitUri from 'git-url-parse';
|
||||
import {
|
||||
GitHubIntegrationConfig,
|
||||
readGitHubIntegrationConfigs,
|
||||
} from '@backstage/integration';
|
||||
import fetch from 'cross-fetch';
|
||||
import parseGitUri from 'git-url-parse';
|
||||
import { Readable } from 'stream';
|
||||
import { InputError, NotFoundError } from '../errors';
|
||||
import { ReadTreeResponseFactory } from './tree';
|
||||
import {
|
||||
ReaderFactory,
|
||||
ReadTreeOptions,
|
||||
ReadTreeResponse,
|
||||
UrlReader,
|
||||
ReadTreeOptions,
|
||||
} from './types';
|
||||
import { ReadTreeResponseFactory } from './tree';
|
||||
|
||||
/**
|
||||
* The configuration parameters for a single GitHub API provider.
|
||||
*/
|
||||
export type ProviderConfig = {
|
||||
/**
|
||||
* The host of the target that this matches on, e.g. "github.com"
|
||||
*/
|
||||
host: string;
|
||||
|
||||
/**
|
||||
* The base URL of the API of this provider, e.g. "https://api.github.com",
|
||||
* with no trailing slash.
|
||||
*
|
||||
* May be omitted specifically for GitHub; then it will be deduced.
|
||||
*
|
||||
* The API will always be preferred if both its base URL and a token are
|
||||
* present.
|
||||
*/
|
||||
apiBaseUrl?: string;
|
||||
|
||||
/**
|
||||
* The base URL of the raw fetch endpoint of this provider, e.g.
|
||||
* "https://raw.githubusercontent.com", with no trailing slash.
|
||||
*
|
||||
* May be omitted specifically for GitHub; then it will be deduced.
|
||||
*
|
||||
* The API will always be preferred if both its base URL and a token are
|
||||
* present.
|
||||
*/
|
||||
rawBaseUrl?: string;
|
||||
|
||||
/**
|
||||
* The authorization token to use for requests to this provider.
|
||||
*
|
||||
* If no token is specified, anonymous access is used.
|
||||
*/
|
||||
token?: string;
|
||||
};
|
||||
|
||||
export function getApiRequestOptions(provider: ProviderConfig): RequestInit {
|
||||
export function getApiRequestOptions(
|
||||
provider: GitHubIntegrationConfig,
|
||||
): RequestInit {
|
||||
const headers: HeadersInit = {
|
||||
Accept: 'application/vnd.github.v3.raw',
|
||||
};
|
||||
@@ -80,7 +46,9 @@ export function getApiRequestOptions(provider: ProviderConfig): RequestInit {
|
||||
};
|
||||
}
|
||||
|
||||
export function getRawRequestOptions(provider: ProviderConfig): RequestInit {
|
||||
export function getRawRequestOptions(
|
||||
provider: GitHubIntegrationConfig,
|
||||
): RequestInit {
|
||||
const headers: HeadersInit = {};
|
||||
|
||||
if (provider.token) {
|
||||
@@ -95,7 +63,10 @@ export function getRawRequestOptions(provider: ProviderConfig): RequestInit {
|
||||
// Converts for example
|
||||
// from: https://github.com/a/b/blob/branchname/path/to/c.yaml
|
||||
// to: https://api.github.com/repos/a/b/contents/path/to/c.yaml?ref=branchname
|
||||
export function getApiUrl(target: string, provider: ProviderConfig): URL {
|
||||
export function getApiUrl(
|
||||
target: string,
|
||||
provider: GitHubIntegrationConfig,
|
||||
): URL {
|
||||
try {
|
||||
const { owner, name, ref, filepathtype, filepath } = parseGitUri(target);
|
||||
|
||||
@@ -120,7 +91,10 @@ export function getApiUrl(target: string, provider: ProviderConfig): URL {
|
||||
// Converts for example
|
||||
// from: https://github.com/a/b/blob/branchname/c.yaml
|
||||
// to: https://raw.githubusercontent.com/a/b/branchname/c.yaml
|
||||
export function getRawUrl(target: string, provider: ProviderConfig): URL {
|
||||
export function getRawUrl(
|
||||
target: string,
|
||||
provider: GitHubIntegrationConfig,
|
||||
): URL {
|
||||
try {
|
||||
const { owner, name, ref, filepathtype, filepath } = parseGitUri(target);
|
||||
|
||||
@@ -142,60 +116,16 @@ export function getRawUrl(target: string, provider: ProviderConfig): URL {
|
||||
}
|
||||
}
|
||||
|
||||
export function readConfig(config: Config): ProviderConfig[] {
|
||||
const providers: ProviderConfig[] = [];
|
||||
|
||||
const providerConfigs =
|
||||
config.getOptionalConfigArray('integrations.github') ?? [];
|
||||
|
||||
// First read all the explicit providers
|
||||
for (const providerConfig of providerConfigs) {
|
||||
const host = providerConfig.getOptionalString('host') ?? 'github.com';
|
||||
let apiBaseUrl = providerConfig.getOptionalString('apiBaseUrl');
|
||||
let rawBaseUrl = providerConfig.getOptionalString('rawBaseUrl');
|
||||
const token = providerConfig.getOptionalString('token');
|
||||
|
||||
if (apiBaseUrl) {
|
||||
apiBaseUrl = apiBaseUrl.replace(/\/+$/, '');
|
||||
} else if (host === 'github.com') {
|
||||
apiBaseUrl = 'https://api.github.com';
|
||||
}
|
||||
|
||||
if (rawBaseUrl) {
|
||||
rawBaseUrl = rawBaseUrl.replace(/\/+$/, '');
|
||||
} else if (host === 'github.com') {
|
||||
rawBaseUrl = 'https://raw.githubusercontent.com';
|
||||
}
|
||||
|
||||
if (!apiBaseUrl && !rawBaseUrl) {
|
||||
throw new Error(
|
||||
`GitHub integration for '${host}' must configure an explicit apiBaseUrl and rawBaseUrl`,
|
||||
);
|
||||
}
|
||||
|
||||
providers.push({ host, apiBaseUrl, rawBaseUrl, token });
|
||||
}
|
||||
|
||||
// If no explicit github.com provider was added, put one in the list as
|
||||
// a convenience
|
||||
if (!providers.some(p => p.host === 'github.com')) {
|
||||
providers.push({
|
||||
host: 'github.com',
|
||||
apiBaseUrl: 'https://api.github.com',
|
||||
rawBaseUrl: 'https://raw.githubusercontent.com',
|
||||
});
|
||||
}
|
||||
|
||||
return providers;
|
||||
}
|
||||
|
||||
/**
|
||||
* A processor that adds the ability to read files from GitHub v3 APIs, such as
|
||||
* the one exposed by GitHub itself.
|
||||
*/
|
||||
export class GithubUrlReader implements UrlReader {
|
||||
static factory: ReaderFactory = ({ config, treeResponseFactory }) => {
|
||||
return readConfig(config).map(provider => {
|
||||
const configs = readGitHubIntegrationConfigs(
|
||||
config.getOptionalConfigArray('integrations.github') ?? [],
|
||||
);
|
||||
return configs.map(provider => {
|
||||
const reader = new GithubUrlReader(provider, { treeResponseFactory });
|
||||
const predicate = (url: URL) => url.host === provider.host;
|
||||
return { reader, predicate };
|
||||
@@ -203,9 +133,15 @@ export class GithubUrlReader implements UrlReader {
|
||||
};
|
||||
|
||||
constructor(
|
||||
private readonly config: ProviderConfig,
|
||||
private readonly config: GitHubIntegrationConfig,
|
||||
private readonly deps: { treeResponseFactory: ReadTreeResponseFactory },
|
||||
) {}
|
||||
) {
|
||||
if (!config.apiBaseUrl && !config.rawBaseUrl) {
|
||||
throw new Error(
|
||||
`GitHub integration for '${config.host}' must configure an explicit apiBaseUrl and rawBaseUrl`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async read(url: string): Promise<Buffer> {
|
||||
const useApi =
|
||||
|
||||
@@ -14,48 +14,27 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
GitLabIntegrationConfig,
|
||||
readGitLabIntegrationConfigs,
|
||||
} from '@backstage/integration';
|
||||
import fetch from 'cross-fetch';
|
||||
import { Config } from '@backstage/config';
|
||||
import { NotFoundError } from '../errors';
|
||||
import { ReaderFactory, ReadTreeResponse, UrlReader } from './types';
|
||||
|
||||
type Options = {
|
||||
host: string;
|
||||
token?: string;
|
||||
};
|
||||
|
||||
function readConfig(config: Config): Options[] {
|
||||
const optionsArr = Array<Options>();
|
||||
|
||||
const providerConfigs =
|
||||
config.getOptionalConfigArray('integrations.gitlab') ?? [];
|
||||
|
||||
for (const providerConfig of providerConfigs) {
|
||||
const host = providerConfig.getOptionalString('host') ?? 'gitlab.com';
|
||||
const token = providerConfig.getOptionalString('token');
|
||||
|
||||
optionsArr.push({ host, token });
|
||||
}
|
||||
|
||||
// As a convenience we always make sure there's at least an unauthenticated
|
||||
// reader for public gitlab repos.
|
||||
if (!optionsArr.some(p => p.host === 'gitlab.com')) {
|
||||
optionsArr.push({ host: 'gitlab.com' });
|
||||
}
|
||||
|
||||
return optionsArr;
|
||||
}
|
||||
|
||||
export class GitlabUrlReader implements UrlReader {
|
||||
static factory: ReaderFactory = ({ config }) => {
|
||||
return readConfig(config).map(options => {
|
||||
const configs = readGitLabIntegrationConfigs(
|
||||
config.getOptionalConfigArray('integrations.gitlab') ?? [],
|
||||
);
|
||||
return configs.map(options => {
|
||||
const reader = new GitlabUrlReader(options);
|
||||
const predicate = (url: URL) => url.host === options.host;
|
||||
return { reader, predicate };
|
||||
});
|
||||
};
|
||||
|
||||
constructor(private readonly options: Options) {}
|
||||
constructor(private readonly options: GitLabIntegrationConfig) {}
|
||||
|
||||
async read(url: string): Promise<Buffer> {
|
||||
// TODO(Rugvip): merged the old GitlabReaderProcessor in here and used
|
||||
@@ -133,9 +112,9 @@ export class GitlabUrlReader implements UrlReader {
|
||||
try {
|
||||
const url = new URL(target);
|
||||
|
||||
const branchAndfilePath = url.pathname.split('/-/blob/')[1];
|
||||
const branchAndFilePath = url.pathname.split('/-/blob/')[1];
|
||||
|
||||
const [branch, ...filePath] = branchAndfilePath.split('/');
|
||||
const [branch, ...filePath] = branchAndFilePath.split('/');
|
||||
|
||||
url.pathname = [
|
||||
'/api/v4/projects',
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
extends: [require.resolve('@backstage/cli/config/eslint')],
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
# Integrations common functionality
|
||||
|
||||
Contains some common functionality of integrations.
|
||||
|
||||
This package will be imported both by the frontend and backend.
|
||||
|
||||
## Links
|
||||
|
||||
- [The Backstage homepage](https://backstage.io)
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "@backstage/integration",
|
||||
"version": "0.1.0",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
"private": false,
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"main": "dist/index.cjs.js",
|
||||
"module": "dist/index.esm.js",
|
||||
"types": "dist/index.d.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "backstage-cli build",
|
||||
"lint": "backstage-cli lint",
|
||||
"test": "backstage-cli test",
|
||||
"prepack": "backstage-cli prepack",
|
||||
"postpack": "backstage-cli postpack",
|
||||
"clean": "backstage-cli clean"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/config": "^0.1.1",
|
||||
"git-url-parse": "^11.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.2.0",
|
||||
"@types/jest": "^26.0.7"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed 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 { Config, ConfigReader } from '@backstage/config';
|
||||
import {
|
||||
AzureIntegrationConfig,
|
||||
readAzureIntegrationConfig,
|
||||
readAzureIntegrationConfigs,
|
||||
} from './config';
|
||||
|
||||
describe('readAzureIntegrationConfig', () => {
|
||||
function buildConfig(data: Partial<AzureIntegrationConfig>): Config {
|
||||
return ConfigReader.fromConfigs([{ context: '', data }]);
|
||||
}
|
||||
|
||||
it('reads all values', () => {
|
||||
const output = readAzureIntegrationConfig(
|
||||
buildConfig({
|
||||
host: 'a.com',
|
||||
token: 't',
|
||||
}),
|
||||
);
|
||||
expect(output).toEqual({
|
||||
host: 'a.com',
|
||||
token: 't',
|
||||
});
|
||||
});
|
||||
|
||||
it('inserts the defaults if missing', () => {
|
||||
const output = readAzureIntegrationConfig(buildConfig({}));
|
||||
expect(output).toEqual({ host: 'dev.azure.com' });
|
||||
});
|
||||
|
||||
it('rejects funky configs', () => {
|
||||
const valid: any = {
|
||||
host: 'a.com',
|
||||
token: 't',
|
||||
};
|
||||
expect(() =>
|
||||
readAzureIntegrationConfig(buildConfig({ ...valid, host: 7 })),
|
||||
).toThrow(/host/);
|
||||
expect(() =>
|
||||
readAzureIntegrationConfig(buildConfig({ ...valid, token: 7 })),
|
||||
).toThrow(/token/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readAzureIntegrationConfigs', () => {
|
||||
function buildConfig(data: Partial<AzureIntegrationConfig>[]): Config[] {
|
||||
return data.map(item =>
|
||||
ConfigReader.fromConfigs([{ context: '', data: item }]),
|
||||
);
|
||||
}
|
||||
|
||||
it('reads all values', () => {
|
||||
const output = readAzureIntegrationConfigs(
|
||||
buildConfig([
|
||||
{
|
||||
host: 'a.com',
|
||||
token: 't',
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(output).toContainEqual({
|
||||
host: 'a.com',
|
||||
token: 't',
|
||||
});
|
||||
});
|
||||
|
||||
it('adds a default entry when missing', () => {
|
||||
const output = readAzureIntegrationConfigs(buildConfig([]));
|
||||
expect(output).toEqual([
|
||||
{
|
||||
host: 'dev.azure.com',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed 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 { Config } from '@backstage/config';
|
||||
|
||||
const AZURE_HOST = 'dev.azure.com';
|
||||
|
||||
/**
|
||||
* The configuration parameters for a single Azure provider.
|
||||
*/
|
||||
export type AzureIntegrationConfig = {
|
||||
/**
|
||||
* The host of the target that this matches on, e.g. "dev.azure.com".
|
||||
*
|
||||
* Currently only "dev.azure.com" is supported.
|
||||
*/
|
||||
host: string;
|
||||
|
||||
/**
|
||||
* The authorization token to use for requests.
|
||||
*
|
||||
* If no token is specified, anonymous access is used.
|
||||
*/
|
||||
token?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads a single Azure integration config.
|
||||
*
|
||||
* @param config The config object of a single integration
|
||||
*/
|
||||
export function readAzureIntegrationConfig(
|
||||
config: Config,
|
||||
): AzureIntegrationConfig {
|
||||
const host = config.getOptionalString('host') ?? AZURE_HOST;
|
||||
const token = config.getOptionalString('token');
|
||||
return { host, token };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a set of Azure integration configs, and inserts some defaults for
|
||||
* public Azure if not specified.
|
||||
*
|
||||
* @param configs All of the integration config objects
|
||||
*/
|
||||
export function readAzureIntegrationConfigs(
|
||||
configs: Config[],
|
||||
): AzureIntegrationConfig[] {
|
||||
// First read all the explicit integrations
|
||||
const result = configs.map(readAzureIntegrationConfig);
|
||||
|
||||
// If no explicit dev.azure.com integration was added, put one in the list as
|
||||
// a convenience
|
||||
if (!result.some(c => c.host === AZURE_HOST)) {
|
||||
result.push({ host: AZURE_HOST });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed 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.
|
||||
*/
|
||||
|
||||
export {
|
||||
readAzureIntegrationConfig,
|
||||
readAzureIntegrationConfigs,
|
||||
} from './config';
|
||||
export type { AzureIntegrationConfig } from './config';
|
||||
@@ -0,0 +1,133 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed 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 { Config, ConfigReader } from '@backstage/config';
|
||||
import {
|
||||
BitbucketIntegrationConfig,
|
||||
readBitbucketIntegrationConfig,
|
||||
readBitbucketIntegrationConfigs,
|
||||
} from './config';
|
||||
|
||||
describe('readBitbucketIntegrationConfig', () => {
|
||||
function buildConfig(data: Partial<BitbucketIntegrationConfig>): Config {
|
||||
return ConfigReader.fromConfigs([{ context: '', data }]);
|
||||
}
|
||||
|
||||
it('reads all values', () => {
|
||||
const output = readBitbucketIntegrationConfig(
|
||||
buildConfig({
|
||||
host: 'a.com',
|
||||
apiBaseUrl: 'https://a.com/api',
|
||||
token: 't',
|
||||
username: 'u',
|
||||
appPassword: 'p',
|
||||
}),
|
||||
);
|
||||
expect(output).toEqual({
|
||||
host: 'a.com',
|
||||
apiBaseUrl: 'https://a.com/api',
|
||||
token: 't',
|
||||
username: 'u',
|
||||
appPassword: 'p',
|
||||
});
|
||||
});
|
||||
|
||||
it('inserts the defaults if missing', () => {
|
||||
const output = readBitbucketIntegrationConfig(buildConfig({}));
|
||||
expect(output).toEqual(
|
||||
expect.objectContaining({
|
||||
host: 'bitbucket.org',
|
||||
apiBaseUrl: 'https://api.bitbucket.org/2.0',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects funky configs', () => {
|
||||
const valid: any = {
|
||||
host: 'a.com',
|
||||
apiBaseUrl: 'https://a.com/api',
|
||||
token: 't',
|
||||
username: 'u',
|
||||
appPassword: 'p',
|
||||
};
|
||||
expect(() =>
|
||||
readBitbucketIntegrationConfig(buildConfig({ ...valid, host: 7 })),
|
||||
).toThrow(/host/);
|
||||
expect(() =>
|
||||
readBitbucketIntegrationConfig(buildConfig({ ...valid, apiBaseUrl: 7 })),
|
||||
).toThrow(/apiBaseUrl/);
|
||||
expect(() =>
|
||||
readBitbucketIntegrationConfig(buildConfig({ ...valid, token: 7 })),
|
||||
).toThrow(/token/);
|
||||
expect(() =>
|
||||
readBitbucketIntegrationConfig(buildConfig({ ...valid, username: 7 })),
|
||||
).toThrow(/username/);
|
||||
expect(() =>
|
||||
readBitbucketIntegrationConfig(buildConfig({ ...valid, appPassword: 7 })),
|
||||
).toThrow(/appPassword/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readBitbucketIntegrationConfigs', () => {
|
||||
function buildConfig(data: Partial<BitbucketIntegrationConfig>[]): Config[] {
|
||||
return data.map(item =>
|
||||
ConfigReader.fromConfigs([{ context: '', data: item }]),
|
||||
);
|
||||
}
|
||||
|
||||
it('reads all values', () => {
|
||||
const output = readBitbucketIntegrationConfigs(
|
||||
buildConfig([
|
||||
{
|
||||
host: 'a.com',
|
||||
apiBaseUrl: 'https://a.com/api',
|
||||
token: 't',
|
||||
username: 'u',
|
||||
appPassword: 'p',
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(output).toContainEqual({
|
||||
host: 'a.com',
|
||||
apiBaseUrl: 'https://a.com/api',
|
||||
token: 't',
|
||||
username: 'u',
|
||||
appPassword: 'p',
|
||||
});
|
||||
});
|
||||
|
||||
it('adds a default Bitbucket Cloud entry when missing', () => {
|
||||
const output = readBitbucketIntegrationConfigs(buildConfig([]));
|
||||
expect(output).toEqual([
|
||||
{
|
||||
host: 'bitbucket.org',
|
||||
apiBaseUrl: 'https://api.bitbucket.org/2.0',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('injects the correct Bitbucket Cloud API base URL when missing', () => {
|
||||
const output = readBitbucketIntegrationConfigs(
|
||||
buildConfig([{ host: 'bitbucket.org' }]),
|
||||
);
|
||||
expect(output).toEqual([
|
||||
{
|
||||
host: 'bitbucket.org',
|
||||
apiBaseUrl: 'https://api.bitbucket.org/2.0',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed 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 { Config } from '@backstage/config';
|
||||
|
||||
const BITBUCKET_HOST = 'bitbucket.org';
|
||||
const BITBUCKET_API_BASE_URL = 'https://api.bitbucket.org/2.0';
|
||||
|
||||
/**
|
||||
* The configuration parameters for a single Bitbucket API provider.
|
||||
*/
|
||||
export type BitbucketIntegrationConfig = {
|
||||
/**
|
||||
* The host of the target that this matches on, e.g. "bitbucket.org"
|
||||
*/
|
||||
host: string;
|
||||
|
||||
/**
|
||||
* The base URL of the API of this provider, e.g. "https://api.bitbucket.org/2.0",
|
||||
* with no trailing slash.
|
||||
*
|
||||
* May be omitted specifically for Bitbucket Cloud; then it will be deduced.
|
||||
*
|
||||
* The API will always be preferred if both its base URL and a token are
|
||||
* present.
|
||||
*/
|
||||
apiBaseUrl?: string;
|
||||
|
||||
/**
|
||||
* The authorization token to use for requests to a Bitbucket Server provider.
|
||||
*
|
||||
* See https://confluence.atlassian.com/bitbucketserver/personal-access-tokens-939515499.html
|
||||
*
|
||||
* If no token is specified, anonymous access is used.
|
||||
*/
|
||||
token?: string;
|
||||
|
||||
/**
|
||||
* The username to use for requests to Bitbucket Cloud (bitbucket.org).
|
||||
*/
|
||||
username?: string;
|
||||
|
||||
/**
|
||||
* Authentication with Bitbucket Cloud (bitbucket.org) is done using app passwords.
|
||||
*
|
||||
* See https://support.atlassian.com/bitbucket-cloud/docs/app-passwords/
|
||||
*/
|
||||
appPassword?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads a single Bitbucket integration config.
|
||||
*
|
||||
* @param config The config object of a single integration
|
||||
*/
|
||||
export function readBitbucketIntegrationConfig(
|
||||
config: Config,
|
||||
): BitbucketIntegrationConfig {
|
||||
const host = config.getOptionalString('host') ?? BITBUCKET_HOST;
|
||||
let apiBaseUrl = config.getOptionalString('apiBaseUrl');
|
||||
const token = config.getOptionalString('token');
|
||||
const username = config.getOptionalString('username');
|
||||
const appPassword = config.getOptionalString('appPassword');
|
||||
|
||||
if (apiBaseUrl) {
|
||||
apiBaseUrl = apiBaseUrl.replace(/\/+$/, '');
|
||||
} else if (host === BITBUCKET_HOST) {
|
||||
apiBaseUrl = BITBUCKET_API_BASE_URL;
|
||||
}
|
||||
|
||||
return {
|
||||
host,
|
||||
apiBaseUrl,
|
||||
token,
|
||||
username,
|
||||
appPassword,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a set of Bitbucket integration configs, and inserts some defaults for
|
||||
* public Bitbucket if not specified.
|
||||
*
|
||||
* @param configs All of the integration config objects
|
||||
*/
|
||||
export function readBitbucketIntegrationConfigs(
|
||||
configs: Config[],
|
||||
): BitbucketIntegrationConfig[] {
|
||||
// First read all the explicit integrations
|
||||
const result = configs.map(readBitbucketIntegrationConfig);
|
||||
|
||||
// If no explicit bitbucket.org integration was added, put one in the list as
|
||||
// a convenience
|
||||
if (!result.some(c => c.host === BITBUCKET_HOST)) {
|
||||
result.push({
|
||||
host: BITBUCKET_HOST,
|
||||
apiBaseUrl: BITBUCKET_API_BASE_URL,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed 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.
|
||||
*/
|
||||
|
||||
export {
|
||||
readBitbucketIntegrationConfig,
|
||||
readBitbucketIntegrationConfigs,
|
||||
} from './config';
|
||||
export type { BitbucketIntegrationConfig } from './config';
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed 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 { Config, ConfigReader } from '@backstage/config';
|
||||
import {
|
||||
GitHubIntegrationConfig,
|
||||
readGitHubIntegrationConfig,
|
||||
readGitHubIntegrationConfigs,
|
||||
} from './config';
|
||||
|
||||
describe('readGitHubIntegrationConfig', () => {
|
||||
function buildConfig(provider: Partial<GitHubIntegrationConfig>) {
|
||||
return ConfigReader.fromConfigs([{ context: '', data: provider }]);
|
||||
}
|
||||
|
||||
it('reads all values', () => {
|
||||
const output = readGitHubIntegrationConfig(
|
||||
buildConfig({
|
||||
host: 'a.com',
|
||||
apiBaseUrl: 'https://a.com/api',
|
||||
rawBaseUrl: 'https://a.com/raw',
|
||||
token: 't',
|
||||
}),
|
||||
);
|
||||
expect(output).toEqual({
|
||||
host: 'a.com',
|
||||
apiBaseUrl: 'https://a.com/api',
|
||||
rawBaseUrl: 'https://a.com/raw',
|
||||
token: 't',
|
||||
});
|
||||
});
|
||||
|
||||
it('injects the correct GitHub API base URL when missing', () => {
|
||||
const output = readGitHubIntegrationConfig(
|
||||
buildConfig({ host: 'github.com' }),
|
||||
);
|
||||
expect(output).toEqual({
|
||||
host: 'github.com',
|
||||
apiBaseUrl: 'https://api.github.com',
|
||||
rawBaseUrl: 'https://raw.githubusercontent.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects funky configs', () => {
|
||||
const valid: any = {
|
||||
host: 'a.com',
|
||||
apiBaseUrl: 'https://a.com/api',
|
||||
rawBaseUrl: 'https://a.com/raw',
|
||||
token: 't',
|
||||
};
|
||||
expect(() =>
|
||||
readGitHubIntegrationConfig(buildConfig({ ...valid, host: 7 })),
|
||||
).toThrow(/host/);
|
||||
expect(() =>
|
||||
readGitHubIntegrationConfig(buildConfig({ ...valid, apiBaseUrl: 7 })),
|
||||
).toThrow(/apiBaseUrl/);
|
||||
expect(() =>
|
||||
readGitHubIntegrationConfig(buildConfig({ ...valid, rawBaseUrl: 7 })),
|
||||
).toThrow(/rawBaseUrl/);
|
||||
expect(() =>
|
||||
readGitHubIntegrationConfig(buildConfig({ ...valid, token: 7 })),
|
||||
).toThrow(/token/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readGitHubIntegrationConfigs', () => {
|
||||
function buildConfig(
|
||||
providers: Partial<GitHubIntegrationConfig>[],
|
||||
): Config[] {
|
||||
return providers.map(provider =>
|
||||
ConfigReader.fromConfigs([{ context: '', data: provider }]),
|
||||
);
|
||||
}
|
||||
|
||||
it('reads all values', () => {
|
||||
const output = readGitHubIntegrationConfigs(
|
||||
buildConfig([
|
||||
{
|
||||
host: 'a.com',
|
||||
apiBaseUrl: 'https://a.com/api',
|
||||
rawBaseUrl: 'https://a.com/raw',
|
||||
token: 't',
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(output).toContainEqual({
|
||||
host: 'a.com',
|
||||
apiBaseUrl: 'https://a.com/api',
|
||||
rawBaseUrl: 'https://a.com/raw',
|
||||
token: 't',
|
||||
});
|
||||
});
|
||||
|
||||
it('adds a default GitHub entry when missing', () => {
|
||||
const output = readGitHubIntegrationConfigs(buildConfig([]));
|
||||
expect(output).toEqual([
|
||||
{
|
||||
host: 'github.com',
|
||||
apiBaseUrl: 'https://api.github.com',
|
||||
rawBaseUrl: 'https://raw.githubusercontent.com',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed 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 { Config } from '@backstage/config';
|
||||
|
||||
const GITHUB_HOST = 'github.com';
|
||||
const GITHUB_API_BASE_URL = 'https://api.github.com';
|
||||
const GITHUB_RAW_BASE_URL = 'https://raw.githubusercontent.com';
|
||||
|
||||
/**
|
||||
* The configuration parameters for a single GitHub integration.
|
||||
*/
|
||||
export type GitHubIntegrationConfig = {
|
||||
/**
|
||||
* The host of the target that this matches on, e.g. "github.com"
|
||||
*/
|
||||
host: string;
|
||||
|
||||
/**
|
||||
* The base URL of the API of this provider, e.g. "https://api.github.com",
|
||||
* with no trailing slash.
|
||||
*
|
||||
* May be omitted specifically for GitHub; then it will be deduced.
|
||||
*
|
||||
* The API will always be preferred if both its base URL and a token are
|
||||
* present.
|
||||
*/
|
||||
apiBaseUrl?: string;
|
||||
|
||||
/**
|
||||
* The base URL of the raw fetch endpoint of this provider, e.g.
|
||||
* "https://raw.githubusercontent.com", with no trailing slash.
|
||||
*
|
||||
* May be omitted specifically for GitHub; then it will be deduced.
|
||||
*
|
||||
* The API will always be preferred if both its base URL and a token are
|
||||
* present.
|
||||
*/
|
||||
rawBaseUrl?: string;
|
||||
|
||||
/**
|
||||
* The authorization token to use for requests to this provider.
|
||||
*
|
||||
* If no token is specified, anonymous access is used.
|
||||
*/
|
||||
token?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads a single GitHub integration config.
|
||||
*
|
||||
* @param config The config object of a single integration
|
||||
*/
|
||||
export function readGitHubIntegrationConfig(
|
||||
config: Config,
|
||||
): GitHubIntegrationConfig {
|
||||
const host = config.getOptionalString('host') ?? GITHUB_HOST;
|
||||
let apiBaseUrl = config.getOptionalString('apiBaseUrl');
|
||||
let rawBaseUrl = config.getOptionalString('rawBaseUrl');
|
||||
const token = config.getOptionalString('token');
|
||||
|
||||
if (apiBaseUrl) {
|
||||
apiBaseUrl = apiBaseUrl.replace(/\/+$/, '');
|
||||
} else if (host === GITHUB_HOST) {
|
||||
apiBaseUrl = GITHUB_API_BASE_URL;
|
||||
}
|
||||
|
||||
if (rawBaseUrl) {
|
||||
rawBaseUrl = rawBaseUrl.replace(/\/+$/, '');
|
||||
} else if (host === GITHUB_HOST) {
|
||||
rawBaseUrl = GITHUB_RAW_BASE_URL;
|
||||
}
|
||||
|
||||
return { host, apiBaseUrl, rawBaseUrl, token };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a set of GitHub integration configs, and inserts some defaults for
|
||||
* public GitHub if not specified.
|
||||
*
|
||||
* @param configs All of the integration config objects
|
||||
*/
|
||||
export function readGitHubIntegrationConfigs(
|
||||
configs: Config[],
|
||||
): GitHubIntegrationConfig[] {
|
||||
// First read all the explicit integrations
|
||||
const result = configs.map(readGitHubIntegrationConfig);
|
||||
|
||||
// If no explicit github.com integration was added, put one in the list as
|
||||
// a convenience
|
||||
if (!result.some(c => c.host === GITHUB_HOST)) {
|
||||
result.push({
|
||||
host: GITHUB_HOST,
|
||||
apiBaseUrl: GITHUB_API_BASE_URL,
|
||||
rawBaseUrl: GITHUB_RAW_BASE_URL,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed 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.
|
||||
*/
|
||||
|
||||
export {
|
||||
readGitHubIntegrationConfig,
|
||||
readGitHubIntegrationConfigs,
|
||||
} from './config';
|
||||
export type { GitHubIntegrationConfig } from './config';
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed 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 { Config, ConfigReader } from '@backstage/config';
|
||||
import {
|
||||
GitLabIntegrationConfig,
|
||||
readGitLabIntegrationConfig,
|
||||
readGitLabIntegrationConfigs,
|
||||
} from './config';
|
||||
|
||||
describe('readGitLabIntegrationConfig', () => {
|
||||
function buildConfig(data: Partial<GitLabIntegrationConfig>): Config {
|
||||
return ConfigReader.fromConfigs([{ context: '', data }]);
|
||||
}
|
||||
|
||||
it('reads all values', () => {
|
||||
const output = readGitLabIntegrationConfig(
|
||||
buildConfig({
|
||||
host: 'a.com',
|
||||
token: 't',
|
||||
}),
|
||||
);
|
||||
expect(output).toEqual({
|
||||
host: 'a.com',
|
||||
token: 't',
|
||||
});
|
||||
});
|
||||
|
||||
it('inserts the defaults if missing', () => {
|
||||
const output = readGitLabIntegrationConfig(buildConfig({}));
|
||||
expect(output).toEqual({ host: 'gitlab.com' });
|
||||
});
|
||||
|
||||
it('rejects funky configs', () => {
|
||||
const valid: any = {
|
||||
host: 'a.com',
|
||||
token: 't',
|
||||
};
|
||||
expect(() =>
|
||||
readGitLabIntegrationConfig(buildConfig({ ...valid, host: 7 })),
|
||||
).toThrow(/host/);
|
||||
expect(() =>
|
||||
readGitLabIntegrationConfig(buildConfig({ ...valid, token: 7 })),
|
||||
).toThrow(/token/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readGitLabIntegrationConfigs', () => {
|
||||
function buildConfig(data: Partial<GitLabIntegrationConfig>[]): Config[] {
|
||||
return data.map(item =>
|
||||
ConfigReader.fromConfigs([{ context: '', data: item }]),
|
||||
);
|
||||
}
|
||||
|
||||
it('reads all values', () => {
|
||||
const output = readGitLabIntegrationConfigs(
|
||||
buildConfig([
|
||||
{
|
||||
host: 'a.com',
|
||||
token: 't',
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(output).toContainEqual({
|
||||
host: 'a.com',
|
||||
token: 't',
|
||||
});
|
||||
});
|
||||
|
||||
it('adds a default entry when missing', () => {
|
||||
const output = readGitLabIntegrationConfigs(buildConfig([]));
|
||||
expect(output).toEqual([
|
||||
{
|
||||
host: 'gitlab.com',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed 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 { Config } from '@backstage/config';
|
||||
|
||||
const GITLAB_HOST = 'gitlab.com';
|
||||
|
||||
/**
|
||||
* The configuration parameters for a single GitLab integration.
|
||||
*/
|
||||
export type GitLabIntegrationConfig = {
|
||||
/**
|
||||
* The host of the target that this matches on, e.g. "gitlab.com"
|
||||
*/
|
||||
host: string;
|
||||
|
||||
/**
|
||||
* The authorization token to use for requests this provider.
|
||||
*
|
||||
* If no token is specified, anonymous access is used.
|
||||
*/
|
||||
token?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads a single GitLab integration config.
|
||||
*
|
||||
* @param config The config object of a single integration
|
||||
*/
|
||||
export function readGitLabIntegrationConfig(
|
||||
config: Config,
|
||||
): GitLabIntegrationConfig {
|
||||
const host = config.getOptionalString('host') ?? GITLAB_HOST;
|
||||
const token = config.getOptionalString('token');
|
||||
return { host, token };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a set of GitLab integration configs, and inserts some defaults for
|
||||
* public GitLab if not specified.
|
||||
*
|
||||
* @param configs All of the integration config objects
|
||||
*/
|
||||
export function readGitLabIntegrationConfigs(
|
||||
configs: Config[],
|
||||
): GitLabIntegrationConfig[] {
|
||||
// First read all the explicit integrations
|
||||
const result = configs.map(readGitLabIntegrationConfig);
|
||||
|
||||
// As a convenience we always make sure there's at least an unauthenticated
|
||||
// reader for public gitlab repos.
|
||||
if (!result.some(c => c.host === GITLAB_HOST)) {
|
||||
result.push({ host: GITLAB_HOST });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed 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.
|
||||
*/
|
||||
|
||||
export {
|
||||
readGitLabIntegrationConfig,
|
||||
readGitLabIntegrationConfigs,
|
||||
} from './config';
|
||||
export type { GitLabIntegrationConfig } from './config';
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed 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.
|
||||
*/
|
||||
|
||||
export * from './azure';
|
||||
export * from './bitbucket';
|
||||
export * from './github';
|
||||
export * from './gitlab';
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed 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.
|
||||
*/
|
||||
|
||||
export {};
|
||||
Reference in New Issue
Block a user