Merge branch 'master' into visibility

This commit is contained in:
James Brooks
2026-03-02 19:08:00 +00:00
349 changed files with 1629 additions and 7070 deletions
+1 -1
View File
@@ -49,7 +49,7 @@ app:
config:
showNavItemIcons: true
# default content order for all groups, can be 'title' or 'natural'
# contentOrder: title
# defaultContentOrder: title
groups:
# placing a tab at the beginning
- overview:
@@ -6,6 +6,7 @@
import { DatabaseService } from '@backstage/backend-plugin-api';
import { HttpRouterService } from '@backstage/backend-plugin-api';
import { LoggerService } from '@backstage/backend-plugin-api';
import { MetricsService } from '@backstage/backend-plugin-api/alpha';
import { PluginMetadataService } from '@backstage/backend-plugin-api';
import { RootLifecycleService } from '@backstage/backend-plugin-api';
import { SchedulerService } from '@backstage/backend-plugin-api';
@@ -17,6 +18,7 @@ export class DefaultSchedulerService {
static create(options: {
database: DatabaseService;
logger: LoggerService;
metrics: MetricsService;
rootLifecycle: RootLifecycleService;
httpRouter: HttpRouterService;
pluginMetadata: PluginMetadataService;
@@ -10,7 +10,6 @@ import { AzureCredentialsManager } from '@backstage/integration';
import { AzureDevOpsCredentialsProvider } from '@backstage/integration';
import { AzureIntegration } from '@backstage/integration';
import { BitbucketCloudIntegration } from '@backstage/integration';
import { BitbucketIntegration } from '@backstage/integration';
import { BitbucketServerIntegration } from '@backstage/integration';
import { Config } from '@backstage/config';
import { GerritIntegration } from '@backstage/integration';
@@ -190,38 +189,6 @@ export class BitbucketServerUrlReader implements UrlReaderService {
toString(): string;
}
// @public @deprecated
export class BitbucketUrlReader implements UrlReaderService {
constructor(
integration: BitbucketIntegration,
logger: LoggerService,
deps: {
treeResponseFactory: ReadTreeResponseFactory;
},
);
// (undocumented)
static factory: ReaderFactory;
// (undocumented)
read(url: string): Promise<Buffer>;
// (undocumented)
readTree(
url: string,
options?: UrlReaderServiceReadTreeOptions,
): Promise<UrlReaderServiceReadTreeResponse>;
// (undocumented)
readUrl(
url: string,
options?: UrlReaderServiceReadUrlOptions,
): Promise<UrlReaderServiceReadUrlResponse>;
// (undocumented)
search(
url: string,
options?: UrlReaderServiceSearchOptions,
): Promise<UrlReaderServiceSearchResponse>;
// (undocumented)
toString(): string;
}
// @public
export class FetchUrlReader implements UrlReaderService {
static factory: ReaderFactory;
@@ -20,6 +20,7 @@ import waitForExpect from 'wait-for-expect';
import { DefaultSchedulerService } from './DefaultSchedulerService';
import { createTestScopedSignal } from './__testUtils__/createTestScopedSignal';
import { PluginMetadataService } from '@backstage/backend-plugin-api';
import { metricsServiceMock } from '@backstage/backend-test-utils/alpha';
jest.setTimeout(60_000);
@@ -32,6 +33,7 @@ describe('TaskScheduler', () => {
getId: () => 'test',
} satisfies PluginMetadataService;
const testScopedSignal = createTestScopedSignal();
const metrics = metricsServiceMock.mock();
it.each(databases.eachSupportedId())(
'can return a working v1 plugin impl, %p',
@@ -42,6 +44,7 @@ describe('TaskScheduler', () => {
const manager = DefaultSchedulerService.create({
database,
logger,
metrics,
rootLifecycle,
httpRouter,
pluginMetadata,
@@ -71,6 +74,7 @@ describe('TaskScheduler', () => {
const manager = DefaultSchedulerService.create({
database,
logger,
metrics,
rootLifecycle,
httpRouter,
pluginMetadata,
@@ -27,6 +27,7 @@ import { Duration } from 'luxon';
import { migrateBackendTasks } from '../database/migrateBackendTasks';
import { PluginTaskSchedulerImpl } from './PluginTaskSchedulerImpl';
import { PluginTaskSchedulerJanitor } from './PluginTaskSchedulerJanitor';
import { MetricsService } from '@backstage/backend-plugin-api/alpha';
/**
* Default implementation of the task scheduler service.
@@ -37,6 +38,7 @@ export class DefaultSchedulerService {
static create(options: {
database: DatabaseService;
logger: LoggerService;
metrics: MetricsService;
rootLifecycle: RootLifecycleService;
httpRouter: HttpRouterService;
pluginMetadata: PluginMetadataService;
@@ -67,6 +69,7 @@ export class DefaultSchedulerService {
options.pluginMetadata.getId(),
databaseFactory,
options.logger,
options.metrics,
options.rootLifecycle,
);
@@ -27,6 +27,7 @@ import {
parseDuration,
} from './PluginTaskSchedulerImpl';
import { createDeferred } from '@backstage/types';
import { metricsServiceMock } from '@backstage/backend-test-utils/alpha';
jest.setTimeout(60_000);
@@ -56,6 +57,7 @@ describe('PluginTaskManagerImpl', () => {
'myplugin',
async () => knex,
mockServices.logger.mock(),
metricsServiceMock.mock(),
{
addShutdownHook,
addBeforeShutdownHook: jest.fn(),
@@ -24,7 +24,13 @@ import {
SchedulerServiceTaskRunner,
SchedulerServiceTaskScheduleDefinition,
} from '@backstage/backend-plugin-api';
import { Counter, Histogram, Gauge, metrics, trace } from '@opentelemetry/api';
import { trace } from '@opentelemetry/api';
import {
MetricsService,
MetricsServiceCounter,
MetricsServiceGauge,
MetricsServiceHistogram,
} from '@backstage/backend-plugin-api/alpha';
import { Knex } from 'knex';
import { Duration } from 'luxon';
import express from 'express';
@@ -45,10 +51,10 @@ export class PluginTaskSchedulerImpl implements SchedulerService {
private readonly allScheduledTasks: SchedulerServiceTaskDescriptor[] = [];
private readonly shutdownInitiated: Promise<boolean>;
private readonly counter: Counter;
private readonly duration: Histogram;
private readonly lastStarted: Gauge;
private readonly lastCompleted: Gauge;
private readonly counter: MetricsServiceCounter;
private readonly duration: MetricsServiceHistogram;
private readonly lastStarted: MetricsServiceGauge;
private readonly lastCompleted: MetricsServiceGauge;
private readonly pluginId: string;
private readonly databaseFactory: () => Promise<Knex>;
@@ -58,24 +64,27 @@ export class PluginTaskSchedulerImpl implements SchedulerService {
pluginId: string,
databaseFactory: () => Promise<Knex>,
logger: LoggerService,
metrics: MetricsService,
rootLifecycle: RootLifecycleService,
) {
this.pluginId = pluginId;
this.databaseFactory = databaseFactory;
this.logger = logger;
const meter = metrics.getMeter('default');
this.counter = meter.createCounter('backend_tasks.task.runs.count', {
this.counter = metrics.createCounter('backend_tasks.task.runs.count', {
description: 'Total number of times a task has been run',
});
this.duration = meter.createHistogram('backend_tasks.task.runs.duration', {
description: 'Histogram of task run durations',
unit: 'seconds',
});
this.lastStarted = meter.createGauge('backend_tasks.task.runs.started', {
this.duration = metrics.createHistogram(
'backend_tasks.task.runs.duration',
{
description: 'Histogram of task run durations',
unit: 'seconds',
},
);
this.lastStarted = metrics.createGauge('backend_tasks.task.runs.started', {
description: 'Epoch timestamp seconds when the task was last started',
unit: 'seconds',
});
this.lastCompleted = meter.createGauge(
this.lastCompleted = metrics.createGauge(
'backend_tasks.task.runs.completed',
{
description: 'Epoch timestamp seconds when the task was last completed',
@@ -18,6 +18,7 @@ import {
coreServices,
createServiceFactory,
} from '@backstage/backend-plugin-api';
import { metricsServiceRef } from '@backstage/backend-plugin-api/alpha';
import { DefaultSchedulerService } from './lib/DefaultSchedulerService';
/**
@@ -37,6 +38,7 @@ export const schedulerServiceFactory = createServiceFactory({
rootLifecycle: coreServices.rootLifecycle,
httpRouter: coreServices.httpRouter,
pluginMetadata: coreServices.pluginMetadata,
metrics: metricsServiceRef,
},
async factory({
database,
@@ -44,6 +46,7 @@ export const schedulerServiceFactory = createServiceFactory({
rootLifecycle,
httpRouter,
pluginMetadata,
metrics,
}) {
return DefaultSchedulerService.create({
database,
@@ -51,6 +54,7 @@ export const schedulerServiceFactory = createServiceFactory({
rootLifecycle,
httpRouter,
pluginMetadata,
metrics,
});
},
});
@@ -1,656 +0,0 @@
/*
* 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 { ConfigReader } from '@backstage/config';
import {
BitbucketIntegration,
readBitbucketIntegrationConfig,
} from '@backstage/integration';
import {
createMockDirectory,
mockServices,
registerMswTestHooks,
} from '@backstage/backend-test-utils';
import fs from 'fs-extra';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import path from 'node:path';
import { NotModifiedError } from '@backstage/errors';
import { BitbucketUrlReader } from './BitbucketUrlReader';
import { DefaultReadTreeResponseFactory } from './tree';
import getRawBody from 'raw-body';
import { UrlReaderServiceReadUrlResponse } from '@backstage/backend-plugin-api';
const logger = mockServices.logger.mock();
describe('BitbucketUrlReader.factory', () => {
it('only apply integration configs not inherited from bitbucketCloud or bitbucketServer', () => {
const config = new ConfigReader({
integrations: {
bitbucket: [],
bitbucketCloud: [
{
username: 'username',
appPassword: 'password',
},
],
bitbucketServer: [
{
host: 'bitbucket-server.local',
token: 'test-token',
},
],
},
});
const treeResponseFactory = DefaultReadTreeResponseFactory.create({
config: config,
});
const tuples = BitbucketUrlReader.factory({
config,
logger,
treeResponseFactory,
});
expect(tuples).toHaveLength(0);
});
});
describe('BitbucketUrlReader', () => {
const mockDir = createMockDirectory({ mockOsTmpDir: true });
beforeEach(mockDir.clear);
const treeResponseFactory = DefaultReadTreeResponseFactory.create({
config: new ConfigReader({}),
});
const bitbucketProcessor = new BitbucketUrlReader(
new BitbucketIntegration(
readBitbucketIntegrationConfig(
new ConfigReader({
host: 'bitbucket.org',
apiBaseUrl: 'https://api.bitbucket.org/2.0',
}),
),
),
logger,
{ treeResponseFactory },
);
const hostedBitbucketProcessor = new BitbucketUrlReader(
new BitbucketIntegration(
readBitbucketIntegrationConfig(
new ConfigReader({
host: 'bitbucket.mycompany.net',
apiBaseUrl: 'https://api.bitbucket.mycompany.net/rest/api/1.0',
}),
),
),
logger,
{ treeResponseFactory },
);
const worker = setupServer();
registerMswTestHooks(worker);
describe('readUrl', () => {
it('should be able to readUrl via buffer without ETag', async () => {
worker.use(
rest.get(
'https://api.bitbucket.org/2.0/repositories/backstage-verification/test-template/src/master/template.yaml',
(req, res, ctx) => {
expect(req.headers.get('If-None-Match')).toBeNull();
return res(
ctx.status(200),
ctx.body('foo'),
ctx.set('ETag', 'etag-value'),
);
},
),
);
const result = await bitbucketProcessor.readUrl(
'https://bitbucket.org/backstage-verification/test-template/src/master/template.yaml',
);
const buffer = await result.buffer();
expect(buffer.toString()).toBe('foo');
});
it('should be able to readUrl using provided token', async () => {
worker.use(
rest.get(
'https://api.bitbucket.org/2.0/repositories/backstage-verification/test-template/src/master/template.yaml',
(req, res, ctx) => {
expect(req.headers.get('authorization')).toBe(
'Bearer manual-token',
);
return res(ctx.status(200), ctx.body('foo'));
},
),
);
const result = await bitbucketProcessor.readUrl(
'https://bitbucket.org/backstage-verification/test-template/src/master/template.yaml',
{ token: 'manual-token' },
);
const buffer = await result.buffer();
expect(buffer.toString()).toBe('foo');
});
it('should be able to readUrl via stream without ETag', async () => {
worker.use(
rest.get(
'https://api.bitbucket.org/2.0/repositories/backstage-verification/test-template/src/master/template.yaml',
(req, res, ctx) => {
expect(req.headers.get('If-None-Match')).toBeNull();
return res(
ctx.status(200),
ctx.body('foo'),
ctx.set('ETag', 'etag-value'),
);
},
),
);
const result = await bitbucketProcessor.readUrl(
'https://bitbucket.org/backstage-verification/test-template/src/master/template.yaml',
);
const fromStream = await getRawBody(result.stream!());
expect(fromStream.toString()).toBe('foo');
});
it('should be able to readUrl with matching ETag', async () => {
worker.use(
rest.get(
'https://api.bitbucket.org/2.0/repositories/backstage-verification/test-template/src/master/template.yaml',
(req, res, ctx) => {
expect(req.headers.get('If-None-Match')).toBe(
'matching-etag-value',
);
return res(ctx.status(304));
},
),
);
await expect(
bitbucketProcessor.readUrl(
'https://bitbucket.org/backstage-verification/test-template/src/master/template.yaml',
{ etag: 'matching-etag-value' },
),
).rejects.toThrow(NotModifiedError);
});
it('should be able to readUrl without matching ETag', async () => {
worker.use(
rest.get(
'https://api.bitbucket.org/2.0/repositories/backstage-verification/test-template/src/master/template.yaml',
(req, res, ctx) => {
expect(req.headers.get('If-None-Match')).toBe(
'previous-etag-value',
);
return res(
ctx.status(200),
ctx.body('foo'),
ctx.set('ETag', 'new-etag-value'),
);
},
),
);
const result = await bitbucketProcessor.readUrl(
'https://bitbucket.org/backstage-verification/test-template/src/master/template.yaml',
{ etag: 'previous-etag-value' },
);
const buffer = await result.buffer();
expect(buffer.toString()).toBe('foo');
expect(result.etag).toBe('new-etag-value');
});
it('should be able to readUrl via buffer without If-Modified-Since', async () => {
worker.use(
rest.get(
'https://api.bitbucket.org/2.0/repositories/backstage-verification/test-template/src/master/template.yaml',
(req, res, ctx) => {
expect(req.headers.get('If-None-Match')).toBeNull();
return res(
ctx.status(200),
ctx.body('foo'),
ctx.set('ETag', 'etag-value'),
ctx.set(
'Last-Modified',
new Date('2020-01-01T00:00:00Z').toUTCString(),
),
);
},
),
);
const result = await bitbucketProcessor.readUrl(
'https://bitbucket.org/backstage-verification/test-template/src/master/template.yaml',
);
const buffer = await result.buffer();
expect(result.lastModifiedAt).toEqual(new Date('2020-01-01T00:00:00Z'));
expect(buffer.toString()).toBe('foo');
});
it('should be throw not modified when If-Modified-Since returns a 304', async () => {
worker.use(
rest.get(
'https://api.bitbucket.org/2.0/repositories/backstage-verification/test-template/src/master/template.yaml',
(req, res, ctx) => {
expect(req.headers.get('If-Modified-Since')).toBe(
new Date('1999 12 31 23:59:59 GMT').toUTCString(),
);
return res(ctx.status(304));
},
),
);
await expect(
bitbucketProcessor.readUrl(
'https://bitbucket.org/backstage-verification/test-template/src/master/template.yaml',
{ lastModifiedAfter: new Date('1999 12 31 23:59:59 GMT') },
),
).rejects.toThrow(NotModifiedError);
});
it('should be able to readUrl when If-Modified-Since is before Last-Modified', async () => {
worker.use(
rest.get(
'https://api.bitbucket.org/2.0/repositories/backstage-verification/test-template/src/master/template.yaml',
(req, res, ctx) => {
expect(req.headers.get('If-Modified-Since')).toBe(
new Date('1999 12 31 23:59:59 GMT').toUTCString(),
);
return res(
ctx.status(200),
ctx.set(
'Last-Modified',
new Date('2020-01-01T00:00:00Z').toUTCString(),
),
ctx.body('foo'),
);
},
),
);
const result = await bitbucketProcessor.readUrl(
'https://bitbucket.org/backstage-verification/test-template/src/master/template.yaml',
{ lastModifiedAfter: new Date('1999 12 31 23:59:59 GMT') },
);
const buffer = await result.buffer();
expect(buffer.toString()).toBe('foo');
expect(result.lastModifiedAt).toEqual(new Date('2020-01-01T00:00:00Z'));
});
});
describe('read', () => {
it('rejects unknown targets', async () => {
await expect(
bitbucketProcessor.read('https://not.bitbucket.com/apa'),
).rejects.toThrow(
'Incorrect URL: https://not.bitbucket.com/apa, Error: Invalid Bitbucket URL or file path',
);
});
});
describe('readTree', () => {
const repoBuffer = fs.readFileSync(
path.resolve(
__dirname,
'__fixtures__/bitbucket-repo-with-commit-hash.tar.gz',
),
);
const privateBitbucketRepoBuffer = fs.readFileSync(
path.resolve(__dirname, '__fixtures__/bitbucket-server-repo.tar.gz'),
);
beforeEach(() => {
worker.use(
rest.get(
'https://api.bitbucket.org/2.0/repositories/backstage/mock',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
mainbranch: {
type: 'branch',
name: 'master',
},
}),
),
),
rest.get(
'https://bitbucket.org/backstage/mock/get/master.tar.gz',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/zip'),
ctx.set(
'content-disposition',
'attachment; filename=backstage-mock-12ab34cd56ef.tar.gz',
),
ctx.body(new Uint8Array(repoBuffer)),
),
),
rest.get(
'https://api.bitbucket.org/2.0/repositories/backstage/mock/commits/master',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
values: [{ hash: '12ab34cd56ef78gh90ij12kl34mn56op78qr90st' }],
}),
),
),
rest.get(
'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/archive',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/zip'),
ctx.set(
'content-disposition',
'attachment; filename=backstage-mock.tgz',
),
ctx.body(new Uint8Array(privateBitbucketRepoBuffer)),
),
),
rest.get(
'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/commits',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
values: [{ id: '12ab34cd56ef78gh90ij12kl34mn56op78qr90st' }],
}),
),
),
);
});
it('returns the wanted files from an archive', async () => {
const response = await bitbucketProcessor.readTree(
'https://bitbucket.org/backstage/mock/src/master',
);
expect(response.etag).toBe('12ab34cd56ef');
const files = await response.files();
expect(files.length).toBe(2);
const mkDocsFile = await files[0].content();
const indexMarkdownFile = await files[1].content();
expect(indexMarkdownFile.toString()).toBe('# Test\n');
expect(mkDocsFile.toString()).toBe('site_name: Test\n');
});
it('creates a directory with the wanted files', async () => {
const response = await bitbucketProcessor.readTree(
'https://bitbucket.org/backstage/mock',
);
const dir = await response.dir({ targetDir: mockDir.path });
await expect(
fs.readFile(path.join(dir, 'mkdocs.yml'), 'utf8'),
).resolves.toBe('site_name: Test\n');
await expect(
fs.readFile(path.join(dir, 'docs', 'index.md'), 'utf8'),
).resolves.toBe('# Test\n');
});
it('uses private bitbucket host', async () => {
const response = await hostedBitbucketProcessor.readTree(
'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/docs?at=some-branch',
);
expect(response.etag).toBe('12ab34cd56ef');
const files = await response.files();
expect(files.length).toBe(1);
const indexMarkdownFile = await files[0].content();
expect(indexMarkdownFile.toString()).toBe('# Test\n');
});
it('returns the wanted files from an archive with a subpath', async () => {
const response = await bitbucketProcessor.readTree(
'https://bitbucket.org/backstage/mock/src/master/docs',
);
expect(response.etag).toBe('12ab34cd56ef');
const files = await response.files();
expect(files.length).toBe(1);
const indexMarkdownFile = await files[0].content();
expect(indexMarkdownFile.toString()).toBe('# Test\n');
});
it('creates a directory with the wanted files with a subpath', async () => {
const response = await bitbucketProcessor.readTree(
'https://bitbucket.org/backstage/mock/src/master/docs',
);
const dir = await response.dir({ targetDir: mockDir.path });
await expect(
fs.readFile(path.join(dir, 'index.md'), 'utf8'),
).resolves.toBe('# Test\n');
});
it('throws a NotModifiedError when given a etag in options', async () => {
const fnBitbucket = async () => {
await bitbucketProcessor.readTree(
'https://bitbucket.org/backstage/mock',
{ etag: '12ab34cd56ef' },
);
};
await expect(fnBitbucket).rejects.toThrow(NotModifiedError);
});
it('should not throw a NotModifiedError when given an outdated etag in options', async () => {
const response = await bitbucketProcessor.readTree(
'https://bitbucket.org/backstage/mock',
{ etag: 'outdatedetag123abc' },
);
expect(response.etag).toBe('12ab34cd56ef');
});
});
describe('search hosted', () => {
const repoBuffer = fs.readFileSync(
path.resolve(
__dirname,
'__fixtures__/bitbucket-repo-with-commit-hash.tar.gz',
),
);
beforeEach(() => {
worker.use(
rest.get(
'https://api.bitbucket.org/2.0/repositories/backstage/mock',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
mainbranch: {
type: 'branch',
name: 'master',
},
}),
),
),
rest.get(
'https://bitbucket.org/backstage/mock/get/master.tar.gz',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/zip'),
ctx.set(
'content-disposition',
'attachment; filename=backstage-mock-12ab34cd56ef.tar.gz',
),
ctx.body(new Uint8Array(repoBuffer)),
),
),
rest.get(
'https://api.bitbucket.org/2.0/repositories/backstage/mock/commits/master',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
values: [{ hash: '12ab34cd56ef78gh90ij12kl34mn56op78qr90st' }],
}),
),
),
);
});
it('works for the naive case', async () => {
const result = await bitbucketProcessor.search(
'https://bitbucket.org/backstage/mock/src/master/**/index.*',
);
expect(result.etag).toBe('12ab34cd56ef');
expect(result.files.length).toBe(1);
expect(result.files[0].url).toBe(
'https://bitbucket.org/backstage/mock/src/master/docs/index.md',
);
await expect(result.files[0].content()).resolves.toEqual(
Buffer.from('# Test\n'),
);
});
it('works in nested folders', async () => {
const result = await bitbucketProcessor.search(
'https://bitbucket.org/backstage/mock/src/master/docs/index.*',
);
expect(result.etag).toBe('12ab34cd56ef');
expect(result.files.length).toBe(1);
expect(result.files[0].url).toBe(
'https://bitbucket.org/backstage/mock/src/master/docs/index.md',
);
await expect(result.files[0].content()).resolves.toEqual(
Buffer.from('# Test\n'),
);
});
it('throws NotModifiedError when same etag', async () => {
await expect(
bitbucketProcessor.search(
'https://bitbucket.org/backstage/mock/src/master/**/index.*',
{ etag: '12ab34cd56ef' },
),
).rejects.toThrow(NotModifiedError);
});
});
describe('search private', () => {
const privateBitbucketRepoBuffer = fs.readFileSync(
path.resolve(__dirname, '__fixtures__/bitbucket-server-repo.tar.gz'),
);
beforeEach(() => {
worker.use(
rest.get(
'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/archive',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/zip'),
ctx.set(
'content-disposition',
'attachment; filename=backstage-mock.tgz',
),
ctx.body(new Uint8Array(privateBitbucketRepoBuffer)),
),
),
rest.get(
'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/commits',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
values: [{ id: '12ab34cd56ef78gh90ij12kl34mn56op78qr90st' }],
}),
),
),
);
});
it('works for the naive case', async () => {
const result = await hostedBitbucketProcessor.search(
'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/**/index.*?at=master',
);
expect(result.etag).toBe('12ab34cd56ef');
expect(result.files.length).toBe(1);
expect(result.files[0].url).toBe(
'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/docs/index.md?at=master',
);
await expect(result.files[0].content()).resolves.toEqual(
Buffer.from('# Test\n'),
);
});
it('works in nested folders', async () => {
const result = await hostedBitbucketProcessor.search(
'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/docs/index.*?at=master',
);
expect(result.etag).toBe('12ab34cd56ef');
expect(result.files.length).toBe(1);
expect(result.files[0].url).toBe(
'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/docs/index.md?at=master',
);
await expect(result.files[0].content()).resolves.toEqual(
Buffer.from('# Test\n'),
);
});
it('throws NotModifiedError when same etag', async () => {
await expect(
hostedBitbucketProcessor.search(
'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/**/index.*?at=master',
{ etag: '12ab34cd56ef' },
),
).rejects.toThrow(NotModifiedError);
});
it('should work for exact URLs', async () => {
hostedBitbucketProcessor.readUrl = jest.fn().mockResolvedValue({
buffer: async () => Buffer.from('content'),
etag: 'etag',
} as UrlReaderServiceReadUrlResponse);
const result = await hostedBitbucketProcessor.search(
'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/docs/index.md?at=master',
);
expect(result.etag).toBe('etag');
expect(result.files.length).toBe(1);
expect(result.files[0].url).toBe(
'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/docs/index.md?at=master',
);
expect((await result.files[0].content()).toString()).toEqual('content');
});
});
});
@@ -1,313 +0,0 @@
/*
* 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 {
UrlReaderService,
UrlReaderServiceReadTreeOptions,
UrlReaderServiceReadTreeResponse,
UrlReaderServiceReadUrlOptions,
UrlReaderServiceReadUrlResponse,
UrlReaderServiceSearchOptions,
UrlReaderServiceSearchResponse,
} from '@backstage/backend-plugin-api';
import {
assertError,
NotFoundError,
NotModifiedError,
} from '@backstage/errors';
import {
BitbucketIntegration,
getBitbucketDefaultBranch,
getBitbucketDownloadUrl,
getBitbucketFileFetchUrl,
getBitbucketRequestOptions,
ScmIntegrations,
} from '@backstage/integration';
import parseGitUrl from 'git-url-parse';
import { trimEnd } from 'lodash';
import { Minimatch } from 'minimatch';
import { LoggerService } from '@backstage/backend-plugin-api';
import { ReaderFactory, ReadTreeResponseFactory } from './types';
import { ReadUrlResponseFactory } from './ReadUrlResponseFactory';
/**
* Implements a {@link @backstage/backend-plugin-api#UrlReaderService} for files from Bitbucket v1 and v2 APIs, such
* as the one exposed by Bitbucket Cloud itself.
*
* @public
* @deprecated in favor of BitbucketCloudUrlReader and BitbucketServerUrlReader
*/
export class BitbucketUrlReader implements UrlReaderService {
static factory: ReaderFactory = ({ config, logger, treeResponseFactory }) => {
const integrations = ScmIntegrations.fromConfig(config);
return integrations.bitbucket
.list()
.filter(
item =>
!integrations.bitbucketCloud.byHost(item.config.host) &&
!integrations.bitbucketServer.byHost(item.config.host),
)
.map(integration => {
const reader = new BitbucketUrlReader(integration, logger, {
treeResponseFactory,
});
const predicate = (url: URL) => url.host === integration.config.host;
return { reader, predicate };
});
};
private readonly integration: BitbucketIntegration;
private readonly deps: { treeResponseFactory: ReadTreeResponseFactory };
constructor(
integration: BitbucketIntegration,
logger: LoggerService,
deps: { treeResponseFactory: ReadTreeResponseFactory },
) {
this.integration = integration;
this.deps = deps;
const { host, token, username, appPassword } = integration.config;
const replacement =
host === 'bitbucket.org' ? 'bitbucketCloud' : 'bitbucketServer';
logger.warn(
`[Deprecated] Please migrate from "integrations.bitbucket" to "integrations.${replacement}".`,
);
if (!token && username && !appPassword) {
throw new Error(
`Bitbucket integration for '${host}' has configured a username but is missing a required appPassword.`,
);
}
}
async read(url: string): Promise<Buffer> {
const response = await this.readUrl(url);
return response.buffer();
}
private getCredentials = async (options?: {
token?: string;
}): Promise<{ headers: Record<string, string> }> => {
if (options?.token) {
return {
headers: {
Authorization: `Bearer ${options.token}`,
},
};
}
return await getBitbucketRequestOptions(this.integration.config);
};
async readUrl(
url: string,
options?: UrlReaderServiceReadUrlOptions,
): Promise<UrlReaderServiceReadUrlResponse> {
const { etag, lastModifiedAfter, signal } = options ?? {};
const bitbucketUrl = getBitbucketFileFetchUrl(url, this.integration.config);
const requestOptions = await this.getCredentials(options);
let response: Response;
try {
response = await fetch(bitbucketUrl.toString(), {
headers: {
...requestOptions.headers,
...(etag && { 'If-None-Match': etag }),
...(lastModifiedAfter && {
'If-Modified-Since': lastModifiedAfter.toUTCString(),
}),
},
// 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 && { signal: signal as any }),
});
} catch (e) {
throw new Error(`Unable to read ${url}, ${e}`);
}
if (response.status === 304) {
throw new NotModifiedError();
}
if (response.ok) {
return ReadUrlResponseFactory.fromResponse(response);
}
const message = `${url} could not be read as ${bitbucketUrl}, ${response.status} ${response.statusText}`;
if (response.status === 404) {
throw new NotFoundError(message);
}
throw new Error(message);
}
async readTree(
url: string,
options?: UrlReaderServiceReadTreeOptions,
): Promise<UrlReaderServiceReadTreeResponse> {
const { filepath } = parseGitUrl(url);
const lastCommitShortHash = await this.getLastCommitShortHash(url);
if (options?.etag && options.etag === lastCommitShortHash) {
throw new NotModifiedError();
}
const downloadUrl = await getBitbucketDownloadUrl(
url,
this.integration.config,
);
const archiveBitbucketResponse = await fetch(
downloadUrl,
getBitbucketRequestOptions(this.integration.config),
);
if (!archiveBitbucketResponse.ok) {
const message = `Failed to read tree from ${url}, ${archiveBitbucketResponse.status} ${archiveBitbucketResponse.statusText}`;
if (archiveBitbucketResponse.status === 404) {
throw new NotFoundError(message);
}
throw new Error(message);
}
return await this.deps.treeResponseFactory.fromTarArchive({
response: archiveBitbucketResponse,
subpath: filepath,
etag: lastCommitShortHash,
filter: options?.filter,
});
}
async search(
url: string,
options?: UrlReaderServiceSearchOptions,
): Promise<UrlReaderServiceSearchResponse> {
const { filepath } = parseGitUrl(url);
// If it's a direct URL we use readUrl instead
if (!filepath?.match(/[*?]/)) {
try {
const data = await this.readUrl(url, options);
return {
files: [
{
url: url,
content: data.buffer,
lastModifiedAt: data.lastModifiedAt,
},
],
etag: data.etag ?? '',
};
} catch (error) {
assertError(error);
if (error.name === 'NotFoundError') {
return {
files: [],
etag: '',
};
}
throw error;
}
}
const matcher = new Minimatch(filepath);
// TODO(freben): For now, read the entire repo and filter through that. In
// a future improvement, we could be smart and try to deduce that non-glob
// prefixes (like for filepaths such as some-prefix/**/a.yaml) can be used
// to get just that part of the repo.
const treeUrl = trimEnd(url.replace(filepath, ''), '/');
const tree = await this.readTree(treeUrl, {
etag: options?.etag,
filter: path => matcher.match(path),
});
const files = await tree.files();
return {
etag: tree.etag,
files: files.map(file => ({
url: this.integration.resolveUrl({
url: `/${file.path}`,
base: url,
}),
content: file.content,
lastModifiedAt: file.lastModifiedAt,
})),
};
}
toString() {
const { host, token, username, appPassword } = this.integration.config;
let authed = Boolean(token);
if (!authed) {
authed = Boolean(username && appPassword);
}
return `bitbucket{host=${host},authed=${authed}}`;
}
private async getLastCommitShortHash(url: string): Promise<string> {
const { resource, name: repoName, owner: project, ref } = parseGitUrl(url);
let branch = ref;
if (!branch) {
branch = await getBitbucketDefaultBranch(url, this.integration.config);
}
const isHosted = resource === 'bitbucket.org';
// Bitbucket Server https://docs.atlassian.com/bitbucket-server/rest/7.9.0/bitbucket-rest.html#idp222
const commitsApiUrl = isHosted
? `${this.integration.config.apiBaseUrl}/repositories/${project}/${repoName}/commits/${branch}`
: `${this.integration.config.apiBaseUrl}/projects/${project}/repos/${repoName}/commits`;
const commitsResponse = await fetch(
commitsApiUrl,
getBitbucketRequestOptions(this.integration.config),
);
if (!commitsResponse.ok) {
const message = `Failed to retrieve commits from ${commitsApiUrl}, ${commitsResponse.status} ${commitsResponse.statusText}`;
if (commitsResponse.status === 404) {
throw new NotFoundError(message);
}
throw new Error(message);
}
const commits = await commitsResponse.json();
if (isHosted) {
if (
commits &&
commits.values &&
commits.values.length > 0 &&
commits.values[0].hash
) {
return commits.values[0].hash.substring(0, 12);
}
} else {
if (
commits &&
commits.values &&
commits.values.length > 0 &&
commits.values[0].id
) {
return commits.values[0].id.substring(0, 12);
}
}
throw new Error(`Failed to read response from ${commitsApiUrl}`);
}
}
@@ -24,7 +24,6 @@ import { UrlReaderPredicateMux } from './UrlReaderPredicateMux';
import { AzureUrlReader } from './AzureUrlReader';
import { BitbucketCloudUrlReader } from './BitbucketCloudUrlReader';
import { BitbucketServerUrlReader } from './BitbucketServerUrlReader';
import { BitbucketUrlReader } from './BitbucketUrlReader';
import { GerritUrlReader } from './GerritUrlReader';
import { GithubUrlReader } from './GithubUrlReader';
import { GitlabUrlReader } from './GitlabUrlReader';
@@ -92,7 +91,6 @@ export class UrlReaders {
AzureUrlReader.factory,
BitbucketCloudUrlReader.factory,
BitbucketServerUrlReader.factory,
BitbucketUrlReader.factory,
GerritUrlReader.factory,
GithubUrlReader.factory,
GiteaUrlReader.factory,
@@ -16,7 +16,6 @@
export { AzureUrlReader } from './AzureUrlReader';
export { BitbucketCloudUrlReader } from './BitbucketCloudUrlReader';
export { BitbucketUrlReader } from './BitbucketUrlReader';
export { BitbucketServerUrlReader } from './BitbucketServerUrlReader';
export { GerritUrlReader } from './GerritUrlReader';
export { GithubUrlReader } from './GithubUrlReader';
@@ -1,4 +1,4 @@
openapi: 3.0.3
openapi: 3.1.0
info:
title: .backstage/dynamic-features
version: '1'
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 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.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 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.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 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.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 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.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 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.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 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.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 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.
@@ -26,7 +26,6 @@ import { ErrorResponse } from '../models/ErrorResponse.model';
*/
export interface ModelError {
[key: string]: any;
error: ErrorError;
request?: ErrorRequest;
response: ErrorResponse;
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 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.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 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.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 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.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 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.
@@ -21,7 +21,7 @@ import { createValidatedOpenApiRouterFromGeneratedEndpointMap } from '@backstage
import { EndpointMap } from './apis';
export const spec = {
openapi: '3.0.3',
openapi: '3.1.0',
info: {
title: '.backstage/dynamic-features',
version: '1',
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 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.
+2
View File
@@ -4,6 +4,8 @@
This package is meant to provide a typed Express router for an OpenAPI spec. Based on the [`oatx`](https://github.com/varanauskas/oatx) library and adapted to override Express values.
Only supports OpenAPI 3.1 specifications.
## Getting Started
### Configuration
@@ -54,6 +54,7 @@ export function getOpenApiSpecRoute(baseUrl: string) {
/**
* Create a router with validation middleware. This is used by typing methods to create an
* "OpenAPI router" with all of the expected validation + metadata.
* Only supports OpenAPI 3.1 specifications.
* @param spec - Your OpenAPI spec imported as a JSON object.
* @param validatorOptions - `openapi-express-validator` options to override the defaults.
* @returns A new express router with validation middleware.
@@ -115,6 +116,7 @@ function createRouterWithValidation(
/**
* Create a new OpenAPI router with some default middleware.
* Only supports OpenAPI 3.1 specifications.
* @param spec - Your OpenAPI spec imported as a JSON object.
* @param validatorOptions - `openapi-express-validator` options to override the defaults.
* @returns A new express router with validation middleware.
@@ -132,6 +134,7 @@ export function createValidatedOpenApiRouter<T extends RequiredDoc>(
/**
* Create a new OpenAPI router with some default middleware.
* Only supports OpenAPI 3.1 specifications.
* @param spec - Your OpenAPI spec imported as a JSON object.
* @param validatorOptions - `openapi-express-validator` options to override the defaults.
* @returns A new express router with validation middleware.
@@ -260,9 +260,9 @@ export class DefaultApiClient {
/**
* Get all entities matching a given filter.
* @param fields - By default the full entities are returned, but you can pass in a &#x60;fields&#x60; query parameter which selects what parts of the entity data to retain. This makes the response smaller and faster to transfer, and may allow the catalog to perform more efficient queries. The query parameter value is a comma separated list of simplified JSON paths like above. Each path corresponds to the key of either a value, or of a subtree root that you want to keep in the output. The rest is pruned away. For example, specifying &#x60;?fields&#x3D;metadata.name,metadata.annotations,spec&#x60; retains only the &#x60;name&#x60; and &#x60;annotations&#x60; fields of the &#x60;metadata&#x60; of each entity (it&#39;ll be an object with at most two keys), keeps the entire &#x60;spec&#x60; unchanged, and cuts out all other roots such as &#x60;relations&#x60;. Some more real world usable examples: - Return only enough data to form the full ref of each entity: &#x60;/entities/by-query?fields&#x3D;kind,metadata.namespace,metadata.name&#x60;
* @param fields - By default the full entities are returned, but you can pass in a &#x60;fields&#x60; query parameter which selects what parts of the entity data to retain. This makes the response smaller and faster to transfer, and may allow the catalog to perform more efficient queries. The query parameter value is a comma separated list of simplified JSON paths like above. Each path corresponds to the key of either a value, or of a subtree root that you want to keep in the output. The rest is pruned away. For example, specifying &#x60;?fields&#x3D;metadata.name,metadata.annotations,spec&#x60; retains only the &#x60;name&#x60; and &#x60;annotations&#x60; fields of the &#x60;metadata&#x60; of each entity (it\&#39;ll be an object with at most two keys), keeps the entire &#x60;spec&#x60; unchanged, and cuts out all other roots such as &#x60;relations&#x60;. Some more real world usable examples: - Return only enough data to form the full ref of each entity: &#x60;/entities/by-query?fields&#x3D;kind,metadata.namespace,metadata.name&#x60;
* @param limit - Number of records to return in the response.
* @param filter - You can pass in one or more filter sets that get matched against each entity. Each filter set is a number of conditions that all have to match for the condition to be true (conditions effectively have an AND between them). At least one filter set has to be true for the entity to be part of the result set (filter sets effectively have an OR between them). Example: &#x60;&#x60;&#x60;text /entities/by-query?filter&#x3D;kind&#x3D;user,metadata.namespace&#x3D;default&amp;filter&#x3D;kind&#x3D;group,spec.type Return entities that match Filter set 1: Condition 1: kind &#x3D; user AND Condition 2: metadata.namespace &#x3D; default OR Filter set 2: Condition 1: kind &#x3D; group AND Condition 2: spec.type exists &#x60;&#x60;&#x60; Each condition is either on the form &#x60;&lt;key&gt;&#x60;, or on the form &#x60;&lt;key&gt;&#x3D;&lt;value&gt;&#x60;. The first form asserts on the existence of a certain key (with any value), and the second asserts that the key exists and has a certain value. All checks are always case _insensitive_. In all cases, the key is a simplified JSON path in a given piece of entity data. Each part of the path is a key of an object, and the traversal also descends through arrays. There are two special forms: - Array items that are simple value types (such as strings) match on a key-value pair where the key is the item as a string, and the value is the string &#x60;true&#x60; - Relations can be matched on a &#x60;relations.&lt;type&gt;&#x3D;&lt;targetRef&gt;&#x60; form Let&#39;s look at a simplified example to illustrate the concept: &#x60;&#x60;&#x60;json { \&quot;a\&quot;: { \&quot;b\&quot;: [\&quot;c\&quot;, { \&quot;d\&quot;: 1 }], \&quot;e\&quot;: 7 } } &#x60;&#x60;&#x60; This would match any one of the following conditions: - &#x60;a&#x60; - &#x60;a.b&#x60; - &#x60;a.b.c&#x60; - &#x60;a.b.c&#x3D;true&#x60; - &#x60;a.b.d&#x60; - &#x60;a.b.d&#x3D;1&#x60; - &#x60;a.e&#x60; - &#x60;a.e&#x3D;7&#x60; Some more real world usable examples: - Return all orphaned entities: &#x60;/entities/by-query?filter&#x3D;metadata.annotations.backstage.io/orphan&#x3D;true&#x60; - Return all users and groups: &#x60;/entities/by-query?filter&#x3D;kind&#x3D;user&amp;filter&#x3D;kind&#x3D;group&#x60; - Return all service components: &#x60;/entities/by-query?filter&#x3D;kind&#x3D;component,spec.type&#x3D;service&#x60; - Return all entities with the &#x60;java&#x60; tag: &#x60;/entities/by-query?filter&#x3D;metadata.tags.java&#x60; - Return all users who are members of the &#x60;ops&#x60; group (note that the full [reference](references.md) of the group is used): &#x60;/entities/by-query?filter&#x3D;kind&#x3D;user,relations.memberof&#x3D;group:default/ops&#x60;
* @param filter - You can pass in one or more filter sets that get matched against each entity. Each filter set is a number of conditions that all have to match for the condition to be true (conditions effectively have an AND between them). At least one filter set has to be true for the entity to be part of the result set (filter sets effectively have an OR between them). Example: &#x60;&#x60;&#x60;text /entities/by-query?filter&#x3D;kind&#x3D;user,metadata.namespace&#x3D;default&amp;filter&#x3D;kind&#x3D;group,spec.type Return entities that match Filter set 1: Condition 1: kind &#x3D; user AND Condition 2: metadata.namespace &#x3D; default OR Filter set 2: Condition 1: kind &#x3D; group AND Condition 2: spec.type exists &#x60;&#x60;&#x60; Each condition is either on the form &#x60;&lt;key&gt;&#x60;, or on the form &#x60;&lt;key&gt;&#x3D;&lt;value&gt;&#x60;. The first form asserts on the existence of a certain key (with any value), and the second asserts that the key exists and has a certain value. All checks are always case _insensitive_. In all cases, the key is a simplified JSON path in a given piece of entity data. Each part of the path is a key of an object, and the traversal also descends through arrays. There are two special forms: - Array items that are simple value types (such as strings) match on a key-value pair where the key is the item as a string, and the value is the string &#x60;true&#x60; - Relations can be matched on a &#x60;relations.&lt;type&gt;&#x3D;&lt;targetRef&gt;&#x60; form Let\&#39;s look at a simplified example to illustrate the concept: &#x60;&#x60;&#x60;json { \&quot;a\&quot;: { \&quot;b\&quot;: [\&quot;c\&quot;, { \&quot;d\&quot;: 1 }], \&quot;e\&quot;: 7 } } &#x60;&#x60;&#x60; This would match any one of the following conditions: - &#x60;a&#x60; - &#x60;a.b&#x60; - &#x60;a.b.c&#x60; - &#x60;a.b.c&#x3D;true&#x60; - &#x60;a.b.d&#x60; - &#x60;a.b.d&#x3D;1&#x60; - &#x60;a.e&#x60; - &#x60;a.e&#x3D;7&#x60; Some more real world usable examples: - Return all orphaned entities: &#x60;/entities/by-query?filter&#x3D;metadata.annotations.backstage.io/orphan&#x3D;true&#x60; - Return all users and groups: &#x60;/entities/by-query?filter&#x3D;kind&#x3D;user&amp;filter&#x3D;kind&#x3D;group&#x60; - Return all service components: &#x60;/entities/by-query?filter&#x3D;kind&#x3D;component,spec.type&#x3D;service&#x60; - Return all entities with the &#x60;java&#x60; tag: &#x60;/entities/by-query?filter&#x3D;metadata.tags.java&#x60; - Return all users who are members of the &#x60;ops&#x60; group (note that the full [reference](references.md) of the group is used): &#x60;/entities/by-query?filter&#x3D;kind&#x3D;user,relations.memberof&#x3D;group:default/ops&#x60;
* @param offset - Number of records to skip in the query page.
* @param after - Pointer to the previous page of results.
* @param order -
@@ -291,12 +291,12 @@ export class DefaultApiClient {
/**
* Search for entities by a given query.
* @param fields - By default the full entities are returned, but you can pass in a &#x60;fields&#x60; query parameter which selects what parts of the entity data to retain. This makes the response smaller and faster to transfer, and may allow the catalog to perform more efficient queries. The query parameter value is a comma separated list of simplified JSON paths like above. Each path corresponds to the key of either a value, or of a subtree root that you want to keep in the output. The rest is pruned away. For example, specifying &#x60;?fields&#x3D;metadata.name,metadata.annotations,spec&#x60; retains only the &#x60;name&#x60; and &#x60;annotations&#x60; fields of the &#x60;metadata&#x60; of each entity (it&#39;ll be an object with at most two keys), keeps the entire &#x60;spec&#x60; unchanged, and cuts out all other roots such as &#x60;relations&#x60;. Some more real world usable examples: - Return only enough data to form the full ref of each entity: &#x60;/entities/by-query?fields&#x3D;kind,metadata.namespace,metadata.name&#x60;
* @param fields - By default the full entities are returned, but you can pass in a &#x60;fields&#x60; query parameter which selects what parts of the entity data to retain. This makes the response smaller and faster to transfer, and may allow the catalog to perform more efficient queries. The query parameter value is a comma separated list of simplified JSON paths like above. Each path corresponds to the key of either a value, or of a subtree root that you want to keep in the output. The rest is pruned away. For example, specifying &#x60;?fields&#x3D;metadata.name,metadata.annotations,spec&#x60; retains only the &#x60;name&#x60; and &#x60;annotations&#x60; fields of the &#x60;metadata&#x60; of each entity (it\&#39;ll be an object with at most two keys), keeps the entire &#x60;spec&#x60; unchanged, and cuts out all other roots such as &#x60;relations&#x60;. Some more real world usable examples: - Return only enough data to form the full ref of each entity: &#x60;/entities/by-query?fields&#x3D;kind,metadata.namespace,metadata.name&#x60;
* @param limit - Number of records to return in the response.
* @param offset - Number of records to skip in the query page.
* @param orderField - By default the entities are returned ordered by their internal uid. You can customize the &#x60;orderField&#x60; query parameters to affect that ordering. For example, to return entities by their name: &#x60;/entities/by-query?orderField&#x3D;metadata.name,asc&#x60; Each parameter can be followed by &#x60;asc&#x60; for ascending lexicographical order or &#x60;desc&#x60; for descending (reverse) lexicographical order.
* @param cursor - You may pass the &#x60;cursor&#x60; query parameters to perform cursor based pagination through the set of entities. The value of &#x60;cursor&#x60; will be returned in the response, under the &#x60;pageInfo&#x60; property: &#x60;&#x60;&#x60;json \&quot;pageInfo\&quot;: { \&quot;nextCursor\&quot;: \&quot;a-cursor\&quot;, \&quot;prevCursor\&quot;: \&quot;another-cursor\&quot; } &#x60;&#x60;&#x60; If &#x60;nextCursor&#x60; exists, it can be used to retrieve the next batch of entities. Following the same approach, if &#x60;prevCursor&#x60; exists, it can be used to retrieve the previous batch of entities. - [&#x60;filter&#x60;](#filtering), for selecting only a subset of all entities - [&#x60;fields&#x60;](#field-selection), for selecting only parts of the full data structure of each entity - &#x60;limit&#x60; for limiting the number of entities returned (20 is the default) - [&#x60;orderField&#x60;](#ordering), for deciding the order of the entities - &#x60;fullTextFilter&#x60; **NOTE**: [&#x60;filter&#x60;, &#x60;orderField&#x60;, &#x60;fullTextFilter&#x60;] and &#x60;cursor&#x60; are mutually exclusive. This means that, it isn&#39;t possible to change any of [&#x60;filter&#x60;, &#x60;orderField&#x60;, &#x60;fullTextFilter&#x60;] when passing &#x60;cursor&#x60; as query parameters, as changing any of these properties will affect pagination. If any of &#x60;filter&#x60;, &#x60;orderField&#x60;, &#x60;fullTextFilter&#x60; is specified together with &#x60;cursor&#x60;, only the latter is taken into consideration.
* @param filter - You can pass in one or more filter sets that get matched against each entity. Each filter set is a number of conditions that all have to match for the condition to be true (conditions effectively have an AND between them). At least one filter set has to be true for the entity to be part of the result set (filter sets effectively have an OR between them). Example: &#x60;&#x60;&#x60;text /entities/by-query?filter&#x3D;kind&#x3D;user,metadata.namespace&#x3D;default&amp;filter&#x3D;kind&#x3D;group,spec.type Return entities that match Filter set 1: Condition 1: kind &#x3D; user AND Condition 2: metadata.namespace &#x3D; default OR Filter set 2: Condition 1: kind &#x3D; group AND Condition 2: spec.type exists &#x60;&#x60;&#x60; Each condition is either on the form &#x60;&lt;key&gt;&#x60;, or on the form &#x60;&lt;key&gt;&#x3D;&lt;value&gt;&#x60;. The first form asserts on the existence of a certain key (with any value), and the second asserts that the key exists and has a certain value. All checks are always case _insensitive_. In all cases, the key is a simplified JSON path in a given piece of entity data. Each part of the path is a key of an object, and the traversal also descends through arrays. There are two special forms: - Array items that are simple value types (such as strings) match on a key-value pair where the key is the item as a string, and the value is the string &#x60;true&#x60; - Relations can be matched on a &#x60;relations.&lt;type&gt;&#x3D;&lt;targetRef&gt;&#x60; form Let&#39;s look at a simplified example to illustrate the concept: &#x60;&#x60;&#x60;json { \&quot;a\&quot;: { \&quot;b\&quot;: [\&quot;c\&quot;, { \&quot;d\&quot;: 1 }], \&quot;e\&quot;: 7 } } &#x60;&#x60;&#x60; This would match any one of the following conditions: - &#x60;a&#x60; - &#x60;a.b&#x60; - &#x60;a.b.c&#x60; - &#x60;a.b.c&#x3D;true&#x60; - &#x60;a.b.d&#x60; - &#x60;a.b.d&#x3D;1&#x60; - &#x60;a.e&#x60; - &#x60;a.e&#x3D;7&#x60; Some more real world usable examples: - Return all orphaned entities: &#x60;/entities/by-query?filter&#x3D;metadata.annotations.backstage.io/orphan&#x3D;true&#x60; - Return all users and groups: &#x60;/entities/by-query?filter&#x3D;kind&#x3D;user&amp;filter&#x3D;kind&#x3D;group&#x60; - Return all service components: &#x60;/entities/by-query?filter&#x3D;kind&#x3D;component,spec.type&#x3D;service&#x60; - Return all entities with the &#x60;java&#x60; tag: &#x60;/entities/by-query?filter&#x3D;metadata.tags.java&#x60; - Return all users who are members of the &#x60;ops&#x60; group (note that the full [reference](references.md) of the group is used): &#x60;/entities/by-query?filter&#x3D;kind&#x3D;user,relations.memberof&#x3D;group:default/ops&#x60;
* @param cursor - You may pass the &#x60;cursor&#x60; query parameters to perform cursor based pagination through the set of entities. The value of &#x60;cursor&#x60; will be returned in the response, under the &#x60;pageInfo&#x60; property: &#x60;&#x60;&#x60;json \&quot;pageInfo\&quot;: { \&quot;nextCursor\&quot;: \&quot;a-cursor\&quot;, \&quot;prevCursor\&quot;: \&quot;another-cursor\&quot; } &#x60;&#x60;&#x60; If &#x60;nextCursor&#x60; exists, it can be used to retrieve the next batch of entities. Following the same approach, if &#x60;prevCursor&#x60; exists, it can be used to retrieve the previous batch of entities. - [&#x60;filter&#x60;](#filtering), for selecting only a subset of all entities - [&#x60;fields&#x60;](#field-selection), for selecting only parts of the full data structure of each entity - &#x60;limit&#x60; for limiting the number of entities returned (20 is the default) - [&#x60;orderField&#x60;](#ordering), for deciding the order of the entities - &#x60;fullTextFilter&#x60; **NOTE**: [&#x60;filter&#x60;, &#x60;orderField&#x60;, &#x60;fullTextFilter&#x60;] and &#x60;cursor&#x60; are mutually exclusive. This means that, it isn\&#39;t possible to change any of [&#x60;filter&#x60;, &#x60;orderField&#x60;, &#x60;fullTextFilter&#x60;] when passing &#x60;cursor&#x60; as query parameters, as changing any of these properties will affect pagination. If any of &#x60;filter&#x60;, &#x60;orderField&#x60;, &#x60;fullTextFilter&#x60; is specified together with &#x60;cursor&#x60;, only the latter is taken into consideration.
* @param filter - You can pass in one or more filter sets that get matched against each entity. Each filter set is a number of conditions that all have to match for the condition to be true (conditions effectively have an AND between them). At least one filter set has to be true for the entity to be part of the result set (filter sets effectively have an OR between them). Example: &#x60;&#x60;&#x60;text /entities/by-query?filter&#x3D;kind&#x3D;user,metadata.namespace&#x3D;default&amp;filter&#x3D;kind&#x3D;group,spec.type Return entities that match Filter set 1: Condition 1: kind &#x3D; user AND Condition 2: metadata.namespace &#x3D; default OR Filter set 2: Condition 1: kind &#x3D; group AND Condition 2: spec.type exists &#x60;&#x60;&#x60; Each condition is either on the form &#x60;&lt;key&gt;&#x60;, or on the form &#x60;&lt;key&gt;&#x3D;&lt;value&gt;&#x60;. The first form asserts on the existence of a certain key (with any value), and the second asserts that the key exists and has a certain value. All checks are always case _insensitive_. In all cases, the key is a simplified JSON path in a given piece of entity data. Each part of the path is a key of an object, and the traversal also descends through arrays. There are two special forms: - Array items that are simple value types (such as strings) match on a key-value pair where the key is the item as a string, and the value is the string &#x60;true&#x60; - Relations can be matched on a &#x60;relations.&lt;type&gt;&#x3D;&lt;targetRef&gt;&#x60; form Let\&#39;s look at a simplified example to illustrate the concept: &#x60;&#x60;&#x60;json { \&quot;a\&quot;: { \&quot;b\&quot;: [\&quot;c\&quot;, { \&quot;d\&quot;: 1 }], \&quot;e\&quot;: 7 } } &#x60;&#x60;&#x60; This would match any one of the following conditions: - &#x60;a&#x60; - &#x60;a.b&#x60; - &#x60;a.b.c&#x60; - &#x60;a.b.c&#x3D;true&#x60; - &#x60;a.b.d&#x60; - &#x60;a.b.d&#x3D;1&#x60; - &#x60;a.e&#x60; - &#x60;a.e&#x3D;7&#x60; Some more real world usable examples: - Return all orphaned entities: &#x60;/entities/by-query?filter&#x3D;metadata.annotations.backstage.io/orphan&#x3D;true&#x60; - Return all users and groups: &#x60;/entities/by-query?filter&#x3D;kind&#x3D;user&amp;filter&#x3D;kind&#x3D;group&#x60; - Return all service components: &#x60;/entities/by-query?filter&#x3D;kind&#x3D;component,spec.type&#x3D;service&#x60; - Return all entities with the &#x60;java&#x60; tag: &#x60;/entities/by-query?filter&#x3D;metadata.tags.java&#x60; - Return all users who are members of the &#x60;ops&#x60; group (note that the full [reference](references.md) of the group is used): &#x60;/entities/by-query?filter&#x3D;kind&#x3D;user,relations.memberof&#x3D;group:default/ops&#x60;
* @param fullTextFilterTerm - Text search term.
* @param fullTextFilterFields - A comma separated list of fields to sort returned results by.
*/
@@ -324,7 +324,7 @@ export class DefaultApiClient {
/**
* Get a batch set of entities given an array of entityRefs.
* @param filter - You can pass in one or more filter sets that get matched against each entity. Each filter set is a number of conditions that all have to match for the condition to be true (conditions effectively have an AND between them). At least one filter set has to be true for the entity to be part of the result set (filter sets effectively have an OR between them). Example: &#x60;&#x60;&#x60;text /entities/by-query?filter&#x3D;kind&#x3D;user,metadata.namespace&#x3D;default&amp;filter&#x3D;kind&#x3D;group,spec.type Return entities that match Filter set 1: Condition 1: kind &#x3D; user AND Condition 2: metadata.namespace &#x3D; default OR Filter set 2: Condition 1: kind &#x3D; group AND Condition 2: spec.type exists &#x60;&#x60;&#x60; Each condition is either on the form &#x60;&lt;key&gt;&#x60;, or on the form &#x60;&lt;key&gt;&#x3D;&lt;value&gt;&#x60;. The first form asserts on the existence of a certain key (with any value), and the second asserts that the key exists and has a certain value. All checks are always case _insensitive_. In all cases, the key is a simplified JSON path in a given piece of entity data. Each part of the path is a key of an object, and the traversal also descends through arrays. There are two special forms: - Array items that are simple value types (such as strings) match on a key-value pair where the key is the item as a string, and the value is the string &#x60;true&#x60; - Relations can be matched on a &#x60;relations.&lt;type&gt;&#x3D;&lt;targetRef&gt;&#x60; form Let&#39;s look at a simplified example to illustrate the concept: &#x60;&#x60;&#x60;json { \&quot;a\&quot;: { \&quot;b\&quot;: [\&quot;c\&quot;, { \&quot;d\&quot;: 1 }], \&quot;e\&quot;: 7 } } &#x60;&#x60;&#x60; This would match any one of the following conditions: - &#x60;a&#x60; - &#x60;a.b&#x60; - &#x60;a.b.c&#x60; - &#x60;a.b.c&#x3D;true&#x60; - &#x60;a.b.d&#x60; - &#x60;a.b.d&#x3D;1&#x60; - &#x60;a.e&#x60; - &#x60;a.e&#x3D;7&#x60; Some more real world usable examples: - Return all orphaned entities: &#x60;/entities/by-query?filter&#x3D;metadata.annotations.backstage.io/orphan&#x3D;true&#x60; - Return all users and groups: &#x60;/entities/by-query?filter&#x3D;kind&#x3D;user&amp;filter&#x3D;kind&#x3D;group&#x60; - Return all service components: &#x60;/entities/by-query?filter&#x3D;kind&#x3D;component,spec.type&#x3D;service&#x60; - Return all entities with the &#x60;java&#x60; tag: &#x60;/entities/by-query?filter&#x3D;metadata.tags.java&#x60; - Return all users who are members of the &#x60;ops&#x60; group (note that the full [reference](references.md) of the group is used): &#x60;/entities/by-query?filter&#x3D;kind&#x3D;user,relations.memberof&#x3D;group:default/ops&#x60;
* @param filter - You can pass in one or more filter sets that get matched against each entity. Each filter set is a number of conditions that all have to match for the condition to be true (conditions effectively have an AND between them). At least one filter set has to be true for the entity to be part of the result set (filter sets effectively have an OR between them). Example: &#x60;&#x60;&#x60;text /entities/by-query?filter&#x3D;kind&#x3D;user,metadata.namespace&#x3D;default&amp;filter&#x3D;kind&#x3D;group,spec.type Return entities that match Filter set 1: Condition 1: kind &#x3D; user AND Condition 2: metadata.namespace &#x3D; default OR Filter set 2: Condition 1: kind &#x3D; group AND Condition 2: spec.type exists &#x60;&#x60;&#x60; Each condition is either on the form &#x60;&lt;key&gt;&#x60;, or on the form &#x60;&lt;key&gt;&#x3D;&lt;value&gt;&#x60;. The first form asserts on the existence of a certain key (with any value), and the second asserts that the key exists and has a certain value. All checks are always case _insensitive_. In all cases, the key is a simplified JSON path in a given piece of entity data. Each part of the path is a key of an object, and the traversal also descends through arrays. There are two special forms: - Array items that are simple value types (such as strings) match on a key-value pair where the key is the item as a string, and the value is the string &#x60;true&#x60; - Relations can be matched on a &#x60;relations.&lt;type&gt;&#x3D;&lt;targetRef&gt;&#x60; form Let\&#39;s look at a simplified example to illustrate the concept: &#x60;&#x60;&#x60;json { \&quot;a\&quot;: { \&quot;b\&quot;: [\&quot;c\&quot;, { \&quot;d\&quot;: 1 }], \&quot;e\&quot;: 7 } } &#x60;&#x60;&#x60; This would match any one of the following conditions: - &#x60;a&#x60; - &#x60;a.b&#x60; - &#x60;a.b.c&#x60; - &#x60;a.b.c&#x3D;true&#x60; - &#x60;a.b.d&#x60; - &#x60;a.b.d&#x3D;1&#x60; - &#x60;a.e&#x60; - &#x60;a.e&#x3D;7&#x60; Some more real world usable examples: - Return all orphaned entities: &#x60;/entities/by-query?filter&#x3D;metadata.annotations.backstage.io/orphan&#x3D;true&#x60; - Return all users and groups: &#x60;/entities/by-query?filter&#x3D;kind&#x3D;user&amp;filter&#x3D;kind&#x3D;group&#x60; - Return all service components: &#x60;/entities/by-query?filter&#x3D;kind&#x3D;component,spec.type&#x3D;service&#x60; - Return all entities with the &#x60;java&#x60; tag: &#x60;/entities/by-query?filter&#x3D;metadata.tags.java&#x60; - Return all users who are members of the &#x60;ops&#x60; group (note that the full [reference](references.md) of the group is used): &#x60;/entities/by-query?filter&#x3D;kind&#x3D;user,relations.memberof&#x3D;group:default/ops&#x60;
* @param getEntitiesByRefsRequest -
*/
public async getEntitiesByRefs(
@@ -351,7 +351,7 @@ export class DefaultApiClient {
}
/**
* Get an entity's ancestry by entity ref.
* Get an entity\'s ancestry by entity ref.
* @param kind -
* @param namespace -
* @param name -
@@ -439,7 +439,7 @@ export class DefaultApiClient {
/**
* Get all entity facets that match the given filters.
* @param facet -
* @param filter - You can pass in one or more filter sets that get matched against each entity. Each filter set is a number of conditions that all have to match for the condition to be true (conditions effectively have an AND between them). At least one filter set has to be true for the entity to be part of the result set (filter sets effectively have an OR between them). Example: &#x60;&#x60;&#x60;text /entities/by-query?filter&#x3D;kind&#x3D;user,metadata.namespace&#x3D;default&amp;filter&#x3D;kind&#x3D;group,spec.type Return entities that match Filter set 1: Condition 1: kind &#x3D; user AND Condition 2: metadata.namespace &#x3D; default OR Filter set 2: Condition 1: kind &#x3D; group AND Condition 2: spec.type exists &#x60;&#x60;&#x60; Each condition is either on the form &#x60;&lt;key&gt;&#x60;, or on the form &#x60;&lt;key&gt;&#x3D;&lt;value&gt;&#x60;. The first form asserts on the existence of a certain key (with any value), and the second asserts that the key exists and has a certain value. All checks are always case _insensitive_. In all cases, the key is a simplified JSON path in a given piece of entity data. Each part of the path is a key of an object, and the traversal also descends through arrays. There are two special forms: - Array items that are simple value types (such as strings) match on a key-value pair where the key is the item as a string, and the value is the string &#x60;true&#x60; - Relations can be matched on a &#x60;relations.&lt;type&gt;&#x3D;&lt;targetRef&gt;&#x60; form Let&#39;s look at a simplified example to illustrate the concept: &#x60;&#x60;&#x60;json { \&quot;a\&quot;: { \&quot;b\&quot;: [\&quot;c\&quot;, { \&quot;d\&quot;: 1 }], \&quot;e\&quot;: 7 } } &#x60;&#x60;&#x60; This would match any one of the following conditions: - &#x60;a&#x60; - &#x60;a.b&#x60; - &#x60;a.b.c&#x60; - &#x60;a.b.c&#x3D;true&#x60; - &#x60;a.b.d&#x60; - &#x60;a.b.d&#x3D;1&#x60; - &#x60;a.e&#x60; - &#x60;a.e&#x3D;7&#x60; Some more real world usable examples: - Return all orphaned entities: &#x60;/entities/by-query?filter&#x3D;metadata.annotations.backstage.io/orphan&#x3D;true&#x60; - Return all users and groups: &#x60;/entities/by-query?filter&#x3D;kind&#x3D;user&amp;filter&#x3D;kind&#x3D;group&#x60; - Return all service components: &#x60;/entities/by-query?filter&#x3D;kind&#x3D;component,spec.type&#x3D;service&#x60; - Return all entities with the &#x60;java&#x60; tag: &#x60;/entities/by-query?filter&#x3D;metadata.tags.java&#x60; - Return all users who are members of the &#x60;ops&#x60; group (note that the full [reference](references.md) of the group is used): &#x60;/entities/by-query?filter&#x3D;kind&#x3D;user,relations.memberof&#x3D;group:default/ops&#x60;
* @param filter - You can pass in one or more filter sets that get matched against each entity. Each filter set is a number of conditions that all have to match for the condition to be true (conditions effectively have an AND between them). At least one filter set has to be true for the entity to be part of the result set (filter sets effectively have an OR between them). Example: &#x60;&#x60;&#x60;text /entities/by-query?filter&#x3D;kind&#x3D;user,metadata.namespace&#x3D;default&amp;filter&#x3D;kind&#x3D;group,spec.type Return entities that match Filter set 1: Condition 1: kind &#x3D; user AND Condition 2: metadata.namespace &#x3D; default OR Filter set 2: Condition 1: kind &#x3D; group AND Condition 2: spec.type exists &#x60;&#x60;&#x60; Each condition is either on the form &#x60;&lt;key&gt;&#x60;, or on the form &#x60;&lt;key&gt;&#x3D;&lt;value&gt;&#x60;. The first form asserts on the existence of a certain key (with any value), and the second asserts that the key exists and has a certain value. All checks are always case _insensitive_. In all cases, the key is a simplified JSON path in a given piece of entity data. Each part of the path is a key of an object, and the traversal also descends through arrays. There are two special forms: - Array items that are simple value types (such as strings) match on a key-value pair where the key is the item as a string, and the value is the string &#x60;true&#x60; - Relations can be matched on a &#x60;relations.&lt;type&gt;&#x3D;&lt;targetRef&gt;&#x60; form Let\&#39;s look at a simplified example to illustrate the concept: &#x60;&#x60;&#x60;json { \&quot;a\&quot;: { \&quot;b\&quot;: [\&quot;c\&quot;, { \&quot;d\&quot;: 1 }], \&quot;e\&quot;: 7 } } &#x60;&#x60;&#x60; This would match any one of the following conditions: - &#x60;a&#x60; - &#x60;a.b&#x60; - &#x60;a.b.c&#x60; - &#x60;a.b.c&#x3D;true&#x60; - &#x60;a.b.d&#x60; - &#x60;a.b.d&#x3D;1&#x60; - &#x60;a.e&#x60; - &#x60;a.e&#x3D;7&#x60; Some more real world usable examples: - Return all orphaned entities: &#x60;/entities/by-query?filter&#x3D;metadata.annotations.backstage.io/orphan&#x3D;true&#x60; - Return all users and groups: &#x60;/entities/by-query?filter&#x3D;kind&#x3D;user&amp;filter&#x3D;kind&#x3D;group&#x60; - Return all service components: &#x60;/entities/by-query?filter&#x3D;kind&#x3D;component,spec.type&#x3D;service&#x60; - Return all entities with the &#x60;java&#x60; tag: &#x60;/entities/by-query?filter&#x3D;metadata.tags.java&#x60; - Return all users who are members of the &#x60;ops&#x60; group (note that the full [reference](references.md) of the group is used): &#x60;/entities/by-query?filter&#x3D;kind&#x3D;user,relations.memberof&#x3D;group:default/ops&#x60;
*/
public async getEntityFacets(
// @ts-ignore
@@ -17,12 +17,11 @@
// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
import { Entity } from '../models/Entity.model';
import { LocationSpec } from '../models/LocationSpec.model';
/**
* If the folder pointed to already contained catalog info yaml files, they are read and emitted like this so that the frontend can inform the user that it located them and can make sure to register them as well if they weren't already
* If the folder pointed to already contained catalog info yaml files, they are read and emitted like this so that the frontend can inform the user that it located them and can make sure to register them as well if they weren\'t already
* @public
*/
export interface AnalyzeLocationExistingEntity {
@@ -17,12 +17,11 @@
// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
import { AnalyzeLocationEntityField } from '../models/AnalyzeLocationEntityField.model';
import { RecursivePartialEntity } from '../models/RecursivePartialEntity.model';
/**
* This is some form of representation of what the analyzer could deduce. We should probably have a chat about how this can best be conveyed to the frontend. It'll probably contain a (possibly incomplete) entity, plus enough info for the frontend to know what form data to show to the user for overriding/completing the info.
* This is some form of representation of what the analyzer could deduce. We should probably have a chat about how this can best be conveyed to the frontend. It\'ll probably contain a (possibly incomplete) entity, plus enough info for the frontend to know what form data to show to the user for overriding/completing the info.
* @public
*/
export interface AnalyzeLocationGenerateEntity {
@@ -17,7 +17,6 @@
// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
import { LocationInput } from '../models/LocationInput.model';
/**
@@ -17,7 +17,6 @@
// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
import { AnalyzeLocationExistingEntity } from '../models/AnalyzeLocationExistingEntity.model';
import { AnalyzeLocationGenerateEntity } from '../models/AnalyzeLocationGenerateEntity.model';
@@ -17,7 +17,6 @@
// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
import { Entity } from '../models/Entity.model';
import { Location } from '../models/Location.model';
@@ -17,7 +17,6 @@
// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
import { NullableEntity } from '../models/NullableEntity.model';
/**
@@ -17,7 +17,6 @@
// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
import { EntitiesQueryResponsePageInfo } from '../models/EntitiesQueryResponsePageInfo.model';
import { Entity } from '../models/Entity.model';
@@ -17,12 +17,11 @@
// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
import { EntityMeta } from '../models/EntityMeta.model';
import { EntityRelation } from '../models/EntityRelation.model';
/**
* The parts of the format that's common to all versions/kinds of entity.
* The parts of the format that\'s common to all versions/kinds of entity.
* @public
*/
export interface Entity {
@@ -17,7 +17,6 @@
// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
import { EntityAncestryResponseItemsInner } from '../models/EntityAncestryResponseItemsInner.model';
/**
@@ -17,7 +17,6 @@
// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
import { Entity } from '../models/Entity.model';
/**
@@ -17,7 +17,6 @@
// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
import { EntityFacet } from '../models/EntityFacet.model';
/**
@@ -17,7 +17,6 @@
// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
import { EntityLink } from '../models/EntityLink.model';
/**
@@ -26,7 +25,6 @@ import { EntityLink } from '../models/EntityLink.model';
*/
export interface EntityMeta {
[key: string]: any;
/**
* A list of external hyperlinks related to the entity.
*/
@@ -17,7 +17,6 @@
// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
import { Location } from '../models/Location.model';
/**
@@ -17,7 +17,6 @@
// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
import { Location } from '../models/Location.model';
import { LocationsQueryResponsePageInfo } from '../models/LocationsQueryResponsePageInfo.model';
@@ -17,7 +17,6 @@
// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
import { ErrorError } from '../models/ErrorError.model';
import { ErrorRequest } from '../models/ErrorRequest.model';
import { ErrorResponse } from '../models/ErrorResponse.model';
@@ -27,7 +26,6 @@ import { ErrorResponse } from '../models/ErrorResponse.model';
*/
export interface ModelError {
[key: string]: any;
error: ErrorError;
request?: ErrorRequest;
response: ErrorResponse;
@@ -17,12 +17,11 @@
// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
import { EntityMeta } from '../models/EntityMeta.model';
import { EntityRelation } from '../models/EntityRelation.model';
/**
* The parts of the format that's common to all versions/kinds of entity.
* The parts of the format that\'s common to all versions/kinds of entity.
* @public
*/
export type NullableEntity = {
@@ -17,7 +17,6 @@
// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
import { QueryEntitiesByPredicateRequestFullTextFilter } from '../models/QueryEntitiesByPredicateRequestFullTextFilter.model';
import { QueryEntitiesByPredicateRequestOrderByInner } from '../models/QueryEntitiesByPredicateRequestOrderByInner.model';
@@ -17,7 +17,6 @@
// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
import { RecursivePartialEntityMeta } from '../models/RecursivePartialEntityMeta.model';
import { RecursivePartialEntityRelation } from '../models/RecursivePartialEntityRelation.model';
@@ -17,7 +17,6 @@
// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
import { EntityLink } from '../models/EntityLink.model';
/**
@@ -1,68 +0,0 @@
/*
* Copyright 2026 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.
*/
// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
import { EntityLink } from '../models/EntityLink.model';
/**
* Metadata fields common to all versions/kinds of entity.
* @public
*/
export interface RecursivePartialEntityMetaAllOf {
/**
* A list of external hyperlinks related to the entity.
*/
links?: Array<EntityLink>;
/**
* A list of single-valued strings, to for example classify catalog entities in various ways.
*/
tags?: Array<string>;
/**
* Construct a type with a set of properties K of type T
*/
annotations?: { [key: string]: string };
/**
* Construct a type with a set of properties K of type T
*/
labels?: { [key: string]: string };
/**
* A short (typically relatively few words, on one line) description of the entity.
*/
description?: string;
/**
* A display name of the entity, to be presented in user interfaces instead of the `name` property above, when available. This field is sometimes useful when the `name` is cumbersome or ends up being perceived as overly technical. The title generally does not have as stringent format requirements on it, so it may contain special characters and be more explanatory. Do keep it very short though, and avoid situations where a title can be confused with the name of another entity, or where two entities share a title. Note that this is only for display purposes, and may be ignored by some parts of the code. Entity references still always make use of the `name` property, not the title.
*/
title?: string;
/**
* The namespace that the entity belongs to.
*/
namespace?: string;
/**
* The name of the entity. Must be unique within the catalog at any given point in time, for any given namespace + kind pair. This value is part of the technical identifier of the entity, and as such it will appear in URLs, database tables, entity references, and similar. It is subject to restrictions regarding what characters are allowed. If you want to use a different, more human readable string with fewer restrictions on it in user interfaces, see the `title` field below.
*/
name?: string;
/**
* An opaque string that changes for each update operation to any part of the entity, including metadata. This field can not be set by the user at creation time, and the server will reject an attempt to do so. The field will be populated in read operations. The field can (optionally) be specified when performing update or delete operations, and the server will then reject the operation if it does not match the current stored value.
*/
etag?: string;
/**
* A globally unique ID for the entity. This field can not be set by the user at creation time, and the server will reject an attempt to do so. The field will be populated in read operations. The field can (optionally) be specified when performing update or delete operations, but the server is free to reject requests that do so in such a way that it breaks semantics.
*/
uid?: string;
}
@@ -17,7 +17,6 @@
// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
import { ValidateEntity400ResponseErrorsInner } from '../models/ValidateEntity400ResponseErrorsInner.model';
/**
@@ -23,7 +23,6 @@
*/
export interface ValidateEntity400ResponseErrorsInner {
[key: string]: any;
name: string;
message: string;
}
@@ -51,7 +51,6 @@ export * from '../models/QueryEntitiesByPredicateRequestOrderByInner.model';
export * from '../models/QueryEntityFacetsByPredicateRequest.model';
export * from '../models/RecursivePartialEntity.model';
export * from '../models/RecursivePartialEntityMeta.model';
export * from '../models/RecursivePartialEntityMetaAllOf.model';
export * from '../models/RecursivePartialEntityRelation.model';
export * from '../models/RefreshEntityRequest.model';
export * from '../models/ValidateEntity400Response.model';
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 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.
@@ -17,6 +17,7 @@
// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
import { DiscoveryApi } from '../types/discovery';
import { FetchApi } from '../types/fetch';
import crossFetch from 'cross-fetch';
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 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.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 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.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 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.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 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.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 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.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 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.
@@ -26,7 +26,6 @@ import { ErrorResponse } from '../models/ErrorResponse.model';
*/
export interface ModelError {
[key: string]: any;
error: ErrorError;
request?: ErrorRequest;
response: ErrorResponse;
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 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.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 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.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 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.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 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.
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 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.
+1 -1
View File
@@ -443,7 +443,7 @@ export type RenderTestAppOptions<TApiPairs extends any[] = any[]> = {
// @public
export type TestApiPair<TApi> =
| readonly [ApiRef<TApi>, TApi extends infer TImpl ? Partial<TImpl> : never]
| MockWithApiFactory<TApi>;
| MockWithApiFactory<NoInfer<TApi>>;
// @public
export type TestApiPairs<TApiPairs> = {
@@ -0,0 +1,95 @@
/*
* 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 { createApiRef } from '@backstage/frontend-plugin-api';
import { TestApiProvider } from './TestApiProvider';
import { mockApis } from './mockApis';
import { render, screen } from '@testing-library/react';
const xApiRef = createApiRef<{ a: string; b: number }>({
id: 'x',
});
const yApiRef = createApiRef<string>({
id: 'y',
});
describe('TestApiProvider', () => {
it('should provide tuple APIs and check types', () => {
render(
<TestApiProvider
apis={[
[xApiRef, { a: 'a', b: 3 }],
[yApiRef, 'y'],
]}
>
<div />
</TestApiProvider>,
);
});
it('should allow partial API implementations', () => {
render(
<TestApiProvider apis={[[xApiRef, { a: 'a' }]]}>
<div />
</TestApiProvider>,
);
});
it('should reject mismatched types in tuple syntax', () => {
render(
// @ts-expect-error - a should be a string, not a number
<TestApiProvider apis={[[xApiRef, { a: 3 }]]}>
<div />
</TestApiProvider>,
);
});
it('should accept MockWithApiFactory entries', () => {
render(
<TestApiProvider apis={[mockApis.alert()]}>
<div />
</TestApiProvider>,
);
});
it('should accept a mix of tuples and MockWithApiFactory entries', () => {
render(
<TestApiProvider apis={[[xApiRef, { a: 'a' }], mockApis.alert()]}>
<div />
</TestApiProvider>,
);
});
it('should allow empty APIs', () => {
render(
<TestApiProvider apis={[]}>
<div />
</TestApiProvider>,
);
});
it('should provide APIs at runtime', async () => {
const alertApi = mockApis.alert();
render(
<TestApiProvider apis={[[xApiRef, { a: 'hello', b: 42 }], alertApi]}>
<span>rendered</span>
</TestApiProvider>,
);
expect(await screen.findByText('rendered')).toBeInTheDocument();
});
});
@@ -29,7 +29,7 @@ import {
*/
export type TestApiPair<TApi> =
| readonly [ApiRef<TApi>, TApi extends infer TImpl ? Partial<TImpl> : never]
| MockWithApiFactory<TApi>;
| MockWithApiFactory<NoInfer<TApi>>;
/**
* Represents an array of mock API implementation.
+1 -5
View File
@@ -16,7 +16,7 @@
import { ScmIntegration, ScmIntegrationsGroup } from '@backstage/integration';
import Typography from '@material-ui/core/Typography';
import { scmIntegrationsApiRef } from '../src/ScmIntegrationsApi';
import { scmIntegrationsApiRef } from '../src/api/ScmIntegrationsApi';
import { Content } from '@backstage/core-components';
import { useApi } from '@backstage/core-plugin-api';
@@ -48,10 +48,6 @@ export const DevPage = () => {
Azure
</Typography>
<Integrations group={integrations.azure} />
<Typography paragraph variant="h2">
Bitbucket
</Typography>
<Integrations group={integrations.bitbucket} />
<Typography paragraph variant="h2">
Bitbucket Cloud
</Typography>
+1 -1
View File
@@ -15,7 +15,7 @@
*/
import { createDevApp } from '@backstage/dev-utils';
import { ScmIntegrations } from '@backstage/integration';
import { scmIntegrationsApiRef } from '../src/ScmIntegrationsApi';
import { scmIntegrationsApiRef } from '../src/api/ScmIntegrationsApi';
import { DevPage } from './DevPage';
import { configApiRef, createApiFactory } from '@backstage/core-plugin-api';
@@ -26,6 +26,6 @@ describe('scmIntegrationsApiRef', () => {
it('should be instantiated', () => {
const i = ScmIntegrationsApi.fromConfig(new ConfigReader({}));
expect(i.list().length).toBe(8); // The default ones
expect(i.list().length).toBe(7); // The default ones
});
});
-58
View File
@@ -27,27 +27,6 @@ export interface Config {
* @visibility frontend
*/
host: string;
/**
* Token used to authenticate requests.
* @visibility secret
* @deprecated Use `credentials` instead.
*/
token?: string;
/**
* The credential to use for requests.
*
* If no credential is specified anonymous access is used.
*
* @deepVisibility secret
* @deprecated Use `credentials` instead.
*/
credential?: {
clientId?: string;
clientSecret?: string;
tenantId?: string;
personalAccessToken?: string;
};
/**
* The credentials to use for requests. If multiple credentials are specified the first one that matches the organization is used.
@@ -132,43 +111,6 @@ export interface Config {
};
}>;
/**
* Integration configuration for Bitbucket
* @deprecated replaced by bitbucketCloud and bitbucketServer
*/
bitbucket?: Array<{
/**
* The hostname of the given Bitbucket instance
* @visibility frontend
*/
host: string;
/**
* Token used to authenticate requests.
* @visibility secret
*/
token?: string;
/**
* The base url for the Bitbucket API, for example https://api.bitbucket.org/2.0
* @visibility frontend
*/
apiBaseUrl?: string;
/**
* The username to use for authenticated requests.
* @visibility secret
*/
username?: string;
/**
* Bitbucket app password used to authenticate requests.
* @visibility secret
*/
appPassword?: string;
/**
* PGP signing key for signing commits.
* @visibility secret
*/
commitSigningKey?: string;
}>;
/** Integration configuration for Bitbucket Cloud */
bitbucketCloud?: Array<{
/**
-108
View File
@@ -201,8 +201,6 @@ export class AzureIntegration implements ScmIntegration {
// @public
export type AzureIntegrationConfig = {
host: string;
token?: string;
credential?: AzureDevOpsCredential;
credentials?: AzureDevOpsCredential[];
commitSigningKey?: string;
};
@@ -255,37 +253,6 @@ export type BitbucketCloudIntegrationConfig = {
commitSigningKey?: string;
};
// @public @deprecated
export class BitbucketIntegration implements ScmIntegration {
constructor(integrationConfig: BitbucketIntegrationConfig);
// (undocumented)
get config(): BitbucketIntegrationConfig;
// (undocumented)
static factory: ScmIntegrationsFactory<BitbucketIntegration>;
// (undocumented)
resolveEditUrl(url: string): string;
// (undocumented)
resolveUrl(options: {
url: string;
base: string;
lineNumber?: number;
}): string;
// (undocumented)
get title(): string;
// (undocumented)
get type(): string;
}
// @public @deprecated
export type BitbucketIntegrationConfig = {
host: string;
apiBaseUrl: string;
token?: string;
username?: string;
appPassword?: string;
commitSigningKey?: string;
};
// @public
export class BitbucketServerIntegration implements ScmIntegration {
constructor(integrationConfig: BitbucketServerIntegrationConfig);
@@ -317,14 +284,6 @@ export type BitbucketServerIntegrationConfig = {
commitSigningKey?: string;
};
// @public @deprecated
export function buildGerritGitilesArchiveUrl(
config: GerritIntegrationConfig,
project: string,
branch: string,
filePath: string,
): string;
// @public
export function buildGerritGitilesArchiveUrlFromLocation(
config: GerritIntegrationConfig,
@@ -426,14 +385,6 @@ export function getAzureDownloadUrl(url: string): string;
// @public
export function getAzureFileFetchUrl(url: string): string;
// @public @deprecated
export function getAzureRequestOptions(
config: AzureIntegrationConfig,
additionalHeaders?: Record<string, string>,
): Promise<{
headers: Record<string, string>;
}>;
// @public
export function getBitbucketCloudDefaultBranch(
url: string,
@@ -465,31 +416,6 @@ export function getBitbucketCloudRequestOptions(
headers: Record<string, string>;
}>;
// @public @deprecated
export function getBitbucketDefaultBranch(
url: string,
config: BitbucketIntegrationConfig,
): Promise<string>;
// @public @deprecated
export function getBitbucketDownloadUrl(
url: string,
config: BitbucketIntegrationConfig,
): Promise<string>;
// @public @deprecated
export function getBitbucketFileFetchUrl(
url: string,
config: BitbucketIntegrationConfig,
): string;
// @public @deprecated
export function getBitbucketRequestOptions(
config: BitbucketIntegrationConfig,
): {
headers: Record<string, string>;
};
// @public
export function getBitbucketServerDefaultBranch(
url: string,
@@ -579,14 +505,6 @@ export function getGithubFileFetchUrl(
credentials: GithubCredentials,
): string;
// @public @deprecated
export function getGitHubRequestOptions(
config: GithubIntegrationConfig,
credentials: GithubCredentials,
): {
headers: Record<string, string>;
};
// @public
export function getGitilesAuthenticationUrl(
config: GerritIntegrationConfig,
@@ -854,8 +772,6 @@ export interface IntegrationsByType {
azure: ScmIntegrationsGroup<AzureIntegration>;
// (undocumented)
azureBlobStorage: ScmIntegrationsGroup<AzureBlobStorageIntergation>;
// @deprecated (undocumented)
bitbucket: ScmIntegrationsGroup<BitbucketIntegration>;
// (undocumented)
bitbucketCloud: ScmIntegrationsGroup<BitbucketCloudIntegration>;
// (undocumented)
@@ -874,16 +790,6 @@ export interface IntegrationsByType {
harness: ScmIntegrationsGroup<HarnessIntegration>;
}
// @public @deprecated
export function parseGerritGitilesUrl(
config: GerritIntegrationConfig,
url: string,
): {
branch: string;
filePath: string;
project: string;
};
// @public
export function parseGerritJsonResponse(response: Response): Promise<unknown>;
@@ -989,16 +895,6 @@ export function readBitbucketCloudIntegrationConfigs(
configs: Config[],
): BitbucketCloudIntegrationConfig[];
// @public @deprecated
export function readBitbucketIntegrationConfig(
config: Config,
): BitbucketIntegrationConfig;
// @public @deprecated
export function readBitbucketIntegrationConfigs(
configs: Config[],
): BitbucketIntegrationConfig[];
// @public
export function readBitbucketServerIntegrationConfig(
config: Config,
@@ -1085,8 +981,6 @@ export interface ScmIntegrationRegistry
azure: ScmIntegrationsGroup<AzureIntegration>;
// (undocumented)
azureBlobStorage: ScmIntegrationsGroup<AzureBlobStorageIntergation>;
// @deprecated (undocumented)
bitbucket: ScmIntegrationsGroup<BitbucketIntegration>;
// (undocumented)
bitbucketCloud: ScmIntegrationsGroup<BitbucketCloudIntegration>;
// (undocumented)
@@ -1120,8 +1014,6 @@ export class ScmIntegrations implements ScmIntegrationRegistry {
get azure(): ScmIntegrationsGroup<AzureIntegration>;
// (undocumented)
get azureBlobStorage(): ScmIntegrationsGroup<AzureBlobStorageIntergation>;
// @deprecated (undocumented)
get bitbucket(): ScmIntegrationsGroup<BitbucketIntegration>;
// (undocumented)
get bitbucketCloud(): ScmIntegrationsGroup<BitbucketCloudIntegration>;
// (undocumented)
@@ -21,8 +21,6 @@ import {
BitbucketCloudIntegration,
BitbucketCloudIntegrationConfig,
} from './bitbucketCloud';
import { BitbucketIntegrationConfig } from './bitbucket';
import { BitbucketIntegration } from './bitbucket/BitbucketIntegration';
import {
BitbucketServerIntegration,
BitbucketServerIntegrationConfig,
@@ -62,10 +60,6 @@ describe('ScmIntegrations', () => {
host: 'azureblobstorage.local',
} as AzureBlobStorageIntegrationConfig);
const bitbucket = new BitbucketIntegration({
host: 'bitbucket.local',
} as BitbucketIntegrationConfig);
const bitbucketCloud = new BitbucketCloudIntegration({
host: 'bitbucket.org',
} as BitbucketCloudIntegrationConfig);
@@ -103,7 +97,6 @@ describe('ScmIntegrations', () => {
awsCodeCommit: basicIntegrations([awsCodeCommit], item => item.config.host),
azure: basicIntegrations([azure], item => item.config.host),
azureBlobStorage: basicIntegrations([azureBlob], item => item.config.host),
bitbucket: basicIntegrations([bitbucket], item => item.config.host),
bitbucketCloud: basicIntegrations([bitbucketCloud], item => item.title),
bitbucketServer: basicIntegrations(
[bitbucketServer],
@@ -126,7 +119,6 @@ describe('ScmIntegrations', () => {
expect(i.azureBlobStorage.byUrl('https://azureblobstorage.local')).toBe(
azureBlob,
);
expect(i.bitbucket.byUrl('https://bitbucket.local')).toBe(bitbucket);
expect(i.bitbucketCloud.byUrl('https://bitbucket.org')).toBe(
bitbucketCloud,
);
@@ -147,7 +139,6 @@ describe('ScmIntegrations', () => {
awsCodeCommit,
azure,
azureBlob,
bitbucket,
bitbucketCloud,
bitbucketServer,
gerrit,
@@ -166,7 +157,6 @@ describe('ScmIntegrations', () => {
expect(i.azureBlobStorage.byUrl('https://azureblobstorage.local')).toBe(
azureBlob,
);
expect(i.byUrl('https://bitbucket.local')).toBe(bitbucket);
expect(i.byUrl('https://bitbucket.org')).toBe(bitbucketCloud);
expect(i.byUrl('https://bitbucket-server.local')).toBe(bitbucketServer);
expect(i.byUrl('https://gerrit.local')).toBe(gerrit);
@@ -179,7 +169,6 @@ describe('ScmIntegrations', () => {
expect(i.byHost('awscodecommit.local')).toBe(awsCodeCommit);
expect(i.byHost('azure.local')).toBe(azure);
expect(i.byHost('azureblobstorage.local')).toBe(azureBlob);
expect(i.byHost('bitbucket.local')).toBe(bitbucket);
expect(i.byHost('bitbucket.org')).toBe(bitbucketCloud);
expect(i.byHost('bitbucket-server.local')).toBe(bitbucketServer);
expect(i.byHost('gerrit.local')).toBe(gerrit);
+1 -24
View File
@@ -19,7 +19,6 @@ import { AwsS3Integration } from './awsS3/AwsS3Integration';
import { AwsCodeCommitIntegration } from './awsCodeCommit/AwsCodeCommitIntegration';
import { AzureIntegration } from './azure/AzureIntegration';
import { BitbucketCloudIntegration } from './bitbucketCloud/BitbucketCloudIntegration';
import { BitbucketIntegration } from './bitbucket/BitbucketIntegration';
import { BitbucketServerIntegration } from './bitbucketServer/BitbucketServerIntegration';
import { GerritIntegration } from './gerrit/GerritIntegration';
import { GithubIntegration } from './github/GithubIntegration';
@@ -42,10 +41,6 @@ export interface IntegrationsByType {
awsCodeCommit: ScmIntegrationsGroup<AwsCodeCommitIntegration>;
azureBlobStorage: ScmIntegrationsGroup<AzureBlobStorageIntergation>;
azure: ScmIntegrationsGroup<AzureIntegration>;
/**
* @deprecated in favor of `bitbucketCloud` and `bitbucketServer`
*/
bitbucket: ScmIntegrationsGroup<BitbucketIntegration>;
bitbucketCloud: ScmIntegrationsGroup<BitbucketCloudIntegration>;
bitbucketServer: ScmIntegrationsGroup<BitbucketServerIntegration>;
gerrit: ScmIntegrationsGroup<GerritIntegration>;
@@ -70,7 +65,6 @@ export class ScmIntegrations implements ScmIntegrationRegistry {
awsCodeCommit: AwsCodeCommitIntegration.factory({ config }),
azureBlobStorage: AzureBlobStorageIntergation.factory({ config }),
azure: AzureIntegration.factory({ config }),
bitbucket: BitbucketIntegration.factory({ config }),
bitbucketCloud: BitbucketCloudIntegration.factory({ config }),
bitbucketServer: BitbucketServerIntegration.factory({ config }),
gerrit: GerritIntegration.factory({ config }),
@@ -102,13 +96,6 @@ export class ScmIntegrations implements ScmIntegrationRegistry {
return this.byType.azure;
}
/**
* @deprecated in favor of `bitbucketCloud()` and `bitbucketServer()`
*/
get bitbucket(): ScmIntegrationsGroup<BitbucketIntegration> {
return this.byType.bitbucket;
}
get bitbucketCloud(): ScmIntegrationsGroup<BitbucketCloudIntegration> {
return this.byType.bitbucketCloud;
}
@@ -148,20 +135,10 @@ export class ScmIntegrations implements ScmIntegrationRegistry {
}
byUrl(url: string | URL): ScmIntegration | undefined {
let candidates = Object.values(this.byType)
const candidates = Object.values(this.byType)
.map(i => i.byUrl(url))
.filter(Boolean);
// Do not return deprecated integrations if there are other options
if (candidates.length > 1) {
const filteredCandidates = candidates.filter(
x => !(x instanceof BitbucketIntegration),
);
if (filteredCandidates.length !== 0) {
candidates = filteredCandidates;
}
}
return candidates[0];
}
@@ -25,7 +25,7 @@ describe('AzureIntegration', () => {
azure: [
{
host: 'h.com',
token: 'token',
credentials: [{ personalAccessToken: 'token' }],
},
],
},
@@ -276,50 +276,6 @@ describe('readAzureIntegrationConfig', () => {
expect(output).toEqual({ host: 'dev.azure.com' });
});
it('maps deprecated token to credentials', () => {
const output = readAzureIntegrationConfig(
buildConfig({
host: 'dev.azure.com',
token: 't',
}),
);
expect(output).toEqual({
host: 'dev.azure.com',
credentials: [
{
kind: 'PersonalAccessToken',
personalAccessToken: 't',
},
],
});
});
it('maps deprecated credential to credentials', () => {
const output = readAzureIntegrationConfig(
buildConfig({
host: 'dev.azure.com',
credential: {
clientId: 'id',
clientSecret: 'secret',
tenantId: 'tenantId',
},
}),
);
expect(output).toEqual({
host: 'dev.azure.com',
credentials: [
{
kind: 'ClientSecret',
clientId: 'id',
clientSecret: 'secret',
tenantId: 'tenantId',
},
],
});
});
it('rejects config when host is not valid', () => {
expect(() =>
readAzureIntegrationConfig(buildConfig({ ...valid, host: 7 })),
+25 -67
View File
@@ -32,24 +32,6 @@ export type AzureIntegrationConfig = {
*/
host: string;
/**
* The authorization token to use for requests.
*
* If no token is specified, anonymous access is used.
*
* @deprecated Use `credentials` instead.
*/
token?: string;
/**
* The credential to use for requests.
*
* If no credential is specified anonymous access is used.
*
* @deprecated Use `credentials` instead.
*/
credential?: AzureDevOpsCredential;
/**
* The credentials to use for requests. If multiple credentials are specified the first one that matches the organization is used.
* If not organization matches the first credential without an organization is used.
@@ -236,9 +218,17 @@ function asAzureDevOpsCredential(
export function readAzureIntegrationConfig(
config: Config,
): AzureIntegrationConfig {
deprecatedConfigCheck(config);
const host = config.getOptionalString('host') ?? AZURE_HOST;
let credentialConfigs = config
if (!isValidHost(host)) {
throw new Error(
`Invalid Azure integration config, '${host}' is not a valid host`,
);
}
const credentialConfigs = config
.getOptionalConfigArray('credentials')
?.map(credential => {
const result: Partial<AzureDevOpsCredentialLike> = {
@@ -257,54 +247,6 @@ export function readAzureIntegrationConfig(
return result;
});
const token = config.getOptionalString('token')?.trim();
if (
config.getOptional('credential') !== undefined &&
config.getOptional('credentials') !== undefined
) {
throw new Error(
`Invalid Azure integration config, 'credential' and 'credentials' cannot be used together. Use 'credentials' instead.`,
);
}
if (
config.getOptional('token') !== undefined &&
config.getOptional('credentials') !== undefined
) {
throw new Error(
`Invalid Azure integration config, 'token' and 'credentials' cannot be used together. Use 'credentials' instead.`,
);
}
if (token !== undefined) {
const mapped = [{ personalAccessToken: token }];
credentialConfigs = credentialConfigs?.concat(mapped) ?? mapped;
}
if (config.getOptional('credential') !== undefined) {
const mapped = [
{
organizations: config.getOptionalStringArray(
'credential.organizations',
),
token: config.getOptionalString('credential.token')?.trim(),
tenantId: config.getOptionalString('credential.tenantId'),
clientId: config.getOptionalString('credential.clientId'),
clientSecret: config
.getOptionalString('credential.clientSecret')
?.trim(),
},
];
credentialConfigs = credentialConfigs?.concat(mapped) ?? mapped;
}
if (!isValidHost(host)) {
throw new Error(
`Invalid Azure integration config, '${host}' is not a valid host`,
);
}
let credentials: AzureDevOpsCredential[] | undefined = undefined;
if (credentialConfigs !== undefined) {
const errors = credentialConfigs
@@ -415,3 +357,19 @@ export function readAzureIntegrationConfigs(
return result;
}
/**
* These config sections have been removed but to ensure they
* don't leak sensitive tokens we have this check in place
* to throw an error if found
*
* @internal
* @deprecated To be removed at a later date
*/
function deprecatedConfigCheck(config: Config) {
if (config.getOptional('credential') || config.getOptional('token')) {
throw new Error(
`Invalid Azure integration config, 'credential' and 'token' have been removed. Use 'credentials' instead.`,
);
}
}
@@ -1,128 +0,0 @@
/*
* Copyright 2023 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 { getAzureRequestOptions } from './deprecated';
import { DateTime } from 'luxon';
import {
AccessToken,
ClientSecretCredential,
ManagedIdentityCredential,
} from '@azure/identity';
jest.mock('@azure/identity');
const MockedClientSecretCredential = ClientSecretCredential as jest.MockedClass<
typeof ClientSecretCredential
>;
const MockedManagedIdentityCredential =
ManagedIdentityCredential as jest.MockedClass<
typeof ManagedIdentityCredential
>;
describe('azure core', () => {
beforeEach(() => {
jest.resetAllMocks();
MockedClientSecretCredential.prototype.getToken.mockImplementation(() =>
Promise.resolve({
expiresOnTimestamp: DateTime.local().plus({ days: 1 }).toSeconds(),
token: 'fake-client-secret-token',
} as AccessToken),
);
MockedManagedIdentityCredential.prototype.getToken.mockImplementation(() =>
Promise.resolve({
expiresOnTimestamp: DateTime.local().plus({ days: 1 }).toSeconds(),
token: 'fake-managed-identity-token',
} as AccessToken),
);
});
describe('getAzureRequestOptions', () => {
it('should not add authorization header when not using token or credential', async () => {
expect(await getAzureRequestOptions({ host: '' })).toEqual(
expect.objectContaining({
headers: expect.not.objectContaining({
Authorization: expect.anything(),
}),
}),
);
});
it('should add authorization header when using a personal access token', async () => {
const pat = '0123456789';
const encoded = Buffer.from(`:${pat}`).toString('base64');
expect(
await getAzureRequestOptions({
host: '',
credentials: [
{
kind: 'PersonalAccessToken',
personalAccessToken: pat,
},
],
}),
).toEqual(
expect.objectContaining({
headers: expect.objectContaining({
Authorization: `Basic ${encoded}`,
}),
}),
);
});
it('should add authorization header when using a client secret', async () => {
expect(
await getAzureRequestOptions({
host: '',
credentials: [
{
kind: 'ClientSecret',
clientId: 'fake-id',
clientSecret: 'fake-secret',
tenantId: 'fake-tenant',
},
],
}),
).toEqual(
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer fake-client-secret-token',
}),
}),
);
});
it('should add authorization header when using a managed identity', async () => {
expect(
await getAzureRequestOptions({
host: '',
credentials: [
{
kind: 'ManagedIdentity',
clientId: 'fake-id',
},
],
}),
).toEqual(
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer fake-managed-identity-token',
}),
}),
);
});
});
});
@@ -1,61 +0,0 @@
/*
* Copyright 2023 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 { AzureIntegrationConfig } from './config';
import { CachedAzureDevOpsCredentialsProvider } from './CachedAzureDevOpsCredentialsProvider';
/**
* Gets the request options necessary to make requests to a given provider.
*
* @param config - The relevant provider config
* @param additionalHeaders - Additional headers for the request
* @public
* @deprecated Use {@link AzureDevOpsCredentialsProvider} instead.
*/
export async function getAzureRequestOptions(
config: AzureIntegrationConfig,
additionalHeaders?: Record<string, string>,
): Promise<{ headers: Record<string, string> }> {
const headers: Record<string, string> = additionalHeaders
? { ...additionalHeaders }
: {};
/*
* Since we do not have a way to determine which organization the request is for,
* we will use the first credential that does not have an organization specified.
*/
const credentialConfig = config.credentials?.filter(
credential =>
credential.organizations === undefined ||
credential.organizations.length === 0,
)[0];
if (credentialConfig) {
const credentialsProvider =
CachedAzureDevOpsCredentialsProvider.fromAzureDevOpsCredential(
credentialConfig,
);
const credentials = await credentialsProvider.getCredentials();
return {
headers: {
...credentials?.headers,
...headers,
},
};
}
return { headers };
}
-2
View File
@@ -38,5 +38,3 @@ export {
export * from './types';
export { DefaultAzureDevOpsCredentialsProvider } from './DefaultAzureDevOpsCredentialsProvider';
export * from './deprecated';
@@ -1,114 +0,0 @@
/*
* 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 { ConfigReader } from '@backstage/config';
import { BitbucketIntegration } from './BitbucketIntegration';
describe('BitbucketIntegration', () => {
describe('factory', () => {
it('works', () => {
const integrations = BitbucketIntegration.factory({
config: new ConfigReader({
integrations: {
bitbucket: [
{
host: 'h.com',
apiBaseUrl: 'a',
token: 't',
username: 'u',
appPassword: 'p',
},
],
},
}),
});
expect(integrations.list().length).toBe(2); // including default
expect(integrations.list()[0].config.host).toBe('h.com');
expect(integrations.list()[1].config.host).toBe('bitbucket.org');
});
it('falls back to bitbucketCloud+bitbucketServer', () => {
const integrations = BitbucketIntegration.factory({
config: new ConfigReader({
integrations: {
bitbucketCloud: [
{
username: 'u',
appPassword: 'p',
},
],
bitbucketServer: [
{
host: 'h.com',
apiBaseUrl: 'a',
token: 't',
},
],
},
}),
});
expect(integrations.list().length).toBe(2); // including default
expect(integrations.list()[0].config.host).toBe('bitbucket.org');
expect(integrations.list()[1].config.host).toBe('h.com');
});
});
it('returns the basics', () => {
const integration = new BitbucketIntegration({ host: 'h.com' } as any);
expect(integration.type).toBe('bitbucket');
expect(integration.title).toBe('h.com');
});
it('resolves url line number correctly for Bitbucket Cloud', () => {
const integration = new BitbucketIntegration({
host: 'bitbucket.org',
} as any);
expect(
integration.resolveUrl({
url: './a.yaml',
base: 'https://bitbucket.org/my-owner/my-project/src/master/README.md',
lineNumber: 14,
}),
).toBe(
'https://bitbucket.org/my-owner/my-project/src/master/a.yaml#lines-14',
);
});
it('resolves url line number correctly for Bitbucket Server', () => {
const integration = new BitbucketIntegration({ host: 'h.com' } as any);
expect(
integration.resolveUrl({
url: './a.yaml',
base: 'https://bitbucket.org/my-owner/my-project/src/master/README.md',
lineNumber: 14,
}),
).toBe('https://bitbucket.org/my-owner/my-project/src/master/a.yaml#14');
});
it('resolve edit URL', () => {
const integration = new BitbucketIntegration({ host: 'h.com' } as any);
expect(
integration.resolveEditUrl(
'https://bitbucket.org/my-owner/my-project/src/master/README.md',
),
).toBe(
'https://bitbucket.org/my-owner/my-project/src/master/README.md?mode=edit&spa=0&at=master',
);
});
});
@@ -1,99 +0,0 @@
/*
* 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 parseGitUrl from 'git-url-parse';
import { basicIntegrations, defaultScmResolveUrl } from '../helpers';
import { ScmIntegration, ScmIntegrationsFactory } from '../types';
import {
BitbucketIntegrationConfig,
readBitbucketIntegrationConfigs,
} from './config';
/**
* A Bitbucket based integration.
*
* @public
* @deprecated replaced by the integrations bitbucketCloud and bitbucketServer.
*/
export class BitbucketIntegration implements ScmIntegration {
static factory: ScmIntegrationsFactory<BitbucketIntegration> = ({
config,
}) => {
const configs = readBitbucketIntegrationConfigs(
config.getOptionalConfigArray('integrations.bitbucket') ?? [
// if integrations.bitbucket was not used assume the use was migrated to the new configs
// and backport for the deprecated integration to be usable for other parts of the system
// until these got migrated
...(config.getOptionalConfigArray('integrations.bitbucketCloud') ?? []),
...(config.getOptionalConfigArray('integrations.bitbucketServer') ??
[]),
],
);
return basicIntegrations(
configs.map(c => new BitbucketIntegration(c)),
i => i.config.host,
);
};
constructor(private readonly integrationConfig: BitbucketIntegrationConfig) {}
get type(): string {
return 'bitbucket';
}
get title(): string {
return this.integrationConfig.host;
}
get config(): BitbucketIntegrationConfig {
return this.integrationConfig;
}
resolveUrl(options: {
url: string;
base: string;
lineNumber?: number;
}): string {
const resolved = defaultScmResolveUrl(options);
if (!options.lineNumber) {
return resolved;
}
const url = new URL(resolved);
if (this.integrationConfig.host === 'bitbucket.org') {
// Bitbucket Cloud uses the syntax #lines-{start}[:{end}][,...]
url.hash = `lines-${options.lineNumber}`;
} else {
// Bitbucket Server uses the syntax #{start}[-{end}][,...]
url.hash = `${options.lineNumber}`;
}
return url.toString();
}
resolveEditUrl(url: string): string {
const urlData = parseGitUrl(url);
const editUrl = new URL(url);
editUrl.searchParams.set('mode', 'edit');
// TODO: Not sure what spa=0 does, at least bitbucket.org doesn't support it
// but this is taken over from the initial implementation.
editUrl.searchParams.set('spa', '0');
editUrl.searchParams.set('at', urlData.ref);
return editUrl.toString();
}
}
@@ -1,175 +0,0 @@
/*
* 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 { Config, ConfigReader } from '@backstage/config';
import { loadConfigSchema } from '@backstage/config-loader';
import {
BitbucketIntegrationConfig,
readBitbucketIntegrationConfig,
readBitbucketIntegrationConfigs,
} from './config';
describe('readBitbucketIntegrationConfig', () => {
function buildConfig(data: Partial<BitbucketIntegrationConfig>): Config {
return new ConfigReader(data);
}
async function buildFrontendConfig(
data: Partial<BitbucketIntegrationConfig>,
): Promise<Config> {
const fullSchema = await loadConfigSchema({
dependencies: ['@backstage/integration'],
});
const serializedSchema = fullSchema.serialize() as {
schemas: { value: { properties?: { integrations?: object } } }[];
};
const schema = await loadConfigSchema({
serialized: {
...serializedSchema, // only include schemas that apply to integrations
schemas: serializedSchema.schemas.filter(
s => s.value?.properties?.integrations,
),
},
});
const processed = schema.process(
[{ data: { integrations: { bitbucket: [data] } }, context: 'app' }],
{ visibility: ['frontend'] },
);
return new ConfigReader(
(processed[0].data as any).integrations.bitbucket[0],
);
}
it('reads all values', () => {
const output = readBitbucketIntegrationConfig(
buildConfig({
host: 'a.com',
apiBaseUrl: 'https://a.com/api',
token: 't\n\n\n',
username: 'u',
appPassword: '\n\n\np',
}),
);
expect(output).toEqual({
host: 'a.com',
apiBaseUrl: 'https://a.com/api',
token: 't',
username: 'u',
appPassword: 'p',
});
});
it('inserts the defaults if missing', () => {
const output = readBitbucketIntegrationConfig(buildConfig({}));
expect(output).toEqual(
expect.objectContaining({
host: 'bitbucket.org',
apiBaseUrl: 'https://api.bitbucket.org/2.0',
}),
);
});
it('rejects funky configs', () => {
const valid: any = {
host: 'a.com',
apiBaseUrl: 'https://a.com/api',
token: 't',
username: 'u',
appPassword: 'p',
};
expect(() =>
readBitbucketIntegrationConfig(buildConfig({ ...valid, host: 7 })),
).toThrow(/host/);
expect(() =>
readBitbucketIntegrationConfig(buildConfig({ ...valid, apiBaseUrl: 7 })),
).toThrow(/apiBaseUrl/);
expect(() =>
readBitbucketIntegrationConfig(buildConfig({ ...valid, token: 7 })),
).toThrow(/token/);
expect(() =>
readBitbucketIntegrationConfig(buildConfig({ ...valid, username: 7 })),
).toThrow(/username/);
expect(() =>
readBitbucketIntegrationConfig(buildConfig({ ...valid, appPassword: 7 })),
).toThrow(/appPassword/);
});
it('works on the frontend', async () => {
expect(
readBitbucketIntegrationConfig(
await buildFrontendConfig({
host: 'a.com',
apiBaseUrl: 'https://a.com/api',
token: 't',
username: 'u',
appPassword: 'p',
}),
),
).toEqual({
host: 'a.com',
apiBaseUrl: 'https://a.com/api',
});
});
});
describe('readBitbucketIntegrationConfigs', () => {
function buildConfig(data: Partial<BitbucketIntegrationConfig>[]): Config[] {
return data.map(item => new ConfigReader(item));
}
it('reads all values', () => {
const output = readBitbucketIntegrationConfigs(
buildConfig([
{
host: 'a.com',
apiBaseUrl: 'https://a.com/api',
token: 't',
username: 'u',
appPassword: 'p',
},
]),
);
expect(output).toContainEqual({
host: 'a.com',
apiBaseUrl: 'https://a.com/api',
token: 't',
username: 'u',
appPassword: 'p',
});
});
it('adds a default Bitbucket Cloud entry when missing', () => {
const output = readBitbucketIntegrationConfigs(buildConfig([]));
expect(output).toEqual([
{
host: 'bitbucket.org',
apiBaseUrl: 'https://api.bitbucket.org/2.0',
},
]);
});
it('injects the correct Bitbucket Cloud API base URL when missing', () => {
const output = readBitbucketIntegrationConfigs(
buildConfig([{ host: 'bitbucket.org' }]),
);
expect(output).toEqual([
{
host: 'bitbucket.org',
apiBaseUrl: 'https://api.bitbucket.org/2.0',
},
]);
});
});
@@ -1,136 +0,0 @@
/*
* 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 { Config } from '@backstage/config';
import { trimEnd } from 'lodash';
import { isValidHost } from '../helpers';
const BITBUCKET_HOST = 'bitbucket.org';
const BITBUCKET_API_BASE_URL = 'https://api.bitbucket.org/2.0';
/**
* The configuration parameters for a single Bitbucket API provider.
*
* @public
* @deprecated bitbucket integration replaced by integrations bitbucketCloud and bitbucketServer.
*/
export type BitbucketIntegrationConfig = {
/**
* The host of the target that this matches on, e.g. "bitbucket.org"
*/
host: string;
/**
* The base URL of the API of this provider, e.g. "https://api.bitbucket.org/2.0",
* with no trailing slash.
*
* Values omitted at the optional property at the app-config will be deduced
* from the "host" value.
*/
apiBaseUrl: string;
/**
* The authorization token to use for requests to a Bitbucket Server provider.
*
* See https://confluence.atlassian.com/bitbucketserver/personal-access-tokens-939515499.html
*
* If no token is specified, anonymous access is used.
*/
token?: string;
/**
* The username to use for requests to Bitbucket Cloud (bitbucket.org).
*/
username?: string;
/**
* Authentication with Bitbucket Cloud (bitbucket.org) is done using app passwords.
*
* See https://support.atlassian.com/bitbucket-cloud/docs/app-passwords/
*/
appPassword?: string;
/**
* Signing key for commits
*/
commitSigningKey?: string;
};
/**
* Reads a single Bitbucket integration config.
*
* @param config - The config object of a single integration
* @public
* @deprecated bitbucket integration replaced by integrations bitbucketCloud and bitbucketServer.
*/
export function readBitbucketIntegrationConfig(
config: Config,
): BitbucketIntegrationConfig {
const host = config.getOptionalString('host') ?? BITBUCKET_HOST;
let apiBaseUrl = config.getOptionalString('apiBaseUrl');
const token = config.getOptionalString('token')?.trim();
const username = config.getOptionalString('username');
const appPassword = config.getOptionalString('appPassword')?.trim();
if (!isValidHost(host)) {
throw new Error(
`Invalid Bitbucket integration config, '${host}' is not a valid host`,
);
}
if (apiBaseUrl) {
apiBaseUrl = trimEnd(apiBaseUrl, '/');
} else if (host === BITBUCKET_HOST) {
apiBaseUrl = BITBUCKET_API_BASE_URL;
} else {
apiBaseUrl = `https://${host}/rest/api/1.0`;
}
return {
host,
apiBaseUrl,
token,
username,
appPassword,
commitSigningKey: config.getOptionalString('commitSigningKey'),
};
}
/**
* Reads a set of Bitbucket integration configs, and inserts some defaults for
* public Bitbucket if not specified.
*
* @param configs - All of the integration config objects
* @public
* @deprecated bitbucket integration replaced by integrations bitbucketCloud and bitbucketServer.
*/
export function readBitbucketIntegrationConfigs(
configs: Config[],
): BitbucketIntegrationConfig[] {
// First read all the explicit integrations
const result = configs.map(readBitbucketIntegrationConfig);
// If no explicit bitbucket.org integration was added, put one in the list as
// a convenience
if (!result.some(c => c.host === BITBUCKET_HOST)) {
result.push({
host: BITBUCKET_HOST,
apiBaseUrl: BITBUCKET_API_BASE_URL,
});
}
return result;
}
@@ -1,304 +0,0 @@
/*
* 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 { registerMswTestHooks } from '../helpers';
import { BitbucketIntegrationConfig } from './config';
import {
getBitbucketDefaultBranch,
getBitbucketDownloadUrl,
getBitbucketFileFetchUrl,
getBitbucketRequestOptions,
} from './core';
describe('bitbucket core', () => {
const worker = setupServer();
registerMswTestHooks(worker);
describe('getBitbucketRequestOptions', () => {
it('inserts a token when needed', () => {
const withToken: BitbucketIntegrationConfig = {
host: '',
apiBaseUrl: '',
token: 'A',
};
const withoutToken: BitbucketIntegrationConfig = {
host: '',
apiBaseUrl: '',
};
expect(
(getBitbucketRequestOptions(withToken).headers as any).Authorization,
).toEqual('Bearer A');
expect(
(getBitbucketRequestOptions(withoutToken).headers as any).Authorization,
).toBeUndefined();
});
it('insert basic auth when needed', () => {
const withUsernameAndPassword: BitbucketIntegrationConfig = {
host: '',
apiBaseUrl: '',
username: 'some-user',
appPassword: 'my-secret',
};
const withoutUsernameAndPassword: BitbucketIntegrationConfig = {
host: '',
apiBaseUrl: '',
};
expect(
(getBitbucketRequestOptions(withUsernameAndPassword).headers as any)
.Authorization,
).toEqual('Basic c29tZS11c2VyOm15LXNlY3JldA==');
expect(
(getBitbucketRequestOptions(withoutUsernameAndPassword).headers as any)
.Authorization,
).toBeUndefined();
});
});
describe('getBitbucketFileFetchUrl', () => {
it('rejects targets that do not look like URLs', () => {
const config: BitbucketIntegrationConfig = { host: '', apiBaseUrl: '' };
expect(() => getBitbucketFileFetchUrl('a/b', config)).toThrow(
/Incorrect URL: a\/b/,
);
});
it('happy path for Bitbucket Cloud', () => {
const config: BitbucketIntegrationConfig = {
host: 'bitbucket.org',
apiBaseUrl: 'https://api.bitbucket.org/2.0',
};
expect(
getBitbucketFileFetchUrl(
'https://bitbucket.org/org-name/repo-name/src/master/templates/my-template.yaml',
config,
),
).toEqual(
'https://api.bitbucket.org/2.0/repositories/org-name/repo-name/src/master/templates/my-template.yaml',
);
});
it('happy path for Bitbucket Server', () => {
const config: BitbucketIntegrationConfig = {
host: 'bitbucket.mycompany.net',
apiBaseUrl: 'https://bitbucket.mycompany.net/rest/api/1.0',
};
expect(
getBitbucketFileFetchUrl(
'https://bitbucket.mycompany.net/projects/a/repos/b/browse/path/to/c.yaml',
config,
),
).toEqual(
'https://bitbucket.mycompany.net/rest/api/1.0/projects/a/repos/b/raw/path/to/c.yaml?at=',
);
});
});
describe('getBitbucketDownloadUrl', () => {
it('add path param if a path is specified for Bitbucket Server', async () => {
const defaultBranchResponse = {
displayId: 'main',
};
worker.use(
rest.get(
'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/default-branch',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/json'),
ctx.json(defaultBranchResponse),
),
),
);
const config: BitbucketIntegrationConfig = {
host: 'bitbucket.mycompany.net',
apiBaseUrl: 'https://api.bitbucket.mycompany.net/rest/api/1.0',
};
const result = await getBitbucketDownloadUrl(
'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/docs',
config,
);
expect(result).toEqual(
'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/archive?format=tgz&at=main&prefix=backstage-mock&path=docs',
);
});
it('does not double encode the filepath', async () => {
const config: BitbucketIntegrationConfig = {
host: 'bitbucket.mycompany.net',
apiBaseUrl: 'https://api.bitbucket.mycompany.net/rest/api/1.0',
};
const result = await getBitbucketDownloadUrl(
'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/%2Fdocs?at=some-branch',
config,
);
expect(result).toEqual(
'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/archive?format=tgz&at=some-branch&prefix=backstage-mock&path=%2Fdocs',
);
});
it('do not add path param if no path is specified for Bitbucket Server', async () => {
const defaultBranchResponse = {
displayId: 'main',
};
worker.use(
rest.get(
'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/default-branch',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/json'),
ctx.json(defaultBranchResponse),
),
),
);
const config: BitbucketIntegrationConfig = {
host: 'bitbucket.mycompany.net',
apiBaseUrl: 'https://api.bitbucket.mycompany.net/rest/api/1.0',
};
const result = await getBitbucketDownloadUrl(
'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse',
config,
);
expect(result).toEqual(
'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/archive?format=tgz&at=main&prefix=backstage-mock',
);
});
it('get by branch for Bitbucket Server', async () => {
const config: BitbucketIntegrationConfig = {
host: 'bitbucket.mycompany.net',
apiBaseUrl: 'https://api.bitbucket.mycompany.net/rest/api/1.0',
};
const result = await getBitbucketDownloadUrl(
'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/docs?at=some-branch',
config,
);
expect(result).toEqual(
'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/archive?format=tgz&at=some-branch&prefix=backstage-mock&path=docs',
);
});
it('do not add path param for Bitbucket Cloud', async () => {
const config: BitbucketIntegrationConfig = {
host: 'bitbucket.org',
apiBaseUrl: 'https://api.bitbucket.org/2.0',
};
const result = await getBitbucketDownloadUrl(
'https://bitbucket.org/backstage/mock/src/master',
config,
);
expect(result).toEqual(
'https://bitbucket.org/backstage/mock/get/master.tar.gz',
);
});
});
describe('getBitbucketDefaultBranch', () => {
it('return default branch for Bitbucket Cloud', async () => {
const repoInfoResponse = {
mainbranch: {
name: 'main',
},
};
worker.use(
rest.get(
'https://api.bitbucket.org/2.0/repositories/backstage/mock',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/json'),
ctx.json(repoInfoResponse),
),
),
);
const config: BitbucketIntegrationConfig = {
host: 'bitbucket.org',
apiBaseUrl: 'https://api.bitbucket.org/2.0',
};
const defaultBranch = await getBitbucketDefaultBranch(
'https://bitbucket.org/backstage/mock/src/main',
config,
);
expect(defaultBranch).toEqual('main');
});
it('return default branch for Bitbucket Server', async () => {
const defaultBranchResponse = {
displayId: 'main',
};
worker.use(
rest.get(
'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/default-branch',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/json'),
ctx.json(defaultBranchResponse),
),
),
);
const config: BitbucketIntegrationConfig = {
host: 'bitbucket.mycompany.net',
apiBaseUrl: 'https://api.bitbucket.mycompany.net/rest/api/1.0',
};
const defaultBranch = await getBitbucketDefaultBranch(
'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/README.md',
config,
);
expect(defaultBranch).toEqual('main');
});
it('return default branch for Bitbucket Server for bitbucket version 5.11', async () => {
const defaultBranchResponse = {
displayId: 'main',
};
worker.use(
rest.get(
'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/default-branch',
(_, res, ctx) =>
res(
ctx.status(404),
ctx.set('Content-Type', 'application/json'),
ctx.json(defaultBranchResponse),
),
),
rest.get(
'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/branches/default',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/json'),
ctx.json(defaultBranchResponse),
),
),
);
const config: BitbucketIntegrationConfig = {
host: 'bitbucket.mycompany.net',
apiBaseUrl: 'https://api.bitbucket.mycompany.net/rest/api/1.0',
};
const defaultBranch = await getBitbucketDefaultBranch(
'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/README.md',
config,
);
expect(defaultBranch).toEqual('main');
});
});
});
-183
View File
@@ -1,183 +0,0 @@
/*
* 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 fetch from 'cross-fetch';
import parseGitUrl from 'git-url-parse';
import { BitbucketIntegrationConfig } from './config';
/**
* Given a URL pointing to a path on a provider, returns the default branch.
*
* @param url - A URL pointing to a path
* @param config - The relevant provider config
* @public
* @deprecated no longer in use, bitbucket integration replaced by integrations bitbucketCloud and bitbucketServer.
*/
export async function getBitbucketDefaultBranch(
url: string,
config: BitbucketIntegrationConfig,
): Promise<string> {
const { name: repoName, owner: project, resource } = parseGitUrl(url);
const isHosted = resource === 'bitbucket.org';
// Bitbucket Server https://docs.atlassian.com/bitbucket-server/rest/7.9.0/bitbucket-rest.html#idp184
let branchUrl = isHosted
? `${config.apiBaseUrl}/repositories/${project}/${repoName}`
: `${config.apiBaseUrl}/projects/${project}/repos/${repoName}/default-branch`;
let response = await fetch(branchUrl, getBitbucketRequestOptions(config));
if (response.status === 404 && !isHosted) {
// First try the new format, and then if it gets specifically a 404 it should try the old format
// (to support old Atlassian Bitbucket v5.11.1 format )
branchUrl = `${config.apiBaseUrl}/projects/${project}/repos/${repoName}/branches/default`;
response = await fetch(branchUrl, getBitbucketRequestOptions(config));
}
if (!response.ok) {
const message = `Failed to retrieve default branch from ${branchUrl}, ${response.status} ${response.statusText}`;
throw new Error(message);
}
let defaultBranch;
if (isHosted) {
const repoInfo = await response.json();
defaultBranch = repoInfo.mainbranch.name;
} else {
const { displayId } = await response.json();
defaultBranch = displayId;
}
if (!defaultBranch) {
throw new Error(
`Failed to read default branch from ${branchUrl}. ` +
`Response ${response.status} ${response.json()}`,
);
}
return defaultBranch;
}
/**
* Given a URL pointing to a path on a provider, returns a URL that is suitable
* for downloading the subtree.
*
* @param url - A URL pointing to a path
* @param config - The relevant provider config
* @public
* @deprecated no longer in use, bitbucket integration replaced by integrations bitbucketCloud and bitbucketServer.
*/
export async function getBitbucketDownloadUrl(
url: string,
config: BitbucketIntegrationConfig,
): Promise<string> {
const {
name: repoName,
owner: project,
ref,
protocol,
resource,
filepath,
} = parseGitUrl(url);
const isHosted = resource === 'bitbucket.org';
let branch = ref;
if (!branch) {
branch = await getBitbucketDefaultBranch(url, config);
}
// path will limit the downloaded content
// /docs will only download the docs folder and everything below it
// /docs/index.md will download the docs folder and everything below it
const path = filepath
? `&path=${encodeURIComponent(decodeURIComponent(filepath))}`
: '';
const archiveUrl = isHosted
? `${protocol}://${resource}/${project}/${repoName}/get/${branch}.tar.gz`
: `${config.apiBaseUrl}/projects/${project}/repos/${repoName}/archive?format=tgz&at=${branch}&prefix=${project}-${repoName}${path}`;
return archiveUrl;
}
/**
* Given a URL pointing to a file on a provider, returns a URL that is suitable
* for fetching the contents of the data.
*
* @remarks
*
* Converts
* from: https://bitbucket.org/orgname/reponame/src/master/file.yaml
* to: https://api.bitbucket.org/2.0/repositories/orgname/reponame/src/master/file.yaml
*
* @param url - A URL pointing to a file
* @param config - The relevant provider config
* @public
* @deprecated no longer in use, bitbucket integration replaced by integrations bitbucketCloud and bitbucketServer.
*/
export function getBitbucketFileFetchUrl(
url: string,
config: BitbucketIntegrationConfig,
): string {
try {
const { owner, name, ref, filepathtype, filepath } = parseGitUrl(url);
if (
!owner ||
!name ||
(filepathtype !== 'browse' &&
filepathtype !== 'raw' &&
filepathtype !== 'src')
) {
throw new Error('Invalid Bitbucket URL or file path');
}
const pathWithoutSlash = filepath.replace(/^\//, '');
if (config.host === 'bitbucket.org') {
if (!ref) {
throw new Error('Invalid Bitbucket URL or file path');
}
return `${config.apiBaseUrl}/repositories/${owner}/${name}/src/${ref}/${pathWithoutSlash}`;
}
return `${config.apiBaseUrl}/projects/${owner}/repos/${name}/raw/${pathWithoutSlash}?at=${ref}`;
} catch (e) {
throw new Error(`Incorrect URL: ${url}, ${e}`);
}
}
/**
* Gets the request options necessary to make requests to a given provider.
*
* @param config - The relevant provider config
* @public
* @deprecated no longer in use, bitbucket integration replaced by integrations bitbucketCloud and bitbucketServer.
*/
export function getBitbucketRequestOptions(
config: BitbucketIntegrationConfig,
): { headers: Record<string, string> } {
const headers: Record<string, string> = {};
if (config.token) {
headers.Authorization = `Bearer ${config.token}`;
} else if (config.username && config.appPassword) {
const buffer = Buffer.from(
`${config.username}:${config.appPassword}`,
'utf8',
);
headers.Authorization = `Basic ${buffer.toString('base64')}`;
}
return {
headers,
};
}
@@ -1,28 +0,0 @@
/*
* 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.
*/
export { BitbucketIntegration } from './BitbucketIntegration';
export {
readBitbucketIntegrationConfig,
readBitbucketIntegrationConfigs,
} from './config';
export type { BitbucketIntegrationConfig } from './config';
export {
getBitbucketDefaultBranch,
getBitbucketDownloadUrl,
getBitbucketFileFetchUrl,
getBitbucketRequestOptions,
} from './core';
+40 -140
View File
@@ -20,7 +20,6 @@ import fetch from 'cross-fetch';
import { registerMswTestHooks } from '../helpers';
import { GerritIntegrationConfig } from './config';
import {
buildGerritGitilesArchiveUrl,
buildGerritGitilesArchiveUrlFromLocation,
buildGerritGitilesUrl,
getGerritBranchApiUrl,
@@ -28,7 +27,6 @@ import {
getGerritRequestOptions,
parseGerritJsonResponse,
parseGitilesUrlRef,
parseGerritGitilesUrl,
getGerritFileContentsApiUrl,
} from './core';
@@ -36,86 +34,6 @@ describe('gerrit core', () => {
const worker = setupServer();
registerMswTestHooks(worker);
describe('buildGerritGitilesArchiveUrl', () => {
const config: GerritIntegrationConfig = {
host: 'gerrit.com',
baseUrl: 'https://gerrit.com',
gitilesBaseUrl: 'https://gerrit.com/gitiles',
};
const configWithPath: GerritIntegrationConfig = {
host: 'gerrit.com',
baseUrl: 'https://gerrit.com/gerrit',
gitilesBaseUrl: 'https://gerrit.com/gerrit/plugins/gitiles',
};
const configWithDedicatedGitiles: GerritIntegrationConfig = {
host: 'gerrit.com',
baseUrl: 'https://gerrit.com/gerrit',
gitilesBaseUrl: 'https://dedicated-gitiles-server.com/gerrit/gitiles',
};
it('can create an archive url for a branch', () => {
expect(buildGerritGitilesArchiveUrl(config, 'repo', 'dev', '')).toEqual(
'https://gerrit.com/gitiles/repo/+archive/refs/heads/dev.tar.gz',
);
expect(buildGerritGitilesArchiveUrl(config, 'repo', 'dev', '/')).toEqual(
'https://gerrit.com/gitiles/repo/+archive/refs/heads/dev.tar.gz',
);
});
it('can create an archive url for a specific directory', () => {
expect(
buildGerritGitilesArchiveUrl(config, 'repo', 'dev', 'docs'),
).toEqual(
'https://gerrit.com/gitiles/repo/+archive/refs/heads/dev/docs.tar.gz',
);
});
it('can create an authenticated url when auth is enabled', () => {
const authConfig = {
...config,
username: 'username',
password: 'password',
};
expect(
buildGerritGitilesArchiveUrl(authConfig, 'repo', 'dev', 'docs'),
).toEqual(
'https://gerrit.com/a/gitiles/repo/+archive/refs/heads/dev/docs.tar.gz',
);
});
it('can create an authenticated url when auth is enabled and an url-path is used', () => {
const authConfig = {
...configWithPath,
username: 'username',
password: 'password',
};
expect(
buildGerritGitilesArchiveUrl(authConfig, 'repo', 'dev', 'docs'),
).toEqual(
'https://gerrit.com/gerrit/a/plugins/gitiles/repo/+archive/refs/heads/dev/docs.tar.gz',
);
});
it('Cannot build an authenticated url when a dedicated Gitiles server is used', () => {
const authConfig = {
...configWithDedicatedGitiles,
username: 'username',
password: 'password',
};
expect(() =>
buildGerritGitilesArchiveUrl(authConfig, 'repo', 'dev', 'docs'),
).toThrow(
'Since the baseUrl (Gerrit) is not part of the gitilesBaseUrl, an authentication URL could not be constructed.',
);
});
it('Build a non-authenticated url when a dedicated Gitiles server is used', () => {
const authConfig = {
...configWithDedicatedGitiles,
};
expect(
buildGerritGitilesArchiveUrl(authConfig, 'repo', 'dev', 'docs'),
).toEqual(
'https://dedicated-gitiles-server.com/gerrit/gitiles/repo/+archive/refs/heads/dev/docs.tar.gz',
);
});
});
describe('buildGerritGitilesArchiveUrlFromLocation', () => {
const config: GerritIntegrationConfig = {
host: 'gerrit.com',
@@ -238,6 +156,7 @@ describe('gerrit core', () => {
).toBeUndefined();
});
});
describe('parseGitilesUrlRef', () => {
const config: GerritIntegrationConfig = {
host: 'gerrit.com',
@@ -360,64 +279,6 @@ describe('gerrit core', () => {
});
});
});
describe('parseGerritGitilesUrl', () => {
it('can parse a valid gitiles urls.', () => {
const config: GerritIntegrationConfig = {
host: 'gerrit.com',
gitilesBaseUrl: 'https://gerrit.com/gitiles',
};
const { branch, filePath, project } = parseGerritGitilesUrl(
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 } = parseGerritGitilesUrl(
config,
'https://gerrit.com/gitiles/web/project/+/refs/heads/master',
);
expect(rootPath).toEqual('/');
});
it('can parse a valid authenticated gitiles url.', () => {
const config: GerritIntegrationConfig = {
host: 'gerrit.com',
gitilesBaseUrl: 'https://gerrit.com/gitiles',
};
const { branch, filePath, project } = parseGerritGitilesUrl(
config,
'https://gerrit.com/a/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 } = parseGerritGitilesUrl(
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(() =>
parseGerritGitilesUrl(
config,
'https://gerrit.com/+/refs/heads/master/README.md',
),
).toThrow(/project/);
expect(() =>
parseGerritGitilesUrl(
config,
'https://gerrit.com/web/project/+/refs/changes/1/11/master/README.md',
),
).toThrow(/branch/);
});
});
describe('getGerritBranchApiUrl', () => {
it('can create an url for anonymous access.', () => {
@@ -450,6 +311,45 @@ describe('gerrit core', () => {
'https://gerrit.com/a/projects/web%2Fproject/branches/master',
);
});
it('throws when ref type is not a branch (tag).', () => {
const config: GerritIntegrationConfig = {
host: 'gerrit.com',
baseUrl: 'https://gerrit.com',
gitilesBaseUrl: 'https://gerrit.com',
};
expect(() =>
getGerritBranchApiUrl(
config,
'https://gerrit.com/web/project/+/refs/tags/v1.0.0/README.md',
),
).toThrow('Unsupported gitiles ref type: tag');
});
it('throws when ref type is not a branch (sha).', () => {
const config: GerritIntegrationConfig = {
host: 'gerrit.com',
baseUrl: 'https://gerrit.com',
gitilesBaseUrl: 'https://gerrit.com',
};
expect(() =>
getGerritBranchApiUrl(
config,
'https://gerrit.com/web/project/+/157f862803d45b9d269f0e390f88aece1ded51e8/README.md',
),
).toThrow('Unsupported gitiles ref type: sha');
});
it('throws when ref type is not a branch (head).', () => {
const config: GerritIntegrationConfig = {
host: 'gerrit.com',
baseUrl: 'https://gerrit.com',
gitilesBaseUrl: 'https://gerrit.com',
};
expect(() =>
getGerritBranchApiUrl(
config,
'https://gerrit.com/web/project/+/HEAD/README.md',
),
).toThrow('Unsupported gitiles ref type: head');
});
});
describe('getGerritCloneRepoUrl', () => {
+7 -91
View File
@@ -18,70 +18,6 @@ import { GerritIntegrationConfig } from './config';
const GERRIT_BODY_PREFIX = ")]}'";
/**
* 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\}
* https://g.com/a/optional_path/\{project\}/+/refs/heads/\{branch\}/\{filePath\}
*
*
* @param url - An URL pointing to a file stored in git.
* @public
* @deprecated `parseGerritGitilesUrl` is deprecated. Use
* {@link parseGitilesUrlRef} instead.
*/
export function parseGerritGitilesUrl(
config: GerritIntegrationConfig,
url: string,
): { branch: string; filePath: string; project: string } {
const baseUrlParse = new URL(config.gitilesBaseUrl!);
const urlParse = new URL(url);
// Remove the gerrit authentication prefix '/a/' from the url
// In case of the gitilesBaseUrl is https://review.gerrit.com/plugins/gitiles
// and the url provided is https://review.gerrit.com/a/plugins/gitiles/...
// remove the prefix only if the pathname start with '/a/'
const urlPath = urlParse.pathname
.substring(urlParse.pathname.startsWith('/a/') ? 2 : 0)
.replace(baseUrlParse.pathname, '');
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,
};
}
/**
* Parses Gitiles urls and returns the following:
*
@@ -231,30 +167,6 @@ export function buildGerritEditUrl(
)}`;
}
/**
* Build a Gerrit Gitiles archive url that targets a specific branch and path
*
* @param config - A Gerrit provider config.
* @param project - The name of the git project
* @param branch - The branch we will target.
* @param filePath - The absolute file path.
* @public
* @deprecated `buildGerritGitilesArchiveUrl` is deprecated. Use
* {@link buildGerritGitilesArchiveUrlFromLocation} instead.
*/
export function buildGerritGitilesArchiveUrl(
config: GerritIntegrationConfig,
project: string,
branch: string,
filePath: string,
): string {
const archiveName =
filePath === '/' || filePath === '' ? '.tar.gz' : `/${filePath}.tar.gz`;
return `${getGitilesAuthenticationUrl(
config,
)}/${project}/+archive/refs/heads/${branch}${archiveName}`;
}
/**
* Build a Gerrit Gitiles archive url from a Gitiles url.
*
@@ -350,11 +262,15 @@ export function getGerritBranchApiUrl(
config: GerritIntegrationConfig,
url: string,
) {
const { branch, project } = parseGerritGitilesUrl(config, url);
const { ref, refType, project } = parseGitilesUrlRef(config, url);
if (refType !== 'branch') {
throw new Error(`Unsupported gitiles ref type: ${refType}`);
}
return `${config.baseUrl}${getAuthenticationPrefix(
config,
)}projects/${encodeURIComponent(project)}/branches/${branch}`;
)}projects/${encodeURIComponent(project)}/branches/${ref}`;
}
/**
@@ -367,7 +283,7 @@ export function getGerritCloneRepoUrl(
config: GerritIntegrationConfig,
url: string,
) {
const { project } = parseGerritGitilesUrl(config, url);
const { project } = parseGitilesUrlRef(config, url);
return `${config.cloneUrl}${getAuthenticationPrefix(config)}${project}`;
}
-2
View File
@@ -19,7 +19,6 @@ export {
readGerritIntegrationConfigs,
} from './config';
export {
buildGerritGitilesArchiveUrl,
buildGerritGitilesArchiveUrlFromLocation,
getGitilesAuthenticationUrl,
getGerritBranchApiUrl,
@@ -28,7 +27,6 @@ export {
getGerritProjectsApiUrl,
getGerritRequestOptions,
parseGerritJsonResponse,
parseGerritGitilesUrl,
parseGitilesUrlRef,
} from './core';
+1 -23
View File
@@ -15,7 +15,7 @@
*/
import { GithubIntegrationConfig } from './config';
import { getGithubFileFetchUrl, getGitHubRequestOptions } from './core';
import { getGithubFileFetchUrl } from './core';
import { GithubCredentials } from './types';
describe('github core', () => {
@@ -35,28 +35,6 @@ describe('github core', () => {
type: 'token',
};
describe('getGitHubRequestOptions', () => {
it('inserts a token when needed', () => {
const withToken: GithubIntegrationConfig = {
host: '',
rawBaseUrl: '',
token: 'A',
};
const withoutToken: GithubIntegrationConfig = {
host: '',
rawBaseUrl: '',
};
expect(
(getGitHubRequestOptions(withToken, appCredentials).headers as any)
.Authorization,
).toEqual('token A');
expect(
(getGitHubRequestOptions(withoutToken, noCredentials).headers as any)
.Authorization,
).toBeUndefined();
});
});
describe('getGithubFileFetchUrl', () => {
it('rejects targets that do not look like URLs', () => {
const config: GithubIntegrationConfig = { host: '', apiBaseUrl: '' };
-24
View File
@@ -63,30 +63,6 @@ export function getGithubFileFetchUrl(
}
}
/**
* Gets the request options necessary to make requests to a given provider.
*
* @deprecated This function is no longer used internally
* @param config - The relevant provider config
* @public
*/
export function getGitHubRequestOptions(
config: GithubIntegrationConfig,
credentials: GithubCredentials,
): { headers: Record<string, string> } {
const headers: Record<string, string> = {};
if (chooseEndpoint(config, credentials) === 'api') {
headers.Accept = 'application/vnd.github.v3.raw';
}
if (credentials.token) {
headers.Authorization = `token ${credentials.token}`;
}
return { headers };
}
export function chooseEndpoint(
config: GithubIntegrationConfig,
credentials: GithubCredentials,
+1 -1
View File
@@ -19,7 +19,7 @@ export {
readGithubIntegrationConfigs,
} from './config';
export type { GithubAppConfig, GithubIntegrationConfig } from './config';
export { getGithubFileFetchUrl, getGitHubRequestOptions } from './core';
export { getGithubFileFetchUrl } from './core';
export { DefaultGithubCredentialsProvider } from './DefaultGithubCredentialsProvider';
export {
GithubAppCredentialsMux,
-1
View File
@@ -24,7 +24,6 @@ export * from './awsS3';
export * from './awsCodeCommit';
export * from './azureBlobStorage';
export * from './azure';
export * from './bitbucket';
export * from './bitbucketCloud';
export * from './bitbucketServer';
export * from './gerrit';
-5
View File
@@ -19,7 +19,6 @@ import { AwsS3Integration } from './awsS3/AwsS3Integration';
import { AwsCodeCommitIntegration } from './awsCodeCommit';
import { AzureIntegration } from './azure/AzureIntegration';
import { BitbucketCloudIntegration } from './bitbucketCloud/BitbucketCloudIntegration';
import { BitbucketIntegration } from './bitbucket/BitbucketIntegration';
import { BitbucketServerIntegration } from './bitbucketServer/BitbucketServerIntegration';
import { GerritIntegration } from './gerrit/GerritIntegration';
import { GithubIntegration } from './github/GithubIntegration';
@@ -39,10 +38,6 @@ export interface ScmIntegrationRegistry
awsCodeCommit: ScmIntegrationsGroup<AwsCodeCommitIntegration>;
azureBlobStorage: ScmIntegrationsGroup<AzureBlobStorageIntergation>;
azure: ScmIntegrationsGroup<AzureIntegration>;
/**
* @deprecated in favor of `bitbucketCloud` and `bitbucketServer`
*/
bitbucket: ScmIntegrationsGroup<BitbucketIntegration>;
bitbucketCloud: ScmIntegrationsGroup<BitbucketCloudIntegration>;
bitbucketServer: ScmIntegrationsGroup<BitbucketServerIntegration>;
gerrit: ScmIntegrationsGroup<GerritIntegration>;
+1 -1
View File
@@ -2,6 +2,6 @@
"$schema": "../../node_modules/@openapitools/openapi-generator-cli/config.schema.json",
"spaces": 2,
"generator-cli": {
"version": "6.5.0"
"version": "7.18.0"
}
}
@@ -226,6 +226,7 @@ export function registerCommands(program: Command) {
'CI run checks that there are no changes to catalog-info.yaml files',
)
.description('Create or fix info yaml files for all backstage packages')
.allowExcessArguments(true)
.action(
lazy(
() => import('./generate-catalog-info/generate-catalog-info'),
@@ -26,6 +26,7 @@ import {
import { deduplicateImports } from '../../../../../lib/openapi/dedupe-imports';
import { targetPaths } from '@backstage/cli-common';
import {
getOpenApiGeneratorKey,
getPathToCurrentOpenApiSpec,
toGeneratorAdditionalProperties,
} from '../../../../../lib/openapi/helpers';
@@ -43,6 +44,7 @@ async function generate(
const additionalProperties = toGeneratorAdditionalProperties({
initialValue: clientAdditionalProperties,
});
const generatorKey = await getOpenApiGeneratorKey(resolvedOpenapiPath);
await fs.emptyDir(resolvedOutputDirectory);
@@ -68,7 +70,7 @@ async function generate(
'templates/typescript-backstage-client.yaml',
),
'--generator-key',
'v3.0',
generatorKey,
additionalProperties
? `--additional-properties=${additionalProperties}`
: '',
@@ -111,7 +113,12 @@ async function generate(
}
fs.removeSync(resolve(resolvedOutputDirectory, '.openapi-generator-ignore'));
fs.removeSync(resolve(resolvedOutputDirectory, '.gitattributes'));
fs.rmSync(resolve(resolvedOutputDirectory, 'docs'), {
recursive: true,
force: true,
});
fs.rmSync(resolve(resolvedOutputDirectory, '.openapi-generator'), {
recursive: true,
force: true,
@@ -29,6 +29,7 @@ import {
import { deduplicateImports } from '../../../../../lib/openapi/dedupe-imports';
import { targetPaths } from '@backstage/cli-common';
import {
getOpenApiGeneratorKey,
getPathToCurrentOpenApiSpec,
getRelativePathToFile,
toGeneratorAdditionalProperties,
@@ -103,6 +104,7 @@ async function generate(
const additionalProperties = toGeneratorAdditionalProperties({
initialValue: serverAdditionalProperties,
});
const generatorKey = await getOpenApiGeneratorKey(resolvedOpenapiPath);
await exec(
'node',
@@ -121,7 +123,7 @@ async function generate(
'templates/typescript-backstage-server.yaml',
),
'--generator-key',
'v3.0',
generatorKey,
additionalProperties
? `--additional-properties=${additionalProperties}`
: '',
@@ -160,7 +162,12 @@ async function generate(
}
fs.removeSync(resolve(resolvedOutputDirectory, '.openapi-generator-ignore'));
fs.removeSync(resolve(resolvedOutputDirectory, '.gitattributes'));
fs.rmSync(resolve(resolvedOutputDirectory, 'docs'), {
recursive: true,
force: true,
});
fs.rmSync(resolve(resolvedOutputDirectory, '.openapi-generator'), {
recursive: true,
force: true,
@@ -49,13 +49,15 @@ async function lint(directoryPath: string, config?: { strict: boolean }) {
{
extends: [oas, ruleset],
rules: {
'allow-reserved-in-params': {
given: '$.paths..parameters[*]',
'allow-reserved-in-query-params': {
given: '$.paths..parameters[?(@.in == "query")]',
then: {
field: 'allowReserved',
function: truthy,
},
severity: 'error',
message:
'Query parameters must specify allowReserved (true or false)',
},
},
overrides: [
@@ -70,3 +70,32 @@ export function toGeneratorAdditionalProperties({
.map(([key, value]) => `${key}=${value}`)
.join(',');
}
export async function getOpenApiGeneratorKey(
specPath: string,
): Promise<string> {
const yaml = (await loadAndValidateOpenApiYaml(specPath)) as any;
const version = yaml.openapi;
if (!version) {
throw new Error(`Could not determine OpenAPI version from ${specPath}`);
}
const semver = /^(\d+)\.(\d+)\.(\d+)(-.+)?$/.exec(version);
if (!semver) {
throw new Error(`Invalid OpenAPI version format ${version} in ${specPath}`);
}
const [, major, minor] = semver;
const supportedVersions = ['3.0', '3.1'];
const majorMinor = `${major}.${minor}`;
if (!supportedVersions.includes(majorMinor)) {
throw new Error(
`Unsupported OpenAPI version ${version} in ${specPath}. Supported versions are: ${supportedVersions.join(
', ',
)}`,
);
}
return `v${majorMinor}`;
}

Some files were not shown because too many files have changed in this diff Show More