Merge pull request #10263 from anicke/gerrit-urlreader

GerritUrlReader added that implements "readUrl"
This commit is contained in:
Fredrik Adelöw
2022-03-24 15:22:23 +01:00
committed by GitHub
14 changed files with 718 additions and 1 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-common': patch
---
Added the GerritUrlReader that implements "readUrl".
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/integration': minor
---
Gerrit integration: Added an optional configuration to set the Gitiles base url.
+1
View File
@@ -108,6 +108,7 @@ Francesco
Gerrit
gitbeaker
github
Gitiles
gitlab
GitLab
Gource
+18
View File
@@ -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';
+15
View File
@@ -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,
},
+19
View File
@@ -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/,
);
});
});
});
+165
View File
@@ -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)}`,
);
}
+6
View File
@@ -18,4 +18,10 @@ export {
readGerritIntegrationConfig,
readGerritIntegrationConfigs,
} from './config';
export {
getGerritFileContentsApiUrl,
getGerritRequestOptions,
parseGerritJsonResponse,
} from './core';
export type { GerritIntegrationConfig } from './config';