Add support for reading files from Bitbucket Server.
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-common': minor
|
||||
---
|
||||
|
||||
Add the ability to import components from Bitbucket Server to the service catalog
|
||||
@@ -41,7 +41,7 @@
|
||||
"express": "^4.17.1",
|
||||
"express-prom-bundle": "^6.1.0",
|
||||
"express-promise-router": "^3.0.3",
|
||||
"git-url-parse": "^11.3.0",
|
||||
"git-url-parse": "^11.4.0",
|
||||
"helmet": "^4.0.0",
|
||||
"knex": "^0.21.1",
|
||||
"lodash": "^4.17.15",
|
||||
|
||||
@@ -14,139 +14,253 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { getVoidLogger } from '../logging';
|
||||
import { BitbucketUrlReader } from './BitbucketUrlReader';
|
||||
import { msw } from '@backstage/test-utils';
|
||||
|
||||
const logger = getVoidLogger();
|
||||
import {
|
||||
BitbucketUrlReader,
|
||||
getApiRequestOptions,
|
||||
getApiUrl,
|
||||
getRawRequestOptions,
|
||||
getRawUrl,
|
||||
ProviderConfig,
|
||||
readConfig,
|
||||
} from './BitbucketUrlReader';
|
||||
|
||||
describe('BitbucketUrlReader', () => {
|
||||
const worker = setupServer();
|
||||
describe('getApiRequestOptions', () => {
|
||||
it('inserts a token when needed', () => {
|
||||
const withToken: ProviderConfig = {
|
||||
host: '',
|
||||
apiBaseUrl: '',
|
||||
token: 'A',
|
||||
};
|
||||
const withoutToken: ProviderConfig = {
|
||||
host: '',
|
||||
apiBaseUrl: '',
|
||||
};
|
||||
expect(
|
||||
(getApiRequestOptions(withToken).headers as any).Authorization,
|
||||
).toEqual('Bearer A');
|
||||
expect(
|
||||
(getApiRequestOptions(withoutToken).headers as any).Authorization,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
msw.setupDefaultHandlers(worker);
|
||||
it('insert basic auth when needed', () => {
|
||||
const withUsernameAndPassword: ProviderConfig = {
|
||||
host: '',
|
||||
apiBaseUrl: '',
|
||||
username: 'some-user',
|
||||
appPassword: 'my-secret',
|
||||
};
|
||||
const withoutUsernameAndPassword: ProviderConfig = {
|
||||
host: '',
|
||||
apiBaseUrl: '',
|
||||
};
|
||||
expect(
|
||||
(getApiRequestOptions(withUsernameAndPassword).headers as any)
|
||||
.Authorization,
|
||||
).toEqual('Basic c29tZS11c2VyOm15LXNlY3JldA==');
|
||||
expect(
|
||||
(getApiRequestOptions(withoutUsernameAndPassword).headers as any)
|
||||
.Authorization,
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
worker.use(
|
||||
rest.get('*', (req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
url: req.url.toString(),
|
||||
headers: req.headers.getAllHeaders(),
|
||||
}),
|
||||
describe('getRawRequestOptions', () => {
|
||||
it('inserts a token when needed', () => {
|
||||
const withToken: ProviderConfig = {
|
||||
host: '',
|
||||
apiBaseUrl: '',
|
||||
token: 'A',
|
||||
};
|
||||
const withoutToken: ProviderConfig = {
|
||||
host: '',
|
||||
apiBaseUrl: '',
|
||||
};
|
||||
expect(
|
||||
(getRawRequestOptions(withToken).headers as any).Authorization,
|
||||
).toEqual('Bearer A');
|
||||
expect(
|
||||
(getRawRequestOptions(withoutToken).headers as any).Authorization,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('insert basic auth when needed', () => {
|
||||
const withUsernameAndPassword: ProviderConfig = {
|
||||
host: '',
|
||||
apiBaseUrl: '',
|
||||
username: 'some-user',
|
||||
appPassword: 'my-secret',
|
||||
};
|
||||
const withoutUsernameAndPassword: ProviderConfig = {
|
||||
host: '',
|
||||
apiBaseUrl: '',
|
||||
};
|
||||
expect(
|
||||
(getRawRequestOptions(withUsernameAndPassword).headers as any)
|
||||
.Authorization,
|
||||
).toEqual('Basic c29tZS11c2VyOm15LXNlY3JldA==');
|
||||
expect(
|
||||
(getRawRequestOptions(withoutUsernameAndPassword).headers as any)
|
||||
.Authorization,
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getApiUrl', () => {
|
||||
it('rejects targets that do not look like URLs', () => {
|
||||
const config: ProviderConfig = { host: '', apiBaseUrl: '' };
|
||||
expect(() => getApiUrl('a/b', config)).toThrow(/Incorrect URL: a\/b/);
|
||||
});
|
||||
it('happy path for Bitbucket Cloud', () => {
|
||||
const config: ProviderConfig = {
|
||||
host: 'bitbucket.org',
|
||||
apiBaseUrl: 'https://api.bitbucket.org/2.0',
|
||||
};
|
||||
expect(
|
||||
getApiUrl(
|
||||
'https://bitbucket.org/org-name/repo-name/src/master/templates/my-template.yaml',
|
||||
config,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
const createConfig = (username?: string, appPassword?: string) =>
|
||||
new ConfigReader(
|
||||
{
|
||||
integrations: {
|
||||
bitbucket: [
|
||||
{
|
||||
host: 'bitbucket.org',
|
||||
username: username,
|
||||
appPassword: appPassword,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
'test-config',
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
url:
|
||||
'https://bitbucket.org/org-name/repo-name/src/master/templates/my-template.yaml',
|
||||
config: createConfig(),
|
||||
response: expect.objectContaining({
|
||||
url:
|
||||
).toEqual(
|
||||
new URL(
|
||||
'https://api.bitbucket.org/2.0/repositories/org-name/repo-name/src/master/templates/my-template.yaml',
|
||||
}),
|
||||
},
|
||||
{
|
||||
url:
|
||||
'https://bitbucket.org/org-name/repo-name/src/master/templates/my-template.yaml',
|
||||
config: createConfig('some-user', 'my-secret'),
|
||||
response: expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
authorization: 'Basic c29tZS11c2VyOm15LXNlY3JldA==',
|
||||
}),
|
||||
}),
|
||||
},
|
||||
{
|
||||
url:
|
||||
'https://bitbucket.org/org-name/repo-name/src/master/templates/my-template.yaml',
|
||||
config: createConfig(),
|
||||
response: expect.objectContaining({
|
||||
headers: expect.not.objectContaining({
|
||||
authorization: expect.anything(),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
{
|
||||
url:
|
||||
'https://bitbucket.org/org-name/repo-name/src/master/templates/my-template.yaml',
|
||||
config: createConfig(undefined, 'only-password-provided'),
|
||||
response: expect.objectContaining({
|
||||
headers: expect.not.objectContaining({
|
||||
authorization: expect.anything(),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
])('should handle happy path %#', async ({ url, config, response }) => {
|
||||
const [{ reader }] = BitbucketUrlReader.factory({ config, logger });
|
||||
|
||||
const data = await reader.read(url);
|
||||
const res = await JSON.parse(data.toString('utf-8'));
|
||||
expect(res).toEqual(response);
|
||||
),
|
||||
);
|
||||
});
|
||||
it('happy path for Bitbucket Server', () => {
|
||||
const config: ProviderConfig = {
|
||||
host: 'bitbucket.mycompany.net',
|
||||
apiBaseUrl: 'https://bitbucket.mycompany.net/rest/api/1.0',
|
||||
};
|
||||
expect(
|
||||
getApiUrl(
|
||||
'https://bitbucket.mycompany.net/projects/a/repos/b/browse/path/to/c.yaml',
|
||||
config,
|
||||
),
|
||||
).toEqual(
|
||||
new URL(
|
||||
'https://bitbucket.mycompany.net/rest/api/1.0/projects/a/repos/b/raw/path/to/c.yaml',
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
url: 'https://api.com/a/b/blob/master/path/to/c.yaml',
|
||||
config: createConfig(),
|
||||
error:
|
||||
'Incorrect url: https://api.com/a/b/blob/master/path/to/c.yaml, Error: Wrong Bitbucket URL or Invalid file path',
|
||||
},
|
||||
{
|
||||
url: 'com/a/b/blob/master/path/to/c.yaml',
|
||||
config: createConfig(),
|
||||
error:
|
||||
'Incorrect url: com/a/b/blob/master/path/to/c.yaml, TypeError: Invalid URL: com/a/b/blob/master/path/to/c.yaml',
|
||||
},
|
||||
{
|
||||
url: '',
|
||||
config: createConfig('', ''),
|
||||
error:
|
||||
"Invalid type in config for key 'integrations.bitbucket[0].username' in 'test-config', got empty-string, wanted string",
|
||||
},
|
||||
{
|
||||
url: '',
|
||||
config: createConfig('only-user-provided', ''),
|
||||
error:
|
||||
"Invalid type in config for key 'integrations.bitbucket[0].appPassword' in 'test-config', got empty-string, wanted string",
|
||||
},
|
||||
{
|
||||
url: '',
|
||||
config: createConfig('', 'only-password-provided'),
|
||||
error:
|
||||
"Invalid type in config for key 'integrations.bitbucket[0].username' in 'test-config', got empty-string, wanted string",
|
||||
},
|
||||
{
|
||||
url: '',
|
||||
config: createConfig('only-user-provided', undefined),
|
||||
error:
|
||||
"Missing required config value at 'integrations.bitbucket[0].appPassword'",
|
||||
},
|
||||
])('should handle error path %#', async ({ url, config, error }) => {
|
||||
await expect(async () => {
|
||||
const [{ reader }] = BitbucketUrlReader.factory({ config, logger });
|
||||
await reader.read(url);
|
||||
}).rejects.toThrow(error);
|
||||
describe('getRawUrl', () => {
|
||||
it('rejects targets that do not look like URLs', () => {
|
||||
const config: ProviderConfig = { host: '', apiBaseUrl: '' };
|
||||
expect(() => getRawUrl('a/b', config)).toThrow(/Incorrect URL: a\/b/);
|
||||
});
|
||||
|
||||
it('happy path for Bitbucket Cloud', () => {
|
||||
const config: ProviderConfig = {
|
||||
host: 'bitbucket.org',
|
||||
rawBaseUrl: 'https://api.bitbucket.org/2.0',
|
||||
};
|
||||
expect(
|
||||
getRawUrl(
|
||||
'https://bitbucket.org/org-name/repo-name/src/master/templates/my-template.yaml',
|
||||
config,
|
||||
),
|
||||
).toEqual(
|
||||
new URL(
|
||||
'https://api.bitbucket.org/2.0/repositories/org-name/repo-name/src/master/templates/my-template.yaml',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('happy path for Bitbucket Server', () => {
|
||||
const config: ProviderConfig = {
|
||||
host: 'bitbucket.mycompany.net',
|
||||
rawBaseUrl: 'https://api.bitbucket.org/2.0',
|
||||
};
|
||||
expect(
|
||||
getRawUrl(
|
||||
'https://bitbucket.mycompany.net/projects/a/repos/b/browse/path/to/c.yaml',
|
||||
config,
|
||||
),
|
||||
).toEqual(
|
||||
new URL(
|
||||
'https://bitbucket.mycompany.net/rest/api/1.0/projects/a/repos/b/raw/path/to/c.yaml',
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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',
|
||||
rawBaseUrl: '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',
|
||||
rawBaseUrl: '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 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: '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({
|
||||
host: 'bitbucket.org',
|
||||
apiBaseUrl: 'https://api.bitbucket.org/2.0',
|
||||
});
|
||||
await expect(
|
||||
processor.read('https://not.bitbucket.com/apa'),
|
||||
).rejects.toThrow(
|
||||
'Incorrect URL: https://not.bitbucket.com/apa, Error: Invalid Bitbucket URL or file path',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,71 +14,262 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import fetch from 'cross-fetch';
|
||||
import { Config } from '@backstage/config';
|
||||
import { ReaderFactory, UrlReader } from './types';
|
||||
import parseGitUri from 'git-url-parse';
|
||||
import fetch from 'cross-fetch';
|
||||
import { NotFoundError } from '../errors';
|
||||
import { ReaderFactory, UrlReader } from './types';
|
||||
|
||||
type Options = {
|
||||
// TODO: added here for future support, but we only allow bitbucket.org for now
|
||||
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;
|
||||
auth?: {
|
||||
username: string;
|
||||
appPassword: 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 base URL of the raw fetch endpoint 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.
|
||||
*/
|
||||
rawBaseUrl?: 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;
|
||||
};
|
||||
|
||||
function readConfig(config: Config): Options[] {
|
||||
const optionsArr = Array<Options>();
|
||||
export function getApiRequestOptions(provider: ProviderConfig): RequestInit {
|
||||
const headers: HeadersInit = {};
|
||||
|
||||
if (provider.token) {
|
||||
headers.Authorization = `Bearer ${provider.token}`;
|
||||
} else if (provider.username && provider.appPassword) {
|
||||
headers.Authorization = `Basic ${Buffer.from(
|
||||
`${provider.username}:${provider.appPassword}`,
|
||||
'utf8',
|
||||
).toString('base64')}`;
|
||||
}
|
||||
|
||||
return {
|
||||
headers,
|
||||
};
|
||||
}
|
||||
|
||||
export function getRawRequestOptions(provider: ProviderConfig): RequestInit {
|
||||
const headers: HeadersInit = {};
|
||||
if (provider.token) {
|
||||
headers.Authorization = `Bearer ${provider.token}`;
|
||||
} else if (provider.username && provider.appPassword) {
|
||||
headers.Authorization = `Basic ${Buffer.from(
|
||||
`${provider.username}:${provider.appPassword}`,
|
||||
'utf8',
|
||||
).toString('base64')}`;
|
||||
}
|
||||
|
||||
return {
|
||||
headers,
|
||||
};
|
||||
}
|
||||
|
||||
// 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 {
|
||||
try {
|
||||
const { owner, name, ref, filepathtype, filepath } = parseGitUri(target);
|
||||
if (
|
||||
!owner ||
|
||||
!name ||
|
||||
(filepathtype !== 'browse' &&
|
||||
filepathtype !== 'raw' &&
|
||||
filepathtype !== 'src')
|
||||
) {
|
||||
throw new Error('Invalid Bitbucket URL or file path');
|
||||
}
|
||||
|
||||
const pathWithoutSlash = filepath.replace(/^\//, '');
|
||||
|
||||
if (provider.host === 'bitbucket.org') {
|
||||
if (!ref) {
|
||||
throw new Error('Invalid Bitbucket URL or file path');
|
||||
}
|
||||
return new URL(
|
||||
`${provider.apiBaseUrl}/repositories/${owner}/${name}/src/${ref}/${pathWithoutSlash}`,
|
||||
);
|
||||
}
|
||||
return new URL(
|
||||
`${provider.apiBaseUrl}/projects/${owner}/repos/${name}/raw/${pathWithoutSlash}?at=${ref}`,
|
||||
);
|
||||
} catch (e) {
|
||||
throw new Error(`Incorrect URL: ${target}, ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 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 getRawUrl(target: string, provider: ProviderConfig): URL {
|
||||
try {
|
||||
const { owner, name, ref, filepathtype, filepath } = parseGitUri(target);
|
||||
|
||||
if (
|
||||
!owner ||
|
||||
!name ||
|
||||
(filepathtype !== 'browse' &&
|
||||
filepathtype !== 'raw' &&
|
||||
filepathtype !== 'src')
|
||||
) {
|
||||
throw new Error('Invalid Bitbucket URL or file path');
|
||||
}
|
||||
|
||||
const pathWithoutSlash = filepath.replace(/^\//, '');
|
||||
|
||||
if (provider.host === 'bitbucket.org') {
|
||||
if (!ref) {
|
||||
throw new Error('Invalid Bitbucket URL or file path');
|
||||
}
|
||||
return new URL(
|
||||
`${provider.rawBaseUrl}/repositories/${owner}/${name}/src/${ref}/${pathWithoutSlash}`,
|
||||
);
|
||||
}
|
||||
return new URL(
|
||||
`${provider.rawBaseUrl}/projects/${owner}/repos/${name}/raw/${pathWithoutSlash}?at=${ref}`,
|
||||
);
|
||||
} catch (e) {
|
||||
throw new Error(`Incorrect URL: ${target}, ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
let rawBaseUrl = providerConfig.getOptionalString('rawBaseUrl');
|
||||
const token = providerConfig.getOptionalString('token');
|
||||
const username = providerConfig.getOptionalString('username');
|
||||
const password = providerConfig.getOptionalString('appPassword');
|
||||
|
||||
let auth;
|
||||
if (providerConfig.has('username')) {
|
||||
const username = providerConfig.getString('username');
|
||||
const appPassword = providerConfig.getString('appPassword');
|
||||
auth = { username, appPassword };
|
||||
if (apiBaseUrl) {
|
||||
apiBaseUrl = apiBaseUrl.replace(/\/+$/, '');
|
||||
} else if (host === 'bitbucket.org') {
|
||||
apiBaseUrl = DEFAULT_BASE_URL;
|
||||
}
|
||||
|
||||
optionsArr.push({ host, auth });
|
||||
if (rawBaseUrl) {
|
||||
rawBaseUrl = rawBaseUrl.replace(/\/+$/, '');
|
||||
} else if (host === 'bitbucket.org') {
|
||||
rawBaseUrl = DEFAULT_BASE_URL;
|
||||
}
|
||||
|
||||
if (!apiBaseUrl && !rawBaseUrl) {
|
||||
throw new Error(
|
||||
`Bitbucket integration for '${host}' must configure an explicit apiBaseUrl and rawBaseUrl`,
|
||||
);
|
||||
}
|
||||
if (!token && username && !password) {
|
||||
throw new Error(
|
||||
`Bitbucket integration for '${host}' has configured a username but is missing a required password.`,
|
||||
);
|
||||
}
|
||||
|
||||
providers.push({
|
||||
host,
|
||||
apiBaseUrl,
|
||||
rawBaseUrl,
|
||||
token,
|
||||
username,
|
||||
appPassword: password,
|
||||
});
|
||||
}
|
||||
|
||||
// As a convenience we always make sure there's at least an unauthenticated
|
||||
// reader for public bitbucket repos.
|
||||
if (!optionsArr.some(p => p.host === 'bitbucket.org')) {
|
||||
optionsArr.push({ host: 'bitbucket.org' });
|
||||
// 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,
|
||||
rawBaseUrl: DEFAULT_BASE_URL,
|
||||
});
|
||||
}
|
||||
|
||||
return optionsArr;
|
||||
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;
|
||||
|
||||
static factory: ReaderFactory = ({ config }) => {
|
||||
return readConfig(config).map(options => {
|
||||
const reader = new BitbucketUrlReader(options);
|
||||
const predicate = (url: URL) => url.host === options.host;
|
||||
return readConfig(config).map(provider => {
|
||||
const reader = new BitbucketUrlReader(provider);
|
||||
const predicate = (url: URL) => url.host === provider.host;
|
||||
return { reader, predicate };
|
||||
});
|
||||
};
|
||||
|
||||
constructor(private readonly options: Options) {
|
||||
if (options.host !== 'bitbucket.org') {
|
||||
throw Error(
|
||||
`Bitbucket integration currently only supports 'bitbucket.org', tried to use host '${options.host}'`,
|
||||
);
|
||||
}
|
||||
constructor(config: ProviderConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
async read(url: string): Promise<Buffer> {
|
||||
const builtUrl = this.buildRawUrl(url);
|
||||
const useApi =
|
||||
this.config.apiBaseUrl && (this.config.token || !this.config.rawBaseUrl);
|
||||
const bitbucketUrl = useApi
|
||||
? getApiUrl(url, this.config)
|
||||
: getRawUrl(url, this.config);
|
||||
const options = useApi
|
||||
? getApiRequestOptions(this.config)
|
||||
: getRawRequestOptions(this.config);
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(builtUrl.toString(), this.getRequestOptions());
|
||||
response = await fetch(bitbucketUrl.toString(), options);
|
||||
} catch (e) {
|
||||
throw new Error(`Unable to read ${url}, ${e}`);
|
||||
}
|
||||
@@ -87,76 +278,19 @@ export class BitbucketUrlReader implements UrlReader {
|
||||
return Buffer.from(await response.text());
|
||||
}
|
||||
|
||||
const message = `${url} could not be read as ${builtUrl}, ${response.status} ${response.statusText}`;
|
||||
const message = `${url} could not be read as ${bitbucketUrl}, ${response.status} ${response.statusText}`;
|
||||
if (response.status === 404) {
|
||||
throw new NotFoundError(message);
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
// Converts
|
||||
// from: https://bitbucket.org/orgname/reponame/src/master/file.yaml
|
||||
// to: https://api.bitbucket.org/2.0/repositories/orgname/reponame/src/master/file.yaml
|
||||
private buildRawUrl(target: string): URL {
|
||||
try {
|
||||
const url = new URL(target);
|
||||
|
||||
const [
|
||||
empty,
|
||||
userOrOrg,
|
||||
repoName,
|
||||
srcKeyword,
|
||||
ref,
|
||||
...restOfPath
|
||||
] = url.pathname.split('/');
|
||||
|
||||
if (
|
||||
url.hostname !== 'bitbucket.org' ||
|
||||
empty !== '' ||
|
||||
userOrOrg === '' ||
|
||||
repoName === '' ||
|
||||
srcKeyword !== 'src'
|
||||
) {
|
||||
throw new Error('Wrong Bitbucket URL or Invalid file path');
|
||||
}
|
||||
|
||||
// transform to api
|
||||
url.pathname = [
|
||||
empty,
|
||||
'2.0',
|
||||
'repositories',
|
||||
userOrOrg,
|
||||
repoName,
|
||||
'src',
|
||||
ref,
|
||||
...restOfPath,
|
||||
].join('/');
|
||||
url.hostname = 'api.bitbucket.org';
|
||||
url.protocol = 'https';
|
||||
|
||||
return url;
|
||||
} catch (e) {
|
||||
throw new Error(`Incorrect url: ${target}, ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
private getRequestOptions(): RequestInit {
|
||||
const headers: HeadersInit = {};
|
||||
|
||||
if (this.options.auth) {
|
||||
headers.Authorization = `Basic ${Buffer.from(
|
||||
`${this.options.auth.username}:${this.options.auth.appPassword}`,
|
||||
'utf8',
|
||||
).toString('base64')}`;
|
||||
}
|
||||
|
||||
return {
|
||||
headers,
|
||||
};
|
||||
}
|
||||
|
||||
toString() {
|
||||
const { host, auth } = this.options;
|
||||
return `bitbucket{host=${host},authed=${Boolean(auth)}}`;
|
||||
const { host, token, username, appPassword } = this.config;
|
||||
let authed = Boolean(token);
|
||||
if (!authed) {
|
||||
authed = Boolean(username && appPassword);
|
||||
}
|
||||
return `bitbucket{host=${host},authed=${authed}}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12249,6 +12249,13 @@ git-url-parse@^11.3.0:
|
||||
dependencies:
|
||||
git-up "^4.0.0"
|
||||
|
||||
git-url-parse@^11.4.0:
|
||||
version "11.4.0"
|
||||
resolved "https://registry.npmjs.org/git-url-parse/-/git-url-parse-11.4.0.tgz#f2bb1f2b00f05552540e95a62e31399a639a6aa6"
|
||||
integrity sha512-KlIa5jvMYLjXMQXkqpFzobsyD/V2K5DRHl5OAf+6oDFPlPLxrGDVQlIdI63c4/Kt6kai4kALENSALlzTGST3GQ==
|
||||
dependencies:
|
||||
git-up "^4.0.0"
|
||||
|
||||
gitconfiglocal@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.npmjs.org/gitconfiglocal/-/gitconfiglocal-1.0.0.tgz#41d045f3851a5ea88f03f24ca1c6178114464b9b"
|
||||
|
||||
Reference in New Issue
Block a user