Skip to content

Commit

Permalink
integrations: move common integration concerns to a separate package (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
freben committed Nov 16, 2020
1 parent 04c0698 commit 7b37e68
Show file tree
Hide file tree
Showing 25 changed files with 1,085 additions and 402 deletions.
6 changes: 6 additions & 0 deletions .changeset/ninety-gifts-guess.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@backstage/backend-common': patch
'@backstage/integration': patch
---

Added the integration package
1 change: 1 addition & 0 deletions packages/backend-common/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
40 changes: 9 additions & 31 deletions packages/backend-common/src/reading/AzureUrlReader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}'`,
Expand Down
78 changes: 8 additions & 70 deletions packages/backend-common/src/reading/BitbucketUrlReader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '',
};
Expand All @@ -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: '',
};
Expand All @@ -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',
};
Expand All @@ -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',
};
Expand All @@ -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({
Expand Down
133 changes: 31 additions & 102 deletions packages/backend-common/src/reading/BitbucketUrlReader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 (
Expand Down Expand Up @@ -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') ?? [];
/**
* 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 readonly config: BitbucketIntegrationConfig;

// 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');
static factory: ReaderFactory = ({ config }) => {
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 };
});
};

if (apiBaseUrl) {
apiBaseUrl = apiBaseUrl.replace(/\/+$/, '');
} else if (host === 'bitbucket.org') {
apiBaseUrl = DEFAULT_BASE_URL;
}
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.`,
);
}

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;

static factory: ReaderFactory = ({ config }) => {
return readConfig(config).map(provider => {
const reader = new BitbucketUrlReader(provider);
const predicate = (url: URL) => url.host === provider.host;
return { reader, predicate };
});
};

constructor(config: ProviderConfig) {
this.config = config;
}

Expand Down
Loading

0 comments on commit 7b37e68

Please sign in to comment.