Merge pull request #10263 from anicke/gerrit-urlreader
GerritUrlReader added that implements "readUrl"
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-common': patch
|
||||
---
|
||||
|
||||
Added the GerritUrlReader that implements "readUrl".
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/integration': minor
|
||||
---
|
||||
|
||||
Gerrit integration: Added an optional configuration to set the Gitiles base url.
|
||||
@@ -108,6 +108,7 @@ Francesco
|
||||
Gerrit
|
||||
gitbeaker
|
||||
github
|
||||
Gitiles
|
||||
gitlab
|
||||
GitLab
|
||||
Gource
|
||||
|
||||
@@ -17,6 +17,7 @@ import Docker from 'dockerode';
|
||||
import { Duration } from 'luxon';
|
||||
import { ErrorRequestHandler } from 'express';
|
||||
import express from 'express';
|
||||
import { GerritIntegration } from '@backstage/integration';
|
||||
import { GithubCredentialsProvider } from '@backstage/integration';
|
||||
import { GitHubIntegration } from '@backstage/integration';
|
||||
import { GitLabIntegration } from '@backstage/integration';
|
||||
@@ -252,6 +253,23 @@ export type FromReadableArrayOptions = Array<{
|
||||
path: string;
|
||||
}>;
|
||||
|
||||
// @public
|
||||
export class GerritUrlReader implements UrlReader {
|
||||
constructor(integration: GerritIntegration);
|
||||
// (undocumented)
|
||||
static factory: ReaderFactory;
|
||||
// (undocumented)
|
||||
read(url: string): Promise<Buffer>;
|
||||
// (undocumented)
|
||||
readTree(): Promise<ReadTreeResponse>;
|
||||
// (undocumented)
|
||||
readUrl(url: string, options?: ReadUrlOptions): Promise<ReadUrlResponse>;
|
||||
// (undocumented)
|
||||
search(): Promise<SearchResponse>;
|
||||
// (undocumented)
|
||||
toString(): string;
|
||||
}
|
||||
|
||||
// @public
|
||||
export function getRootLogger(): winston.Logger;
|
||||
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
/*
|
||||
* Copyright 2022 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 { rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { setupRequestMockHandlers } from '@backstage/backend-test-utils';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { getVoidLogger } from '../logging';
|
||||
import { DefaultReadTreeResponseFactory } from './tree';
|
||||
import { UrlReaderPredicateTuple } from './types';
|
||||
import {
|
||||
GerritIntegration,
|
||||
readGerritIntegrationConfig,
|
||||
} from '@backstage/integration';
|
||||
import { GerritUrlReader } from './GerritUrlReader';
|
||||
|
||||
const treeResponseFactory = DefaultReadTreeResponseFactory.create({
|
||||
config: new ConfigReader({}),
|
||||
});
|
||||
|
||||
const gerritProcessor = new GerritUrlReader(
|
||||
new GerritIntegration(
|
||||
readGerritIntegrationConfig(
|
||||
new ConfigReader({
|
||||
host: 'gerrit.com',
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const createReader = (config: JsonObject): UrlReaderPredicateTuple[] => {
|
||||
return GerritUrlReader.factory({
|
||||
config: new ConfigReader(config),
|
||||
logger: getVoidLogger(),
|
||||
treeResponseFactory,
|
||||
});
|
||||
};
|
||||
|
||||
describe('GerritUrlReader', () => {
|
||||
const worker = setupServer();
|
||||
setupRequestMockHandlers(worker);
|
||||
|
||||
describe('reader factory', () => {
|
||||
it('creates a reader.', () => {
|
||||
const readers = createReader({
|
||||
integrations: {
|
||||
gerrit: [{ host: 'gerrit.com' }],
|
||||
},
|
||||
});
|
||||
expect(readers).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should not create a default entry.', () => {
|
||||
const readers = createReader({
|
||||
integrations: {},
|
||||
});
|
||||
expect(readers).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('predicates without Gitiles', () => {
|
||||
const readers = createReader({
|
||||
integrations: {
|
||||
gerrit: [{ host: 'gerrit.com' }],
|
||||
},
|
||||
});
|
||||
const predicate = readers[0].predicate;
|
||||
|
||||
it('returns true for the configured host', () => {
|
||||
expect(predicate(new URL('https://gerrit.com/path'))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for a different host.', () => {
|
||||
expect(predicate(new URL('https://github.com/path'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('predicates with gitilesBaseUrl set.', () => {
|
||||
const readers = createReader({
|
||||
integrations: {
|
||||
gerrit: [
|
||||
{ host: 'gerrit-review.com', gitilesBaseUrl: 'https://gerrit.com' },
|
||||
],
|
||||
},
|
||||
});
|
||||
const predicate = readers[0].predicate;
|
||||
|
||||
it('returns false since gitilesBaseUrl is set to the api host.', () => {
|
||||
expect(predicate(new URL('https://gerrit-review.com/path'))).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for host.', () => {
|
||||
expect(predicate(new URL('https://gerrit.com/path'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readUrl', () => {
|
||||
const responseBuffer = Buffer.from('Apache License');
|
||||
it('should be able to read file contents', async () => {
|
||||
worker.use(
|
||||
rest.get(
|
||||
'https://gerrit.com/projects/web%2Fproject/branches/master/files/LICENSE/content',
|
||||
(_, res, ctx) => {
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.body(responseBuffer.toString('base64')),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const result = await gerritProcessor.readUrl(
|
||||
'https://gerrit.com/web/project/+/refs/heads/master/LICENSE',
|
||||
);
|
||||
const buffer = await result.buffer();
|
||||
expect(buffer.toString()).toBe(responseBuffer.toString());
|
||||
});
|
||||
|
||||
it('should raise NotFoundError on 404.', async () => {
|
||||
worker.use(
|
||||
rest.get(
|
||||
'https://gerrit.com/projects/web%2Fproject/branches/master/files/LICENSE/content',
|
||||
(_, res, ctx) => {
|
||||
return res(ctx.status(404, 'File not found.'));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
gerritProcessor.readUrl(
|
||||
'https://gerrit.com/web/project/+/refs/heads/master/LICENSE',
|
||||
),
|
||||
).rejects.toThrow(
|
||||
'File https://gerrit.com/web/project/+/refs/heads/master/LICENSE not found.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error on non 404 errors.', async () => {
|
||||
worker.use(
|
||||
rest.get(
|
||||
'https://gerrit.com/projects/web%2Fproject/branches/master/files/LICENSE/content',
|
||||
(_, res, ctx) => {
|
||||
return res(ctx.status(500, 'Error!!!'));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
gerritProcessor.readUrl(
|
||||
'https://gerrit.com/web/project/+/refs/heads/master/LICENSE',
|
||||
),
|
||||
).rejects.toThrow(
|
||||
'https://gerrit.com/web/project/+/refs/heads/master/LICENSE' +
|
||||
' could not be read as https://gerrit.com/projects/web%2Fproject' +
|
||||
'/branches/master/files/LICENSE/content, 500 Error!!!',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* Copyright 2022 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 { NotFoundError } from '@backstage/errors';
|
||||
import {
|
||||
GerritIntegration,
|
||||
getGerritFileContentsApiUrl,
|
||||
getGerritRequestOptions,
|
||||
} from '@backstage/integration';
|
||||
import fetch, { Response } from 'node-fetch';
|
||||
import {
|
||||
ReaderFactory,
|
||||
ReadTreeResponse,
|
||||
ReadUrlOptions,
|
||||
ReadUrlResponse,
|
||||
SearchResponse,
|
||||
UrlReader,
|
||||
} from './types';
|
||||
import { ScmIntegrations } from '@backstage/integration';
|
||||
|
||||
/**
|
||||
* Implements a {@link UrlReader} for files in Gerrit.
|
||||
*
|
||||
* @remarks
|
||||
* To be able to link to Git contents for Gerrit providers in a user friendly
|
||||
* way we are depending on that there is a Gitiles installation somewhere
|
||||
* that we can link to. It is perfectly possible to integrate Gerrit with
|
||||
* Backstage without Gitiles since all API calls goes directly to Gerrit.
|
||||
*
|
||||
* The "host" variable in the config is the Gerrit host. The address where
|
||||
* Gitiles is installed may be on the same host but it could be on a
|
||||
* separate host. For example a Gerrit instance could be hosted on
|
||||
* "gerrit-review.company.com" but the repos could be browsable on a separate
|
||||
* host, e.g. "gerrit.company.com" and the human readable URL would then
|
||||
* not point to the API host.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export class GerritUrlReader implements UrlReader {
|
||||
static factory: ReaderFactory = ({ config }) => {
|
||||
const integrations = ScmIntegrations.fromConfig(config);
|
||||
if (!integrations.gerrit) {
|
||||
return [];
|
||||
}
|
||||
return integrations.gerrit.list().map(integration => {
|
||||
const reader = new GerritUrlReader(integration);
|
||||
const predicate = (url: URL) => {
|
||||
const gitilesUrl = new URL(integration.config.gitilesBaseUrl!);
|
||||
// If gitilesUrl is not specfified it will default to
|
||||
// "integration.config.host".
|
||||
return url.host === gitilesUrl.host;
|
||||
};
|
||||
return { reader, predicate };
|
||||
});
|
||||
};
|
||||
|
||||
constructor(private readonly integration: GerritIntegration) {}
|
||||
|
||||
async read(url: string): Promise<Buffer> {
|
||||
const response = await this.readUrl(url);
|
||||
return response.buffer();
|
||||
}
|
||||
|
||||
async readUrl(
|
||||
url: string,
|
||||
options?: ReadUrlOptions,
|
||||
): Promise<ReadUrlResponse> {
|
||||
const apiUrl = getGerritFileContentsApiUrl(this.integration.config, url);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(apiUrl, {
|
||||
method: 'GET',
|
||||
...getGerritRequestOptions(this.integration.config),
|
||||
// TODO(freben): The signal cast is there because pre-3.x versions of
|
||||
// node-fetch have a very slightly deviating AbortSignal type signature.
|
||||
// The difference does not affect us in practice however. The cast can
|
||||
// be removed after we support ESM for CLI dependencies and migrate to
|
||||
// version 3 of node-fetch.
|
||||
// https://github.com/backstage/backstage/issues/8242
|
||||
signal: options?.signal as any,
|
||||
});
|
||||
} catch (e) {
|
||||
throw new Error(`Unable to read gerrit file ${url}, ${e}`);
|
||||
}
|
||||
if (response.ok) {
|
||||
const responseBody = await response.text();
|
||||
return {
|
||||
buffer: async () => Buffer.from(responseBody, 'base64'),
|
||||
};
|
||||
}
|
||||
if (response.status === 404) {
|
||||
throw new NotFoundError(`File ${url} not found.`);
|
||||
}
|
||||
throw new Error(
|
||||
`${url} could not be read as ${apiUrl}, ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
async readTree(): Promise<ReadTreeResponse> {
|
||||
throw new Error('GerritReader does not implement readTree');
|
||||
}
|
||||
|
||||
async search(): Promise<SearchResponse> {
|
||||
throw new Error('GerritReader does not implement search');
|
||||
}
|
||||
|
||||
toString() {
|
||||
const { host, password } = this.integration.config;
|
||||
return `gerrit{host=${host},authed=${Boolean(password)}}`;
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import { ReaderFactory, UrlReader } from './types';
|
||||
import { UrlReaderPredicateMux } from './UrlReaderPredicateMux';
|
||||
import { AzureUrlReader } from './AzureUrlReader';
|
||||
import { BitbucketUrlReader } from './BitbucketUrlReader';
|
||||
import { GerritUrlReader } from './GerritUrlReader';
|
||||
import { GithubUrlReader } from './GithubUrlReader';
|
||||
import { GitlabUrlReader } from './GitlabUrlReader';
|
||||
import { DefaultReadTreeResponseFactory } from './tree';
|
||||
@@ -82,6 +83,7 @@ export class UrlReaders {
|
||||
factories: factories.concat([
|
||||
AzureUrlReader.factory,
|
||||
BitbucketUrlReader.factory,
|
||||
GerritUrlReader.factory,
|
||||
GithubUrlReader.factory,
|
||||
GitlabUrlReader.factory,
|
||||
GoogleGcsUrlReader.factory,
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
export { AzureUrlReader } from './AzureUrlReader';
|
||||
export { BitbucketUrlReader } from './BitbucketUrlReader';
|
||||
export { GerritUrlReader } from './GerritUrlReader';
|
||||
export { GithubUrlReader } from './GithubUrlReader';
|
||||
export { GitlabUrlReader } from './GitlabUrlReader';
|
||||
export { AwsS3UrlReader } from './AwsS3UrlReader';
|
||||
|
||||
@@ -138,6 +138,7 @@ export class GerritIntegration implements ScmIntegration {
|
||||
export type GerritIntegrationConfig = {
|
||||
host: string;
|
||||
baseUrl?: string;
|
||||
gitilesBaseUrl?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
};
|
||||
@@ -184,6 +185,17 @@ export function getBitbucketRequestOptions(
|
||||
headers: Record<string, string>;
|
||||
};
|
||||
|
||||
// @public
|
||||
export function getGerritFileContentsApiUrl(
|
||||
config: GerritIntegrationConfig,
|
||||
url: string,
|
||||
): string;
|
||||
|
||||
// @public
|
||||
export function getGerritRequestOptions(config: GerritIntegrationConfig): {
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
|
||||
// @public
|
||||
export function getGitHubFileFetchUrl(
|
||||
url: string,
|
||||
@@ -330,6 +342,9 @@ export interface IntegrationsByType {
|
||||
gitlab: ScmIntegrationsGroup<GitLabIntegration>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export function parseGerritJsonResponse(response: Response): Promise<unknown>;
|
||||
|
||||
// @public
|
||||
export function readAwsS3IntegrationConfig(
|
||||
config: Config,
|
||||
|
||||
@@ -56,6 +56,7 @@ describe('readGerritIntegrationConfig', () => {
|
||||
buildConfig({
|
||||
host: 'a.com',
|
||||
baseUrl: 'https://a.com/api',
|
||||
gitilesBaseUrl: 'https://a.com/git',
|
||||
username: 'u',
|
||||
password: 'p',
|
||||
}),
|
||||
@@ -63,6 +64,7 @@ describe('readGerritIntegrationConfig', () => {
|
||||
expect(output).toEqual({
|
||||
host: 'a.com',
|
||||
baseUrl: 'https://a.com/api',
|
||||
gitilesBaseUrl: 'https://a.com/git',
|
||||
username: 'u',
|
||||
password: 'p',
|
||||
});
|
||||
@@ -77,6 +79,7 @@ describe('readGerritIntegrationConfig', () => {
|
||||
expect(output).toEqual({
|
||||
host: 'a.com',
|
||||
baseUrl: 'https://a.com',
|
||||
gitilesBaseUrl: 'https://a.com',
|
||||
username: undefined,
|
||||
password: undefined,
|
||||
});
|
||||
@@ -107,6 +110,7 @@ describe('readGerritIntegrationConfig', () => {
|
||||
).toEqual({
|
||||
host: 'a.com',
|
||||
baseUrl: 'https://a.com/gerrit',
|
||||
gitilesBaseUrl: 'https://a.com',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -116,7 +120,7 @@ describe('readGerritIntegrationConfigs', () => {
|
||||
return data.map(item => new ConfigReader(item));
|
||||
}
|
||||
|
||||
it('reads all values', () => {
|
||||
it('reads multiple configs', () => {
|
||||
const output = readGerritIntegrationConfigs(
|
||||
buildConfig([
|
||||
{
|
||||
@@ -135,12 +139,14 @@ describe('readGerritIntegrationConfigs', () => {
|
||||
{
|
||||
host: 'a.com',
|
||||
baseUrl: 'https://a.com/api',
|
||||
gitilesBaseUrl: 'https://a.com',
|
||||
username: 'u',
|
||||
password: 'p',
|
||||
},
|
||||
{
|
||||
host: 'b.com',
|
||||
baseUrl: 'https://b.com/api',
|
||||
gitilesBaseUrl: 'https://b.com',
|
||||
username: undefined,
|
||||
password: undefined,
|
||||
},
|
||||
|
||||
@@ -38,6 +38,14 @@ export type GerritIntegrationConfig = {
|
||||
*/
|
||||
baseUrl?: string;
|
||||
|
||||
/**
|
||||
* Optional base url for Gitiles. This is needed for creating a valid
|
||||
* user-friendly url that can be used for browsing the content of the
|
||||
* provider. If not set a default value will be created in the same way
|
||||
* as the "baseUrl" option.
|
||||
*/
|
||||
gitilesBaseUrl?: string;
|
||||
|
||||
/**
|
||||
* The username to use for requests to gerrit.
|
||||
*/
|
||||
@@ -61,6 +69,7 @@ export function readGerritIntegrationConfig(
|
||||
): GerritIntegrationConfig {
|
||||
const host = config.getString('host');
|
||||
let baseUrl = config.getOptionalString('baseUrl');
|
||||
let gitilesBaseUrl = config.getOptionalString('gitilesBaseUrl');
|
||||
const username = config.getOptionalString('username');
|
||||
const password = config.getOptionalString('password');
|
||||
|
||||
@@ -72,16 +81,26 @@ export function readGerritIntegrationConfig(
|
||||
throw new Error(
|
||||
`Invalid Gerrit integration config, '${baseUrl}' is not a valid baseUrl`,
|
||||
);
|
||||
} else if (gitilesBaseUrl && !isValidUrl(gitilesBaseUrl)) {
|
||||
throw new Error(
|
||||
`Invalid Gerrit integration config, '${gitilesBaseUrl}' is not a valid gitilesBaseUrl`,
|
||||
);
|
||||
}
|
||||
if (baseUrl) {
|
||||
baseUrl = trimEnd(baseUrl, '/');
|
||||
} else {
|
||||
baseUrl = `https://${host}`;
|
||||
}
|
||||
if (gitilesBaseUrl) {
|
||||
gitilesBaseUrl = trimEnd(gitilesBaseUrl, '/');
|
||||
} else {
|
||||
gitilesBaseUrl = `https://${host}`;
|
||||
}
|
||||
|
||||
return {
|
||||
host,
|
||||
baseUrl,
|
||||
gitilesBaseUrl,
|
||||
username,
|
||||
password,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
/*
|
||||
* Copyright 2020 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 { rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import fetch from 'cross-fetch';
|
||||
import { setupRequestMockHandlers } from '@backstage/test-utils';
|
||||
import { GerritIntegrationConfig } from './config';
|
||||
import {
|
||||
getGerritRequestOptions,
|
||||
parseGerritJsonResponse,
|
||||
parseGitilesUrl,
|
||||
getGerritFileContentsApiUrl,
|
||||
} from './core';
|
||||
|
||||
describe('gerrit core', () => {
|
||||
const worker = setupServer();
|
||||
setupRequestMockHandlers(worker);
|
||||
|
||||
describe('getGerritRequestOptions', () => {
|
||||
it('adds headers when a password is specified', () => {
|
||||
const authRequest: GerritIntegrationConfig = {
|
||||
host: 'gerrit.com',
|
||||
username: 'U',
|
||||
password: 'P',
|
||||
};
|
||||
const anonymousRequest: GerritIntegrationConfig = {
|
||||
host: 'gerrit.com',
|
||||
};
|
||||
expect(
|
||||
(getGerritRequestOptions(authRequest).headers as any).Authorization,
|
||||
).toEqual('Basic VTpQ');
|
||||
expect(
|
||||
getGerritRequestOptions(anonymousRequest).headers as any,
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseGitilesUrl', () => {
|
||||
it('can parse a valid gitiles urls.', () => {
|
||||
const config: GerritIntegrationConfig = {
|
||||
host: 'gerrit.com',
|
||||
gitilesBaseUrl: 'https://gerrit.com/gitiles',
|
||||
};
|
||||
const { branch, filePath, project } = parseGitilesUrl(
|
||||
config,
|
||||
'https://gerrit.com/gitiles/web/project/+/refs/heads/master/README.md',
|
||||
);
|
||||
expect(project).toEqual('web/project');
|
||||
expect(branch).toEqual('master');
|
||||
expect(filePath).toEqual('README.md');
|
||||
|
||||
const { filePath: rootPath } = parseGitilesUrl(
|
||||
config,
|
||||
'https://gerrit.com/gitiles/web/project/+/refs/heads/master',
|
||||
);
|
||||
expect(rootPath).toEqual('/');
|
||||
});
|
||||
it('throws on incorrect gitiles urls.', () => {
|
||||
const config: GerritIntegrationConfig = {
|
||||
host: 'gerrit.com',
|
||||
gitilesBaseUrl: 'https://gerrit.com',
|
||||
};
|
||||
expect(() =>
|
||||
parseGitilesUrl(
|
||||
config,
|
||||
'https://gerrit.com/+/refs/heads/master/README.md',
|
||||
),
|
||||
).toThrow(/project/);
|
||||
expect(() =>
|
||||
parseGitilesUrl(
|
||||
config,
|
||||
'https://gerrit.com/web/project/+/refs/changes/1/11/master/README.md',
|
||||
),
|
||||
).toThrow(/branch/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGerritFileContentsApiUrl', () => {
|
||||
it('can create an url for anonymous access to the file fetch api.', () => {
|
||||
const config: GerritIntegrationConfig = {
|
||||
host: 'gerrit.com',
|
||||
baseUrl: 'https://gerrit.com',
|
||||
gitilesBaseUrl: 'https://gerrit.com',
|
||||
};
|
||||
const fileContentUrl = getGerritFileContentsApiUrl(
|
||||
config,
|
||||
'https://gerrit.com/web/project/+/refs/heads/master/README.md',
|
||||
);
|
||||
expect(fileContentUrl).toEqual(
|
||||
'https://gerrit.com/projects/web%2Fproject/branches/master/files/README.md/content',
|
||||
);
|
||||
});
|
||||
it('can create an url for authenticated access to the file fetch api.', () => {
|
||||
const authConfig: GerritIntegrationConfig = {
|
||||
host: 'gerrit.com',
|
||||
baseUrl: 'https://gerrit.com',
|
||||
gitilesBaseUrl: 'https://gerrit.com',
|
||||
username: 'u',
|
||||
password: 'u',
|
||||
};
|
||||
const authFileContentUrl = getGerritFileContentsApiUrl(
|
||||
authConfig,
|
||||
'https://gerrit.com/web/project/+/refs/heads/master/README.md',
|
||||
);
|
||||
expect(authFileContentUrl).toEqual(
|
||||
'https://gerrit.com/a/projects/web%2Fproject/branches/master/files/README.md/content',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseGerritJsonResponse', () => {
|
||||
it('can strip the magic prefix from the response.', async () => {
|
||||
const responseBody = ")]}'[]";
|
||||
const apiUrl = 'https://gerrit.com/projects/';
|
||||
worker.use(
|
||||
rest.get(apiUrl, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.set('Content-Type', 'application/json'),
|
||||
ctx.text(responseBody),
|
||||
),
|
||||
),
|
||||
);
|
||||
const response = await fetch(apiUrl, { method: 'GET' });
|
||||
const jsonData = await parseGerritJsonResponse(response);
|
||||
expect(jsonData).toEqual([]);
|
||||
});
|
||||
it('will throw if the magic prefix is missing from the response.', async () => {
|
||||
const responseBody = '[]';
|
||||
const apiUrl = 'https://gerrit.com/projects/';
|
||||
worker.use(
|
||||
rest.get(apiUrl, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.set('Content-Type', 'application/json'),
|
||||
ctx.text(responseBody),
|
||||
),
|
||||
),
|
||||
);
|
||||
const response = await fetch(apiUrl, { method: 'GET' });
|
||||
await expect(parseGerritJsonResponse(response)).rejects.toThrow(
|
||||
/body prefix missing/,
|
||||
);
|
||||
});
|
||||
it('will throw on invalid json with the magic prefix.', async () => {
|
||||
const responseBody = ")]}']{}[";
|
||||
const apiUrl = 'https://gerrit.com/projects/';
|
||||
worker.use(
|
||||
rest.get(apiUrl, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.set('Content-Type', 'application/json'),
|
||||
ctx.text(responseBody),
|
||||
),
|
||||
),
|
||||
);
|
||||
const response = await fetch(apiUrl, { method: 'GET' });
|
||||
await expect(parseGerritJsonResponse(response)).rejects.toThrow(
|
||||
/response from/,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
/*
|
||||
* Copyright 2022 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 { trimStart } from 'lodash';
|
||||
import { GerritIntegrationConfig } from '.';
|
||||
|
||||
const GERRIT_BODY_PREFIX = ")]}'";
|
||||
|
||||
type GitFile = {
|
||||
branch: string;
|
||||
filePath: string;
|
||||
project: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse a Gitiles URL and return branch, file path and project.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* Gerrit only handles code reviews so it does not have a native way to browse
|
||||
* or showing the content of gits. Image if Github only had the "pull requests"
|
||||
* tab.
|
||||
*
|
||||
* Any source code browsing is instead handled by optional services outside
|
||||
* Gerrit. The url format chosen for the Gerrit url reader is the one used by
|
||||
* the Gitiles project. Gerrit will work perfectly with Backstage without
|
||||
* having Gitiles installed but there are some places in the Backstage GUI
|
||||
* with links to the url used by the url reader. These will not work unless
|
||||
* the urls point to an actual Gitiles installation.
|
||||
*
|
||||
* Gitiles url:
|
||||
* https://g.com/optional_path/{project}/+/refs/heads/{branch}/{filePath}
|
||||
*
|
||||
*
|
||||
* @param url - An URL pointing to a file stored in git.
|
||||
* @public
|
||||
*/
|
||||
|
||||
export function parseGitilesUrl(
|
||||
config: GerritIntegrationConfig,
|
||||
url: string,
|
||||
): GitFile {
|
||||
const urlPath = url.replace(config.gitilesBaseUrl!, '');
|
||||
const parts = urlPath.split('/').filter(p => !!p);
|
||||
|
||||
const projectEndIndex = parts.indexOf('+');
|
||||
|
||||
if (projectEndIndex <= 0) {
|
||||
throw new Error(`Unable to parse project from url: ${url}`);
|
||||
}
|
||||
const project = trimStart(parts.slice(0, projectEndIndex).join('/'), '/');
|
||||
|
||||
const branchIndex = parts.indexOf('heads');
|
||||
if (branchIndex <= 0) {
|
||||
throw new Error(`Unable to parse branch from url: ${url}`);
|
||||
}
|
||||
const branch = parts[branchIndex + 1];
|
||||
const filePath = parts.slice(branchIndex + 2).join('/');
|
||||
|
||||
return {
|
||||
branch,
|
||||
filePath: filePath === '' ? '/' : filePath,
|
||||
project,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the authentication prefix.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* To authenticate with a password the API url must be prefixed with "/a/".
|
||||
* If no password is set anonymous access (without the prefix) will
|
||||
* be used.
|
||||
*
|
||||
* @param config - A Gerrit provider config.
|
||||
* @public
|
||||
*/
|
||||
export function getAuthenticationPrefix(
|
||||
config: GerritIntegrationConfig,
|
||||
): string {
|
||||
return config.password ? '/a/' : '/';
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the url to fetch the contents of a file using the Gerrit API.
|
||||
*
|
||||
* @param url - An url pointing to a file in git.
|
||||
* @public
|
||||
*/
|
||||
export function getGerritFileContentsApiUrl(
|
||||
config: GerritIntegrationConfig,
|
||||
url: string,
|
||||
) {
|
||||
const { branch, filePath, project } = parseGitilesUrl(config, url);
|
||||
|
||||
return `${config.baseUrl}${getAuthenticationPrefix(
|
||||
config,
|
||||
)}projects/${encodeURIComponent(
|
||||
project,
|
||||
)}/branches/${branch}/files/${encodeURIComponent(filePath)}/content`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return request headers for a Gerrit provider.
|
||||
*
|
||||
* @param config - A Gerrit provider config
|
||||
* @public
|
||||
*/
|
||||
export function getGerritRequestOptions(config: GerritIntegrationConfig): {
|
||||
headers?: Record<string, string>;
|
||||
} {
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
if (!config.password) {
|
||||
return headers;
|
||||
}
|
||||
const buffer = Buffer.from(`${config.username}:${config.password}`, 'utf8');
|
||||
headers.Authorization = `Basic ${buffer.toString('base64')}`;
|
||||
return {
|
||||
headers,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the json response from Gerrit and strip the magic prefix.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* To prevent against XSSI attacks the JSON response body from Gerrit starts
|
||||
* with a magic prefix that must be stripped before it can be fed to a JSON
|
||||
* parser.
|
||||
*
|
||||
* @param response - An API response.
|
||||
* @public
|
||||
*/
|
||||
export async function parseGerritJsonResponse(
|
||||
response: Response,
|
||||
): Promise<unknown> {
|
||||
const responseBody = await response.text();
|
||||
if (responseBody.startsWith(GERRIT_BODY_PREFIX)) {
|
||||
try {
|
||||
return JSON.parse(responseBody.slice(GERRIT_BODY_PREFIX.length));
|
||||
} catch (ex) {
|
||||
throw new Error(
|
||||
`Invalid response from Gerrit: ${responseBody.slice(0, 10)} - ${ex}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`Gerrit JSON body prefix missing. Found: ${responseBody.slice(0, 10)}`,
|
||||
);
|
||||
}
|
||||
@@ -18,4 +18,10 @@ export {
|
||||
readGerritIntegrationConfig,
|
||||
readGerritIntegrationConfigs,
|
||||
} from './config';
|
||||
export {
|
||||
getGerritFileContentsApiUrl,
|
||||
getGerritRequestOptions,
|
||||
parseGerritJsonResponse,
|
||||
} from './core';
|
||||
|
||||
export type { GerritIntegrationConfig } from './config';
|
||||
|
||||
Reference in New Issue
Block a user