feat: split BitbucketClient into BitbucketCloudClient, BitbucketServerClient

Bitbucket Cloud and Bitbucket Server (fka. Stash) are different product lines at Atlassian
and offer different APIs.

This was also reflected within the client which contained a mix of endpoints for both.

This is a tiny transparent step towards an easier maintainable setup as proposed at #9923.
The change is transparent for users of the processor, contained within the internals of it.

Besides the maintainability as of #9923, this can also enable future enhancements like
handling of rate limits, etc.

Some API interactions are not part of the client but are included in other packages like
`packages/integration` and then e.g. used at the `UrlReader` implementation.
This imposes some challenges towards topics like rate limit handling, etc.
Due to this, it may even make sense to externalize the clients in the future
(or alternative options) and ensure all interactions go through it.

As part of creating entity providers for such processors (#10183), this might be a good candidate
to create separate entity providers instead. It would not impact current users of the processor
(more than one entity provider).

Relates-to: #9923
Relates-to: #10183
Signed-off-by: Patrick Jungermann <Patrick.Jungermann@gmail.com>
This commit is contained in:
Patrick Jungermann
2022-03-30 01:35:29 +02:00
parent d8dd90927e
commit 9fed130139
5 changed files with 135 additions and 95 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend-module-bitbucket': patch
---
split BitbucketClient into BitbucketCloudClient, BitbucketServerClient
@@ -27,10 +27,11 @@ import {
} from '@backstage/plugin-catalog-backend';
import { Logger } from 'winston';
import {
BitbucketClient,
BitbucketCloudClient,
BitbucketRepository,
BitbucketRepository20,
BitbucketRepositoryParser,
BitbucketServerClient,
defaultRepositoryParser,
paginated,
paginated20,
@@ -90,28 +91,25 @@ export class BitbucketDiscoveryProcessor implements CatalogProcessor {
);
}
const client = new BitbucketClient({
config: integration.config,
});
const startTimestamp = Date.now();
this.logger.info(`Reading Bitbucket repositories from ${location.target}`);
const isBitbucketCloud = integration.config.host === 'bitbucket.org';
this.logger.info(
`Reading ${integration.config.host} repositories from ${location.target}`,
);
const processOptions: ProcessOptions = {
client,
emit,
integration,
location,
};
const isBitbucketCloud = integration.config.host === 'bitbucket.org';
const { scanned, matches } = isBitbucketCloud
? await this.processCloudRepositories(processOptions)
: await this.processOrganizationRepositories(processOptions);
const duration = ((Date.now() - startTimestamp) / 1000).toFixed(1);
this.logger.debug(
`Read ${scanned} Bitbucket repositories (${matches} matching the pattern) in ${duration} seconds`,
`Read ${scanned} ${integration.config.host} repositories (${matches} matching the pattern) in ${duration} seconds`,
);
return true;
@@ -120,7 +118,10 @@ export class BitbucketDiscoveryProcessor implements CatalogProcessor {
private async processCloudRepositories(
options: ProcessOptions,
): Promise<ResultSummary> {
const { client, location, integration, emit } = options;
const { location, integration, emit } = options;
const client = new BitbucketCloudClient({
config: integration.config,
});
const { searchEnabled } = parseBitbucketCloudUrl(location.target);
@@ -147,12 +148,16 @@ export class BitbucketDiscoveryProcessor implements CatalogProcessor {
private async processOrganizationRepositories(
options: ProcessOptions,
): Promise<ResultSummary> {
const { client, location, integration, emit } = options;
const { location, integration, emit } = options;
const { catalogPath: requestedCatalogPath } = parseUrl(location.target);
const catalogPath = requestedCatalogPath
? `/${requestedCatalogPath}`
: DEFAULT_CATALOG_LOCATION;
const client = new BitbucketServerClient({
config: integration.config,
});
const result = await readBitbucketOrg(client, location.target);
for (const repository of result.matches) {
for await (const entity of this.parser({
@@ -171,7 +176,7 @@ export class BitbucketDiscoveryProcessor implements CatalogProcessor {
}
export async function readBitbucketOrg(
client: BitbucketClient,
client: BitbucketServerClient,
target: string,
): Promise<Result<BitbucketRepository>> {
const { projectSearchPath, repoSearchPath } = parseUrl(target);
@@ -199,7 +204,7 @@ export async function readBitbucketOrg(
}
export async function searchBitbucketCloudLocations(
client: BitbucketClient,
client: BitbucketCloudClient,
target: string,
): Promise<Result<string>> {
const {
@@ -252,7 +257,7 @@ export async function searchBitbucketCloudLocations(
}
export async function readBitbucketCloudLocations(
client: BitbucketClient,
client: BitbucketCloudClient,
target: string,
): Promise<Result<string>> {
const { catalogPath: requestedCatalogPath } = parseBitbucketCloudUrl(target);
@@ -274,7 +279,7 @@ export async function readBitbucketCloudLocations(
}
export async function readBitbucketCloud(
client: BitbucketClient,
client: BitbucketCloudClient,
target: string,
): Promise<Result<BitbucketRepository20>> {
const {
@@ -285,7 +290,7 @@ export async function readBitbucketCloud(
} = parseBitbucketCloudUrl(target);
const repositories = paginated20(
options => client.listRepositoriesByWorkspace20(workspacePath, options),
options => client.listRepositoriesByWorkspace(workspacePath, options),
{
q,
},
@@ -380,7 +385,6 @@ function escapeRegExp(str: string): RegExp {
}
type ProcessOptions = {
client: BitbucketClient;
integration: BitbucketIntegration;
location: LocationSpec;
emit: CatalogProcessorEmit;
@@ -21,7 +21,7 @@ import {
import fetch from 'node-fetch';
import { BitbucketRepository20 } from './types';
export class BitbucketClient {
export class BitbucketCloudClient {
private readonly config: BitbucketIntegrationConfig;
constructor(options: { config: BitbucketIntegrationConfig }) {
@@ -49,7 +49,7 @@ export class BitbucketClient {
'+values.file.commit.repository.links.html.href',
].join(',');
return this.pagedRequest20<CodeSearchResultItem>(
return this.pagedRequest<CodeSearchResultItem>(
`${this.config.apiBaseUrl}/workspaces/${encodeURIComponent(
workspace,
)}/search/code`,
@@ -61,58 +61,17 @@ export class BitbucketClient {
);
}
async listProjects(options?: ListOptions): Promise<PagedResponse<any>> {
return this.pagedRequest(`${this.config.apiBaseUrl}/projects`, options);
}
async listRepositoriesByWorkspace20(
async listRepositoriesByWorkspace(
workspace: string,
options?: ListOptions20,
): Promise<PagedResponse20<BitbucketRepository20>> {
return this.pagedRequest20<BitbucketRepository20>(
return this.pagedRequest<BitbucketRepository20>(
`${this.config.apiBaseUrl}/repositories/${encodeURIComponent(workspace)}`,
options,
);
}
async listRepositories(
projectKey: string,
options?: ListOptions,
): Promise<PagedResponse<any>> {
return this.pagedRequest(
`${this.config.apiBaseUrl}/projects/${encodeURIComponent(
projectKey,
)}/repos`,
options,
);
}
private async pagedRequest(
endpoint: string,
options?: ListOptions,
): Promise<PagedResponse<any>> {
const request = new URL(endpoint);
for (const key in options) {
if (options[key]) {
request.searchParams.append(key, options[key]!.toString());
}
}
const response = await fetch(
request.toString(),
getBitbucketRequestOptions(this.config),
);
if (!response.ok) {
throw new Error(
`Unexpected response when fetching ${request.toString()}. Expected 200 but got ${
response.status
} - ${response.statusText}`,
);
}
return response.json() as Promise<PagedResponse<any>>;
}
private async pagedRequest20<T = any>(
private async pagedRequest<T = any>(
endpoint: string,
options?: ListOptions20,
): Promise<PagedResponse20<T>> {
@@ -154,21 +113,6 @@ export type CodeSearchResultItem = {
};
};
export type ListOptions = {
[key: string]: number | undefined;
limit?: number | undefined;
start?: number | undefined;
};
export type PagedResponse<T> = {
size: number;
limit: number;
start: number;
isLastPage: boolean;
values: T[];
nextPageStart: number;
};
export type ListOptions20 = {
[key: string]: string | number | undefined;
page?: number | undefined;
@@ -183,21 +127,6 @@ export type PagedResponse20<T> = {
next: string;
};
export async function* paginated(
request: (options: ListOptions) => Promise<PagedResponse<any>>,
options?: ListOptions,
) {
const opts = options || { start: 0 };
let res;
do {
res = await request(opts);
opts.start = res.nextPageStart;
for (const item of res.values) {
yield item;
}
} while (!res.isLastPage);
}
export async function* paginated20<T = any>(
request: (options: ListOptions20) => Promise<PagedResponse20<T>>,
options?: ListOptions20,
@@ -0,0 +1,100 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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 {
BitbucketIntegrationConfig,
getBitbucketRequestOptions,
} from '@backstage/integration';
import fetch from 'node-fetch';
export class BitbucketServerClient {
private readonly config: BitbucketIntegrationConfig;
constructor(options: { config: BitbucketIntegrationConfig }) {
this.config = options.config;
}
async listProjects(options?: ListOptions): Promise<PagedResponse<any>> {
return this.pagedRequest(`${this.config.apiBaseUrl}/projects`, options);
}
async listRepositories(
projectKey: string,
options?: ListOptions,
): Promise<PagedResponse<any>> {
return this.pagedRequest(
`${this.config.apiBaseUrl}/projects/${encodeURIComponent(
projectKey,
)}/repos`,
options,
);
}
private async pagedRequest(
endpoint: string,
options?: ListOptions,
): Promise<PagedResponse<any>> {
const request = new URL(endpoint);
for (const key in options) {
if (options[key]) {
request.searchParams.append(key, options[key]!.toString());
}
}
const response = await fetch(
request.toString(),
getBitbucketRequestOptions(this.config),
);
if (!response.ok) {
throw new Error(
`Unexpected response when fetching ${request.toString()}. Expected 200 but got ${
response.status
} - ${response.statusText}`,
);
}
return response.json() as Promise<PagedResponse<any>>;
}
}
export type ListOptions = {
[key: string]: number | undefined;
limit?: number | undefined;
start?: number | undefined;
};
export type PagedResponse<T> = {
size: number;
limit: number;
start: number;
isLastPage: boolean;
values: T[];
nextPageStart: number;
};
export async function* paginated(
request: (options: ListOptions) => Promise<PagedResponse<any>>,
options?: ListOptions,
) {
const opts = options || { start: 0 };
let res;
do {
res = await request(opts);
opts.start = res.nextPageStart;
for (const item of res.values) {
yield item;
}
} while (!res.isLastPage);
}
@@ -16,6 +16,8 @@
export { defaultRepositoryParser } from './BitbucketRepositoryParser';
export type { BitbucketRepositoryParser } from './BitbucketRepositoryParser';
export { BitbucketClient, paginated, paginated20 } from './client';
export type { PagedResponse, PagedResponse20 } from './client';
export { BitbucketCloudClient, paginated20 } from './BitbucketCloudClient';
export { BitbucketServerClient, paginated } from './BitbucketServerClient';
export type { PagedResponse20 } from './BitbucketCloudClient';
export type { PagedResponse } from './BitbucketServerClient';
export type { BitbucketRepository, BitbucketRepository20 } from './types';