GerritUrlReader added that implements "readUrl"
The url used by "readUrl" is supposed to be human friendly url that can
be opened in the browser. This makes the Gerrit configuration a bit more
complicated since there is no built in support to browse a git in Gerrit.
Instead this is handled by separate services that can be deployed at a
different host then the Gerrit instance. This implementation supports
Gitiles as the code browser tool. The dependency to Gitiles is optional,
it is perfectly possible to integrate Gerrit with Backstage but it will
not be possible to open the "human readable links" found in different
places of the Backstage gui.
A new optional config field has been added to Gerrit integration where
the base url of the Gitiles installation can be set. This is needed to
create a working link to some git content.
A new "url reader" has been added that reads content from Gerrit, as of
now only "readUrl" is implemented ("readTree" will soon follow). The
format of url is the format used by Gitiles. Gerrit's "readUrl" is
pretty simple, it's a single API call to Gerrit that returns the content
base64 encoded.
What is a bit different to other "urlReaders" is the predicate
function in that is not matching the host of the Gerrrit API. 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.
Signed-off-by: Niklas Aronsson <niklasar@axis.com>
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