diff --git a/.changeset/ninety-gifts-guess.md b/.changeset/ninety-gifts-guess.md new file mode 100644 index 0000000000..bdd20322ec --- /dev/null +++ b/.changeset/ninety-gifts-guess.md @@ -0,0 +1,6 @@ +--- +'@backstage/backend-common': patch +'@backstage/integration': patch +--- + +Added the integration package diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index 67e4ffe11f..ea84d1a383 100644 --- a/packages/backend-common/package.json +++ b/packages/backend-common/package.json @@ -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", diff --git a/packages/backend-common/src/reading/AzureUrlReader.ts b/packages/backend-common/src/reading/AzureUrlReader.ts index 79058c79a9..db8b738667 100644 --- a/packages/backend-common/src/reading/AzureUrlReader.ts +++ b/packages/backend-common/src/reading/AzureUrlReader.ts @@ -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(); - - 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}'`, diff --git a/packages/backend-common/src/reading/BitbucketUrlReader.test.ts b/packages/backend-common/src/reading/BitbucketUrlReader.test.ts index 210ee16873..01744db28a 100644 --- a/packages/backend-common/src/reading/BitbucketUrlReader.test.ts +++ b/packages/backend-common/src/reading/BitbucketUrlReader.test.ts @@ -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({ diff --git a/packages/backend-common/src/reading/BitbucketUrlReader.ts b/packages/backend-common/src/reading/BitbucketUrlReader.ts index b2fb9fa309..9694c1d987 100644 --- a/packages/backend-common/src/reading/BitbucketUrlReader.ts +++ b/packages/backend-common/src/reading/BitbucketUrlReader.ts @@ -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; } diff --git a/packages/backend-common/src/reading/GithubUrlReader.test.ts b/packages/backend-common/src/reading/GithubUrlReader.test.ts index 658d908f84..eb87d339ea 100644 --- a/packages/backend-common/src/reading/GithubUrlReader.test.ts +++ b/packages/backend-common/src/reading/GithubUrlReader.test.ts @@ -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 }, ); diff --git a/packages/backend-common/src/reading/GithubUrlReader.ts b/packages/backend-common/src/reading/GithubUrlReader.ts index ca67a6cfcb..2fbaa0b32b 100644 --- a/packages/backend-common/src/reading/GithubUrlReader.ts +++ b/packages/backend-common/src/reading/GithubUrlReader.ts @@ -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 { const useApi = diff --git a/packages/backend-common/src/reading/GitlabUrlReader.ts b/packages/backend-common/src/reading/GitlabUrlReader.ts index c621e41338..e2d3edfea2 100644 --- a/packages/backend-common/src/reading/GitlabUrlReader.ts +++ b/packages/backend-common/src/reading/GitlabUrlReader.ts @@ -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(); - - 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 { // 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', diff --git a/packages/integration/.eslintrc.js b/packages/integration/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/packages/integration/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/packages/integration/README.md b/packages/integration/README.md new file mode 100644 index 0000000000..c1b66f0104 --- /dev/null +++ b/packages/integration/README.md @@ -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) diff --git a/packages/integration/package.json b/packages/integration/package.json new file mode 100644 index 0000000000..cfbdcbe57c --- /dev/null +++ b/packages/integration/package.json @@ -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" + ] +} diff --git a/packages/integration/src/azure/config.test.ts b/packages/integration/src/azure/config.test.ts new file mode 100644 index 0000000000..0b943f2081 --- /dev/null +++ b/packages/integration/src/azure/config.test.ts @@ -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): 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[]): 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', + }, + ]); + }); +}); diff --git a/packages/integration/src/azure/config.ts b/packages/integration/src/azure/config.ts new file mode 100644 index 0000000000..27b73ed707 --- /dev/null +++ b/packages/integration/src/azure/config.ts @@ -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; +} diff --git a/packages/integration/src/azure/index.ts b/packages/integration/src/azure/index.ts new file mode 100644 index 0000000000..ede0c88a81 --- /dev/null +++ b/packages/integration/src/azure/index.ts @@ -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'; diff --git a/packages/integration/src/bitbucket/config.test.ts b/packages/integration/src/bitbucket/config.test.ts new file mode 100644 index 0000000000..775a8b7d2d --- /dev/null +++ b/packages/integration/src/bitbucket/config.test.ts @@ -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): 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[]): 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', + }, + ]); + }); +}); diff --git a/packages/integration/src/bitbucket/config.ts b/packages/integration/src/bitbucket/config.ts new file mode 100644 index 0000000000..1997a5597d --- /dev/null +++ b/packages/integration/src/bitbucket/config.ts @@ -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; +} diff --git a/packages/integration/src/bitbucket/index.ts b/packages/integration/src/bitbucket/index.ts new file mode 100644 index 0000000000..897c00d160 --- /dev/null +++ b/packages/integration/src/bitbucket/index.ts @@ -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'; diff --git a/packages/integration/src/github/config.test.ts b/packages/integration/src/github/config.test.ts new file mode 100644 index 0000000000..d33cffb7be --- /dev/null +++ b/packages/integration/src/github/config.test.ts @@ -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) { + 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[], + ): 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', + }, + ]); + }); +}); diff --git a/packages/integration/src/github/config.ts b/packages/integration/src/github/config.ts new file mode 100644 index 0000000000..f646acc53b --- /dev/null +++ b/packages/integration/src/github/config.ts @@ -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; +} diff --git a/packages/integration/src/github/index.ts b/packages/integration/src/github/index.ts new file mode 100644 index 0000000000..2099dd42e3 --- /dev/null +++ b/packages/integration/src/github/index.ts @@ -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'; diff --git a/packages/integration/src/gitlab/config.test.ts b/packages/integration/src/gitlab/config.test.ts new file mode 100644 index 0000000000..9998a530c6 --- /dev/null +++ b/packages/integration/src/gitlab/config.test.ts @@ -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): 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[]): 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', + }, + ]); + }); +}); diff --git a/packages/integration/src/gitlab/config.ts b/packages/integration/src/gitlab/config.ts new file mode 100644 index 0000000000..97c948c999 --- /dev/null +++ b/packages/integration/src/gitlab/config.ts @@ -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; +} diff --git a/packages/integration/src/gitlab/index.ts b/packages/integration/src/gitlab/index.ts new file mode 100644 index 0000000000..0801914fd4 --- /dev/null +++ b/packages/integration/src/gitlab/index.ts @@ -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'; diff --git a/packages/integration/src/index.ts b/packages/integration/src/index.ts new file mode 100644 index 0000000000..bfed81824f --- /dev/null +++ b/packages/integration/src/index.ts @@ -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'; diff --git a/packages/integration/src/setupTests.ts b/packages/integration/src/setupTests.ts new file mode 100644 index 0000000000..ba33cf996b --- /dev/null +++ b/packages/integration/src/setupTests.ts @@ -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 {};