Merge pull request #2285 from spotify/rugvip/di

Switch to automatic dependency injection of Utility APIs
This commit is contained in:
Patrik Oldsberg
2020-09-08 18:51:23 +02:00
committed by GitHub
40 changed files with 889 additions and 729 deletions
+34 -178
View File
@@ -15,63 +15,18 @@
*/
import {
ApiRegistry,
alertApiRef,
errorApiRef,
AlertApiForwarder,
ConfigApi,
ErrorApiForwarder,
ErrorAlerter,
featureFlagsApiRef,
FeatureFlags,
discoveryApiRef,
UrlPatternDiscovery,
GoogleAuth,
GithubAuth,
OAuth2,
OktaAuth,
GitlabAuth,
Auth0Auth,
MicrosoftAuth,
oauthRequestApiRef,
OAuthRequestManager,
googleAuthApiRef,
githubAuthApiRef,
oauth2ApiRef,
oktaAuthApiRef,
gitlabAuthApiRef,
auth0AuthApiRef,
microsoftAuthApiRef,
storageApiRef,
WebStorage,
createApiFactory,
configApiRef,
} from '@backstage/core';
import {
lighthouseApiRef,
LighthouseRestApi,
} from '@backstage/plugin-lighthouse';
import { CircleCIApi, circleCIApiRef } from '@backstage/plugin-circleci';
import { catalogApiRef, CatalogClient } from '@backstage/plugin-catalog';
import { gitOpsApiRef, GitOpsRestApi } from '@backstage/plugin-gitops-profiles';
import {
graphQlBrowseApiRef,
GraphQLEndpoints,
} from '@backstage/plugin-graphiql';
import { scaffolderApiRef, ScaffolderApi } from '@backstage/plugin-scaffolder';
import {
techdocsStorageApiRef,
TechDocsStorageApi,
} from '@backstage/plugin-techdocs';
import { rollbarApiRef, RollbarClient } from '@backstage/plugin-rollbar';
import { GCPClient, GCPApiRef } from '@backstage/plugin-gcp-projects';
import {
GithubActionsClient,
githubActionsApiRef,
} from '@backstage/plugin-github-actions';
import { jenkinsApiRef, JenkinsApi } from '@backstage/plugin-jenkins';
import {
TravisCIApi,
@@ -82,135 +37,36 @@ import {
githubPullRequestsApiRef,
} from '@roadiehq/backstage-plugin-github-pull-requests';
export const apis = (config: ConfigApi) => {
// eslint-disable-next-line no-console
console.log(`Creating APIs for ${config.getString('app.title')}`);
export const apis = [
// TODO(Rugvip): migrate to use /api
createApiFactory({
api: discoveryApiRef,
deps: { configApi: configApiRef },
factory: ({ configApi }) =>
UrlPatternDiscovery.compile(
`${configApi.getString('backend.baseUrl')}/{{ pluginId }}`,
),
}),
createApiFactory({
api: graphQlBrowseApiRef,
deps: { errorApi: errorApiRef, githubAuthApi: githubAuthApiRef },
factory: ({ errorApi, githubAuthApi }) =>
GraphQLEndpoints.from([
GraphQLEndpoints.create({
id: 'gitlab',
title: 'GitLab',
url: 'https://gitlab.com/api/graphql',
}),
GraphQLEndpoints.github({
id: 'github',
title: 'GitHub',
errorApi,
githubAuthApi,
}),
]),
}),
const backendUrl = config.getString('backend.baseUrl');
const techdocsUrl = config.getString('techdocs.storageUrl');
const builder = ApiRegistry.builder();
const discoveryApi = builder.add(
discoveryApiRef,
UrlPatternDiscovery.compile(`${backendUrl}/{{ pluginId }}`),
);
const alertApi = builder.add(alertApiRef, new AlertApiForwarder());
const errorApi = builder.add(
errorApiRef,
new ErrorAlerter(alertApi, new ErrorApiForwarder()),
);
builder.add(storageApiRef, WebStorage.create({ errorApi }));
builder.add(GCPApiRef, new GCPClient());
builder.add(
circleCIApiRef,
new CircleCIApi(`${backendUrl}/proxy/circleci/api`),
);
builder.add(jenkinsApiRef, new JenkinsApi(`${backendUrl}/proxy/jenkins/api`));
builder.add(githubActionsApiRef, new GithubActionsClient());
builder.add(featureFlagsApiRef, new FeatureFlags());
builder.add(lighthouseApiRef, LighthouseRestApi.fromConfig(config));
builder.add(travisCIApiRef, new TravisCIApi());
builder.add(githubPullRequestsApiRef, new GithubPullRequestsClient());
const oauthRequestApi = builder.add(
oauthRequestApiRef,
new OAuthRequestManager(),
);
builder.add(
googleAuthApiRef,
GoogleAuth.create({
discoveryApi,
oauthRequestApi,
}),
);
builder.add(
microsoftAuthApiRef,
MicrosoftAuth.create({
discoveryApi,
oauthRequestApi,
}),
);
const githubAuthApi = builder.add(
githubAuthApiRef,
GithubAuth.create({
discoveryApi,
oauthRequestApi,
}),
);
builder.add(
oktaAuthApiRef,
OktaAuth.create({
discoveryApi,
oauthRequestApi,
}),
);
builder.add(
gitlabAuthApiRef,
GitlabAuth.create({
discoveryApi,
oauthRequestApi,
}),
);
builder.add(
auth0AuthApiRef,
Auth0Auth.create({
discoveryApi,
oauthRequestApi,
}),
);
builder.add(
oauth2ApiRef,
OAuth2.create({
discoveryApi,
oauthRequestApi,
}),
);
builder.add(catalogApiRef, new CatalogClient({ discoveryApi }));
builder.add(scaffolderApiRef, new ScaffolderApi({ discoveryApi }));
builder.add(gitOpsApiRef, new GitOpsRestApi('http://localhost:3008'));
builder.add(
graphQlBrowseApiRef,
GraphQLEndpoints.from([
GraphQLEndpoints.create({
id: 'gitlab',
title: 'GitLab',
url: 'https://gitlab.com/api/graphql',
}),
GraphQLEndpoints.github({
id: 'github',
title: 'GitHub',
errorApi,
githubAuthApi,
}),
]),
);
builder.add(rollbarApiRef, new RollbarClient({ discoveryApi }));
builder.add(
techdocsStorageApiRef,
new TechDocsStorageApi({
apiOrigin: techdocsUrl,
}),
);
return builder.build();
};
// TODO: move to plugins
createApiFactory(travisCIApiRef, new TravisCIApi()),
createApiFactory(githubPullRequestsApiRef, new GithubPullRequestsClient()),
];
@@ -0,0 +1,70 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { ApiFactoryRegistry } from './ApiFactoryRegistry';
import { createApiRef } from './ApiRef';
const aRef = createApiRef<number>({ id: 'a', description: '' });
const aFactory1 = { api: aRef, deps: {}, factory: () => 1 };
const aFactory2 = { api: aRef, deps: {}, factory: () => 2 };
const bRef = createApiRef<string>({ id: 'b', description: '' });
const bFactory = { api: bRef, deps: {}, factory: () => 'x' };
const cRef = createApiRef<string>({ id: 'c', description: '' });
const cFactory = { api: cRef, deps: {}, factory: () => 'y' };
describe('ApiFactoryRegistry', () => {
it('should be empty when created', () => {
const registry = new ApiFactoryRegistry();
expect(registry.getAllApis()).toEqual(new Set());
});
it('should register a factory', () => {
const registry = new ApiFactoryRegistry();
expect(registry.register('default', aFactory1)).toBe(true);
expect(registry.get(aRef)).toBe(aFactory1);
expect(registry.getAllApis()).toEqual(new Set([aRef]));
});
it('should prioritize factories based on scope', () => {
const registry = new ApiFactoryRegistry();
expect(registry.register('default', aFactory1)).toBe(true);
expect(registry.get(aRef)).toBe(aFactory1);
expect(registry.register('default', aFactory2)).toBe(false);
expect(registry.get(aRef)).toBe(aFactory1);
expect(registry.register('app', aFactory2)).toBe(true);
expect(registry.get(aRef)).toBe(aFactory2);
expect(registry.register('default', aFactory1)).toBe(false);
expect(registry.get(aRef)).toBe(aFactory2);
expect(registry.register('static', aFactory1)).toBe(true);
expect(registry.get(aRef)).toBe(aFactory1);
expect(registry.register('static', aFactory2)).toBe(false);
expect(registry.get(aRef)).toBe(aFactory1);
expect(registry.register('app', aFactory2)).toBe(false);
expect(registry.get(aRef)).toBe(aFactory1);
expect(registry.getAllApis()).toEqual(new Set([aRef]));
});
it('should register multiple factories without conflict', () => {
const registry = new ApiFactoryRegistry();
expect(registry.register('static', aFactory1)).toBe(true);
expect(registry.register('default', bFactory)).toBe(true);
expect(registry.register('app', cFactory)).toBe(true);
expect(registry.get(aRef)).toBe(aFactory1);
expect(registry.get(bRef)).toBe(bFactory);
expect(registry.get(cRef)).toBe(cFactory);
expect(registry.getAllApis()).toEqual(new Set([aRef, bRef, cRef]));
});
});
@@ -0,0 +1,83 @@
/*
* Copyright 2020 Spotify AB
*
* 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 {
ApiFactoryHolder,
ApiFactory,
AnyApiRef,
AnyApiFactory,
} from './types';
import { ApiRef } from './ApiRef';
type ApiFactoryScope =
| 'default' // Default factories registered by core and plugins
| 'app' // Factories registered in the app, overriding default ones
| 'static'; // APIs that can't be overridden, e.g. config
enum ScopePriority {
default = 10,
app = 50,
static = 100,
}
type FactoryTuple = {
priority: number;
factory: AnyApiFactory;
};
/**
* ApiFactoryRegistry is an ApiFactoryHolder implementation that enables
* registration of API Factories with different scope.
*
* Each scope has an assigned priority, where factories registered with
* higher priority scopes override ones with lower priority.
*/
export class ApiFactoryRegistry implements ApiFactoryHolder {
private readonly factories = new Map<AnyApiRef, FactoryTuple>();
/**
* Register a new API factory. Returns true if the factory was added
* to the registry.
*
* A factory will not be added to the registry if there is already
* an existing factory with the same or higher priority.
*/
register<Api, Deps extends { [name in string]: unknown }>(
scope: ApiFactoryScope,
factory: ApiFactory<Api, Deps>,
) {
const priority = ScopePriority[scope];
const existing = this.factories.get(factory.api);
if (existing && existing.priority >= priority) {
return false;
}
this.factories.set(factory.api, { priority, factory });
return true;
}
get<T>(api: ApiRef<T>): ApiFactory<T, { [x: string]: unknown }> | undefined {
const tuple = this.factories.get(api);
if (!tuple) {
return undefined;
}
return tuple.factory as ApiFactory<T, { [x: string]: unknown }>;
}
getAllApis(): Set<AnyApiRef> {
return new Set(this.factories.keys());
}
}
@@ -0,0 +1,177 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { ApiResolver } from './ApiResolver';
import { createApiRef } from './ApiRef';
import { ApiFactoryRegistry } from './ApiFactoryRegistry';
const aRef = createApiRef<number>({ id: 'a', description: '' });
const bRef = createApiRef<string>({ id: 'b', description: '' });
const cRef = createApiRef<{ x: string }>({ id: 'c', description: '' });
function createRegistry() {
const registry = new ApiFactoryRegistry();
registry.register('default', {
api: aRef,
deps: {},
factory: () => 1,
});
registry.register('default', {
api: bRef,
deps: {},
factory: () => 'b',
});
registry.register('default', {
api: cRef,
deps: { b: bRef },
factory: ({ b }) => ({ x: 'x', b }),
});
return registry;
}
function createLongCyclicRegistry() {
const registry = new ApiFactoryRegistry();
registry.register('default', {
api: aRef,
deps: { b: bRef },
factory: () => 1,
});
registry.register('default', {
api: bRef,
deps: { c: cRef },
factory: () => 'b',
});
registry.register('default', {
api: cRef,
deps: { a: aRef },
factory: () => ({ x: 'x' }),
});
return registry;
}
function createShortCyclicRegistry() {
const registry = new ApiFactoryRegistry();
registry.register('default', {
api: aRef,
deps: { a: aRef },
factory: () => 1,
});
registry.register('default', {
api: bRef,
deps: { c: cRef },
factory: () => 'b',
});
registry.register('default', {
api: cRef,
deps: { b: bRef },
factory: () => ({ x: 'x' }),
});
return registry;
}
describe('ApiResolver', () => {
it('should be created empty', () => {
const resolver = new ApiResolver(new ApiFactoryRegistry());
expect(resolver.get(aRef)).toBe(undefined);
expect(resolver.get(bRef)).toBe(undefined);
expect(resolver.get(cRef)).toBe(undefined);
});
it('should instantiate APIs', () => {
const resolver = new ApiResolver(createRegistry());
expect(resolver.get(aRef)).toBe(1);
expect(resolver.get(bRef)).toBe('b');
expect(resolver.get(cRef)).toEqual({ x: 'x', b: 'b' });
expect(resolver.get(cRef)).toBe(resolver.get(cRef));
});
it('should detect long dependency cycles', () => {
const resolver = new ApiResolver(createLongCyclicRegistry());
expect(() => resolver.get(aRef)).toThrow(
'Circular dependency of api factory for apiRef{a}',
);
// Second call for same ref should still throw
expect(() => resolver.get(aRef)).toThrow(
'Circular dependency of api factory for apiRef{a}',
);
expect(() => resolver.get(bRef)).toThrow(
'Circular dependency of api factory for apiRef{b}',
);
expect(() => resolver.get(cRef)).toThrow(
'Circular dependency of api factory for apiRef{c}',
);
});
it('should detect short dependency cycles', () => {
const resolver = new ApiResolver(createShortCyclicRegistry());
expect(() => resolver.get(aRef)).toThrow(
'Circular dependency of api factory for apiRef{a}',
);
expect(() => resolver.get(bRef)).toThrow(
'Circular dependency of api factory for apiRef{b}',
);
expect(() => resolver.get(cRef)).toThrow(
'Circular dependency of api factory for apiRef{c}',
);
});
it('should validate a factory holder', () => {
expect(() => {
ApiResolver.validateFactories(createRegistry(), [aRef, bRef, cRef]);
}).not.toThrow();
});
it('should find dependency cycles with validation', () => {
const short = createShortCyclicRegistry();
expect(() =>
ApiResolver.validateFactories(short, short.getAllApis()),
).toThrow('Circular dependency of api factory for apiRef{a}');
expect(() => ApiResolver.validateFactories(short, [bRef])).toThrow(
'Circular dependency of api factory for apiRef{b}',
);
expect(() => ApiResolver.validateFactories(short, [cRef])).toThrow(
'Circular dependency of api factory for apiRef{c}',
);
const long = createLongCyclicRegistry();
expect(() =>
ApiResolver.validateFactories(long, long.getAllApis()),
).toThrow('Circular dependency of api factory for apiRef{a}');
expect(() => ApiResolver.validateFactories(long, [bRef])).toThrow(
'Circular dependency of api factory for apiRef{b}',
);
expect(() => ApiResolver.validateFactories(long, [cRef])).toThrow(
'Circular dependency of api factory for apiRef{c}',
);
});
it('should only call factory func once', () => {
const registry = new ApiFactoryRegistry();
const factory = jest.fn().mockReturnValue(2);
registry.register('default', {
api: aRef,
deps: {},
factory,
});
const resolver = new ApiResolver(registry);
expect(factory).toHaveBeenCalledTimes(0);
expect(resolver.get(aRef)).toBe(2);
expect(factory).toHaveBeenCalledTimes(1);
expect(resolver.get(aRef)).toBe(2);
expect(factory).toHaveBeenCalledTimes(1);
});
});
@@ -15,38 +15,54 @@
*/
import { ApiRef } from './ApiRef';
import { TypesToApiRefs, AnyApiRef, ApiHolder, ApiFactory } from './types';
import {
ApiHolder,
ApiFactoryHolder,
AnyApiRef,
TypesToApiRefs,
} from './types';
export class ApiTestRegistry implements ApiHolder {
export class ApiResolver implements ApiHolder {
private readonly apis = new Map<AnyApiRef, unknown>();
private factories = new Map<
AnyApiRef,
ApiFactory<unknown, unknown, unknown>
>();
private savedFactories = new Map<
AnyApiRef,
ApiFactory<unknown, unknown, unknown>
>();
/**
* Validate factories by making sure that each of the apis can be created
* without hitting any circular dependencies.
*/
static validateFactories(
factories: ApiFactoryHolder,
apis: Iterable<AnyApiRef>,
) {
for (const api of apis) {
const heap = [api];
const allDeps = new Set<AnyApiRef>();
while (heap.length) {
const apiRef = heap.shift()!;
const factory = factories.get(apiRef);
if (!factory) {
continue;
}
for (const dep of Object.values(factory.deps)) {
if (dep === api) {
throw new Error(`Circular dependency of api factory for ${api}`);
}
if (!allDeps.has(dep)) {
allDeps.add(dep);
heap.push(dep);
}
}
}
}
}
constructor(private readonly factories: ApiFactoryHolder) {}
get<T>(ref: ApiRef<T>): T | undefined {
return this.load(ref);
}
register<A, I, D>(factory: ApiFactory<A, I, D>): ApiTestRegistry {
this.factories.set(factory.implements, factory);
return this;
}
reset() {
this.factories = this.savedFactories;
this.apis.clear();
}
save(): ApiTestRegistry {
this.savedFactories = new Map(this.factories);
return this;
}
private load<T>(ref: ApiRef<T>, loading: AnyApiRef[] = []): T | undefined {
const impl = this.apis.get(ref);
if (impl) {
@@ -58,16 +74,11 @@ export class ApiTestRegistry implements ApiHolder {
return undefined;
}
if (loading.includes(factory.implements)) {
throw new Error(
`Circular dependency of api factory for ${factory.implements}`,
);
if (loading.includes(factory.api)) {
throw new Error(`Circular dependency of api factory for ${factory.api}`);
}
const deps = this.loadDeps(ref, factory.deps, [
...loading,
factory.implements,
]);
const deps = this.loadDeps(ref, factory.deps, [...loading, factory.api]);
const api = factory.factory(deps);
this.apis.set(ref, api);
return api as T;
@@ -1,154 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { ApiTestRegistry } from './ApiTestRegistry';
import { createApiRef } from './ApiRef';
describe('ApiTestRegistry', () => {
const aRef = createApiRef<number>({ id: 'a', description: '' });
const bRef = createApiRef<string>({ id: 'b', description: '' });
const cRef = createApiRef<string>({ id: 'c', description: '' });
it('should be created', () => {
const registry = new ApiTestRegistry();
expect(registry.get(aRef)).toBe(undefined);
expect(registry.get(bRef)).toBe(undefined);
expect(registry.get(cRef)).toBe(undefined);
});
it('should register a factory', () => {
const registry = new ApiTestRegistry();
registry.register({ implements: aRef, deps: {}, factory: () => 3 });
expect(registry.get(aRef)).toBe(3);
expect(registry.get(bRef)).toBe(undefined);
expect(registry.get(cRef)).toBe(undefined);
});
it('should remove factories when resetting', () => {
const registry = new ApiTestRegistry();
registry.register({ implements: aRef, deps: {}, factory: () => 3 });
expect(registry.get(aRef)).toBe(3);
registry.reset();
expect(registry.get(aRef)).toBe(undefined);
});
it('should keep saved factories when resetting', () => {
const registry = new ApiTestRegistry();
registry.register({ implements: aRef, deps: {}, factory: () => 3 });
registry.save();
registry.register({ implements: bRef, deps: {}, factory: () => 'x' });
expect(registry.get(aRef)).toBe(3);
expect(registry.get(bRef)).toBe('x');
registry.reset();
expect(registry.get(aRef)).toBe(3);
expect(registry.get(bRef)).toBe(undefined);
});
it('should register factories with dependencies', () => {
// 100% coverage + happy typescript = hasOwnProperty + this atrocity
const cDeps = Object.create(
{ c: cRef },
{ a: { enumerable: true, value: aRef } },
);
cDeps.b = bRef;
const registry = new ApiTestRegistry();
registry.register({ implements: aRef, deps: {}, factory: () => 3 });
registry.register({
implements: bRef,
deps: { dep: aRef },
factory: ({ dep }) => `hello ${dep}`,
});
registry.register({
implements: cRef,
deps: cDeps,
factory: ({ a, b }) => b.repeat(a),
});
expect(registry.get(aRef)).toBe(3);
expect(registry.get(bRef)).toBe('hello 3');
expect(registry.get(cRef)).toBe('hello 3hello 3hello 3');
});
it('should not allow cyclic dependencies', () => {
const registry = new ApiTestRegistry();
registry.register({
implements: aRef,
deps: { b: bRef },
factory: () => 1,
});
registry.register({
implements: bRef,
deps: { c: cRef },
factory: () => 'b',
});
registry.register({
implements: cRef,
deps: { a: aRef },
factory: () => 'c',
});
expect(() => registry.get(aRef)).toThrow(
'Circular dependency of api factory for apiRef{a}',
);
expect(() => registry.get(bRef)).toThrow(
'Circular dependency of api factory for apiRef{b}',
);
expect(() => registry.get(cRef)).toThrow(
'Circular dependency of api factory for apiRef{c}',
);
});
it('should throw error if dependency is not available', () => {
const registry = new ApiTestRegistry();
registry.register({
implements: aRef,
deps: { b: bRef },
factory: () => 1,
});
expect(() => registry.get(aRef)).toThrow(
'No API factory available for dependency apiRef{b} of dependent apiRef{a}',
);
expect(registry.get(bRef)).toBe(undefined);
expect(registry.get(cRef)).toBe(undefined);
});
it('should only call factory func once', () => {
const registry = new ApiTestRegistry();
const factory = jest.fn().mockReturnValue(2);
registry.register({ implements: aRef, deps: {}, factory });
expect(factory).toHaveBeenCalledTimes(0);
expect(registry.get(aRef)).toBe(2);
expect(factory).toHaveBeenCalledTimes(1);
expect(registry.get(aRef)).toBe(2);
expect(factory).toHaveBeenCalledTimes(1);
});
it('should call factory again after reset', () => {
const registry = new ApiTestRegistry();
const factory = jest.fn().mockReturnValue(2);
registry.register({ implements: aRef, deps: {}, factory });
registry.save();
expect(factory).toHaveBeenCalledTimes(0);
expect(registry.get(aRef)).toBe(2);
expect(factory).toHaveBeenCalledTimes(1);
expect(registry.get(aRef)).toBe(2);
expect(factory).toHaveBeenCalledTimes(1);
registry.reset();
expect(registry.get(aRef)).toBe(2);
expect(factory).toHaveBeenCalledTimes(2);
});
});
+25 -4
View File
@@ -14,15 +14,36 @@
* limitations under the License.
*/
import { ApiFactory } from './types';
import { ApiFactory, TypesToApiRefs } from './types';
import { ApiRef } from './ApiRef';
/**
* Used to infer types for a standalone ApiFactory that isn't immediately passed
* to another function.
* This function doesn't actually do anything, it's only used to infer types.
*/
export function createApiFactory<Api, Impl, Deps>(
factory: ApiFactory<Api, Impl, Deps>,
): ApiFactory<Api, Impl, Deps> {
export function createApiFactory<
Api,
Impl extends Api,
Deps extends { [name in string]: unknown }
>(factory: ApiFactory<Api, Deps>): ApiFactory<Api, Deps>;
export function createApiFactory<Api>(
api: ApiRef<Api>,
instance: Api,
): ApiFactory<Api, {}>;
export function createApiFactory<
Api,
Deps extends { [name in string]: unknown }
>(
factory: ApiFactory<Api, Deps> | ApiRef<Api>,
instance?: Api,
): ApiFactory<Api, Deps> {
if ('id' in factory) {
return {
api: factory,
deps: {} as TypesToApiRefs<Deps>,
factory: () => instance!,
};
}
return factory;
}
-1
View File
@@ -16,7 +16,6 @@
export { ApiProvider, useApi, useApiHolder } from './ApiProvider';
export { ApiRegistry } from './ApiRegistry';
export { ApiTestRegistry } from './ApiTestRegistry';
export * from './ApiRef';
export * from './types';
export * from './helpers';
+13 -5
View File
@@ -16,13 +16,13 @@
import { ApiRef } from './ApiRef';
export type AnyApiRef = ApiRef<any>;
export type AnyApiRef = ApiRef<unknown>;
export type ApiRefType<T> = T extends ApiRef<infer U> ? U : never;
export type TypesToApiRefs<T> = { [key in keyof T]: ApiRef<T[key]> };
export type ApiRefsToTypes<T extends { [key in any]: ApiRef<any> }> = {
export type ApiRefsToTypes<T extends { [key in string]: ApiRef<unknown> }> = {
[key in keyof T]: ApiRefType<T[key]>;
};
@@ -30,8 +30,16 @@ export type ApiHolder = {
get<T>(api: ApiRef<T>): T | undefined;
};
export type ApiFactory<Api, Impl, Deps> = {
implements: ApiRef<Api>;
export type ApiFactory<Api, Deps extends { [name in string]: unknown }> = {
api: ApiRef<Api>;
deps: TypesToApiRefs<Deps>;
factory(deps: Deps): Impl extends Api ? Impl : never;
factory(deps: Deps): Api;
};
export type AnyApiFactory = ApiFactory<unknown, { [key in string]: unknown }>;
export type ApiFactoryHolder = {
get<T>(
api: ApiRef<T>,
): ApiFactory<T, { [key in string]: unknown }> | undefined;
};
+80 -35
View File
@@ -26,7 +26,6 @@ import {
BackstageApp,
AppComponents,
AppConfigLoader,
Apis,
SignInResult,
SignInPageProps,
} from './types';
@@ -42,7 +41,6 @@ import { AppThemeProvider } from './AppThemeProvider';
import { IconComponent, SystemIcons, SystemIconKey } from '../icons';
import {
ApiHolder,
ApiProvider,
ApiRegistry,
AppTheme,
@@ -51,18 +49,22 @@ import {
configApiRef,
ConfigReader,
useApi,
AnyApiFactory,
ApiHolder,
} from '../apis';
import { ApiAggregator } from '../apis/ApiAggregator';
import { useAsync } from 'react-use';
import { AppIdentity } from './AppIdentity';
import { ApiFactoryRegistry } from '../apis/ApiFactoryRegistry';
import { ApiResolver } from '../apis/ApiResolver';
type FullAppOptions = {
apis: Apis;
apis: Iterable<AnyApiFactory>;
icons: SystemIcons;
plugins: BackstagePlugin[];
components: AppComponents;
themes: AppTheme[];
configLoader?: AppConfigLoader;
defaultApis: Iterable<AnyApiFactory>;
};
function useConfigLoader(
@@ -101,31 +103,27 @@ function useConfigLoader(
}
export class PrivateAppImpl implements BackstageApp {
private apis?: ApiHolder = undefined;
private apiHolder?: ApiHolder;
private configApi?: ConfigApi;
private readonly apis: Iterable<AnyApiFactory>;
private readonly icons: SystemIcons;
private readonly plugins: BackstagePlugin[];
private readonly components: AppComponents;
private readonly themes: AppTheme[];
private readonly configLoader?: AppConfigLoader;
private readonly defaultApis: Iterable<AnyApiFactory>;
private readonly identityApi = new AppIdentity();
private apisOrFactory: Apis;
constructor(options: FullAppOptions) {
this.apisOrFactory = options.apis;
this.apis = options.apis;
this.icons = options.icons;
this.plugins = options.plugins;
this.components = options.components;
this.themes = options.themes;
this.configLoader = options.configLoader;
}
getApis(): ApiHolder {
if (!this.apis) {
throw new Error('Tried to access APIs before app was loaded');
}
return this.apis;
this.defaultApis = options.defaultApis;
}
getPlugins(): BackstagePlugin[] {
@@ -186,9 +184,9 @@ export class PrivateAppImpl implements BackstageApp {
}
}
const FeatureFlags = this.apis && this.apis.get(featureFlagsApiRef);
if (FeatureFlags) {
FeatureFlags.registeredFeatureFlags = registeredFeatureFlags;
const featureFlags = this.getApiHolder().get(featureFlagsApiRef);
if (featureFlags) {
featureFlags.registeredFeatureFlags = registeredFeatureFlags;
}
routes.push(<Route path="/*" element={<NotFoundErrorPage />} />);
@@ -210,28 +208,14 @@ export class PrivateAppImpl implements BackstageApp {
);
if ('node' in loadedConfig) {
// Loading or error
return loadedConfig.node;
}
const configApi = loadedConfig.api;
const appApis = ApiRegistry.from([
[appThemeApiRef, appThemeApi],
[configApiRef, configApi],
[identityApiRef, this.identityApi],
]);
if (!this.apis) {
if ('get' in this.apisOrFactory) {
this.apis = this.apisOrFactory;
} else {
this.apis = this.apisOrFactory(configApi);
}
}
const apis = new ApiAggregator(this.apis, appApis);
this.configApi = loadedConfig.api;
return (
<ApiProvider apis={apis}>
<ApiProvider apis={this.getApiHolder()}>
<AppContextProvider app={this}>
<AppThemeProvider>{children}</AppThemeProvider>
</AppContextProvider>
@@ -306,6 +290,67 @@ export class PrivateAppImpl implements BackstageApp {
return AppRouter;
}
private getApiHolder(): ApiHolder {
if (this.apiHolder) {
return this.apiHolder;
}
const registry = new ApiFactoryRegistry();
registry.register('static', {
api: appThemeApiRef,
deps: {},
factory: () => AppThemeSelector.createWithStorage(this.themes),
});
registry.register('static', {
api: configApiRef,
deps: {},
factory: () => {
if (!this.configApi) {
throw new Error(
'Tried to access config API before config was loaded',
);
}
return this.configApi;
},
});
registry.register('static', {
api: identityApiRef,
deps: {},
factory: () => this.identityApi,
});
for (const factory of this.defaultApis) {
registry.register('default', factory);
}
for (const plugin of this.plugins) {
for (const factory of plugin.getApis()) {
if (!registry.register('default', factory)) {
throw new Error(
`Plugin ${plugin.getId()} tried to register duplicate or forbidden API factory for ${
factory.api
}`,
);
}
}
}
for (const factory of this.apis) {
if (!registry.register('app', factory)) {
throw new Error(
`Duplicate or forbidden API factory for ${factory.api} in app`,
);
}
}
ApiResolver.validateFactories(registry, registry.getAllApis());
this.apiHolder = new ApiResolver(registry);
return this.apiHolder;
}
verify() {
const pluginIds = new Set<string>();
+5 -14
View File
@@ -17,8 +17,8 @@
import { ComponentType } from 'react';
import { IconComponent, SystemIconKey, SystemIcons } from '../icons';
import { BackstagePlugin } from '../plugin';
import { ApiHolder } from '../apis';
import { AppTheme, ConfigApi, ProfileInfo } from '../apis/definitions';
import { AnyApiFactory } from '../apis';
import { AppTheme, ProfileInfo } from '../apis/definitions';
import { AppConfig } from '@backstage/config';
export type BootErrorPageProps = {
@@ -77,16 +77,12 @@ export type AppComponents = {
*/
export type AppConfigLoader = () => Promise<AppConfig[]>;
// TODO(Rugvip): Temporary workaround for accessing config when instantiating APIs, we might want to do this differently
export type Apis = ApiHolder | ((config: ConfigApi) => ApiHolder);
export type AppOptions = {
/**
* A holder of all APIs available in the app.
*
* Use for example ApiRegistry or ApiTestRegistry.
* A collection of ApiFactories to register in the application to either
* add add new ones, or override factories provided by default or by plugins.
*/
apis?: Apis;
apis?: Iterable<AnyApiFactory>;
/**
* Supply icons to override the default ones.
@@ -138,11 +134,6 @@ export type AppOptions = {
};
export type BackstageApp = {
/**
* Get the holder for all APIs available in the app.
*/
getApis(): ApiHolder;
/**
* Returns all plugins registered for the app.
*/
+6
View File
@@ -24,9 +24,11 @@ import {
} from './types';
import { validateBrowserCompat, validateFlagName } from '../app/FeatureFlags';
import { RouteRef } from '../routing';
import { AnyApiFactory } from '../apis';
export type PluginConfig = {
id: string;
apis?: Iterable<AnyApiFactory>;
register?(hooks: PluginHooks): void;
};
@@ -65,6 +67,10 @@ export class PluginImpl {
return this.config.id;
}
getApis(): Iterable<AnyApiFactory> {
return this.config.apis ?? [];
}
output(): PluginOutput[] {
if (this.storedOutput) {
return this.storedOutput;
+2
View File
@@ -16,6 +16,7 @@
import { ComponentType } from 'react';
import { RouteRef } from '../routing';
import { AnyApiFactory } from '../apis';
export type RouteOptions = {
// Whether the route path must match exactly, defaults to true.
@@ -70,4 +71,5 @@ export type PluginOutput =
export type BackstagePlugin = {
getId(): string;
output(): PluginOutput[];
getApis(): Iterable<AnyApiFactory>;
};
+3 -2
View File
@@ -17,7 +17,6 @@
import React, { FC } from 'react';
import privateExports, {
AppOptions,
ApiRegistry,
defaultSystemIcons,
BootErrorPageProps,
AppConfigLoader,
@@ -26,6 +25,7 @@ import { BrowserRouter, MemoryRouter } from 'react-router-dom';
import { ErrorPage } from '../layout/ErrorPage';
import { Progress } from '../components/Progress';
import { defaultApis } from './defaultApis';
import { lightTheme, darkTheme } from '@backstage/theme';
import { AppConfig, JsonObject } from '@backstage/config';
@@ -94,7 +94,7 @@ export function createApp(options?: AppOptions) {
);
};
const apis = options?.apis ?? ApiRegistry.from([]);
const apis = options?.apis ?? [];
const icons = { ...defaultSystemIcons, ...options?.icons };
const plugins = options?.plugins ?? [];
const components = {
@@ -127,6 +127,7 @@ export function createApp(options?: AppOptions) {
components,
themes,
configLoader,
defaultApis,
});
app.verify();
@@ -0,0 +1,135 @@
/*
* Copyright 2020 Spotify AB
*
* 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 {
alertApiRef,
errorApiRef,
AlertApiForwarder,
ErrorApiForwarder,
ErrorAlerter,
featureFlagsApiRef,
FeatureFlags,
discoveryApiRef,
GoogleAuth,
GithubAuth,
OAuth2,
OktaAuth,
GitlabAuth,
Auth0Auth,
MicrosoftAuth,
oauthRequestApiRef,
OAuthRequestManager,
googleAuthApiRef,
githubAuthApiRef,
oauth2ApiRef,
oktaAuthApiRef,
gitlabAuthApiRef,
auth0AuthApiRef,
microsoftAuthApiRef,
storageApiRef,
WebStorage,
createApiFactory,
configApiRef,
UrlPatternDiscovery,
} from '@backstage/core-api';
export const defaultApis = [
createApiFactory({
api: discoveryApiRef,
deps: { configApi: configApiRef },
factory: ({ configApi }) =>
UrlPatternDiscovery.compile(
`${configApi.getString('backend.baseUrl')}/api/{{ pluginId }}`,
),
}),
createApiFactory(alertApiRef, new AlertApiForwarder()),
createApiFactory({
api: errorApiRef,
deps: { alertApi: alertApiRef },
factory: ({ alertApi }) =>
new ErrorAlerter(alertApi, new ErrorApiForwarder()),
}),
createApiFactory({
api: storageApiRef,
deps: { errorApi: errorApiRef },
factory: ({ errorApi }) => WebStorage.create({ errorApi }),
}),
createApiFactory(featureFlagsApiRef, new FeatureFlags()),
createApiFactory(oauthRequestApiRef, new OAuthRequestManager()),
createApiFactory({
api: googleAuthApiRef,
deps: {
discoveryApi: discoveryApiRef,
oauthRequestApi: oauthRequestApiRef,
},
factory: ({ discoveryApi, oauthRequestApi }) =>
GoogleAuth.create({ discoveryApi, oauthRequestApi }),
}),
createApiFactory({
api: microsoftAuthApiRef,
deps: {
discoveryApi: discoveryApiRef,
oauthRequestApi: oauthRequestApiRef,
},
factory: ({ discoveryApi, oauthRequestApi }) =>
MicrosoftAuth.create({ discoveryApi, oauthRequestApi }),
}),
createApiFactory({
api: githubAuthApiRef,
deps: {
discoveryApi: discoveryApiRef,
oauthRequestApi: oauthRequestApiRef,
},
factory: ({ discoveryApi, oauthRequestApi }) =>
GithubAuth.create({ discoveryApi, oauthRequestApi }),
}),
createApiFactory({
api: oktaAuthApiRef,
deps: {
discoveryApi: discoveryApiRef,
oauthRequestApi: oauthRequestApiRef,
},
factory: ({ discoveryApi, oauthRequestApi }) =>
OktaAuth.create({ discoveryApi, oauthRequestApi }),
}),
createApiFactory({
api: gitlabAuthApiRef,
deps: {
discoveryApi: discoveryApiRef,
oauthRequestApi: oauthRequestApiRef,
},
factory: ({ discoveryApi, oauthRequestApi }) =>
GitlabAuth.create({ discoveryApi, oauthRequestApi }),
}),
createApiFactory({
api: auth0AuthApiRef,
deps: {
discoveryApi: discoveryApiRef,
oauthRequestApi: oauthRequestApiRef,
},
factory: ({ discoveryApi, oauthRequestApi }) =>
Auth0Auth.create({ discoveryApi, oauthRequestApi }),
}),
createApiFactory({
api: oauth2ApiRef,
deps: {
discoveryApi: discoveryApiRef,
oauthRequestApi: oauthRequestApiRef,
},
factory: ({ discoveryApi, oauthRequestApi }) =>
OAuth2.create({ discoveryApi, oauthRequestApi }),
}),
];
@@ -1,79 +1,17 @@
import {
ApiRegistry,
alertApiRef,
errorApiRef,
AlertApiForwarder,
ConfigApi,
ErrorApiForwarder,
ErrorAlerter,
discoveryApiRef,
UrlPatternDiscovery,
oauthRequestApiRef,
OAuthRequestManager,
storageApiRef,
WebStorage,
createApiFactory,
configApiRef,
} from '@backstage/core';
import {
lighthouseApiRef,
LighthouseRestApi,
} from '@backstage/plugin-lighthouse';
import {
GithubActionsClient,
githubActionsApiRef,
} from '@backstage/plugin-github-actions';
import {
techdocsStorageApiRef,
TechDocsStorageApi,
} from '@backstage/plugin-techdocs';
import { catalogApiRef, CatalogClient } from '@backstage/plugin-catalog';
import { CircleCIApi, circleCIApiRef } from '@backstage/plugin-circleci';
import { scaffolderApiRef, ScaffolderApi } from '@backstage/plugin-scaffolder';
export const apis = (config: ConfigApi) => {
// eslint-disable-next-line no-console
console.log(`Creating APIs for ${config.getString('app.title')}`);
const backendUrl = config.getString('backend.baseUrl');
const techdocsStorageUrl = config.getString('techdocs.storageUrl');
const builder = ApiRegistry.builder();
const discoveryApi = builder.add(
discoveryApiRef,
UrlPatternDiscovery.compile(`${backendUrl}/{{ pluginId }}`),
);
const alertApi = builder.add(alertApiRef, new AlertApiForwarder());
const errorApi = builder.add(
errorApiRef,
new ErrorAlerter(alertApi, new ErrorApiForwarder()),
);
builder.add(storageApiRef, WebStorage.create({ errorApi }));
builder.add(oauthRequestApiRef, new OAuthRequestManager());
builder.add(catalogApiRef, new CatalogClient({ discoveryApi }));
builder.add(githubActionsApiRef, new GithubActionsClient());
builder.add(lighthouseApiRef, new LighthouseRestApi('http://localhost:3003'));
builder.add(
circleCIApiRef,
new CircleCIApi(`${backendUrl}/proxy/circleci/api`),
);
builder.add(scaffolderApiRef, new ScaffolderApi({ discoveryApi }));
builder.add(
techdocsStorageApiRef,
new TechDocsStorageApi({ apiOrigin: techdocsStorageUrl }),
);
return builder.build();
};
export const apis = [
createApiFactory({
api: discoveryApiRef,
deps: { configApi: configApiRef },
factory: ({ configApi }) =>
UrlPatternDiscovery.compile(
`${configApi.getString('backend.baseUrl')}/{{ pluginId }}`,
),
}),
];
@@ -1,36 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* 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 * as apiFactories from './apiFactories';
import { ApiTestRegistry, ApiFactory } from '@backstage/core';
describe('apiFactories', () => {
it('should be possible to get an instance of each API', () => {
const registry = new ApiTestRegistry();
const factories: ApiFactory<unknown, unknown, unknown>[] = Object.values(
apiFactories,
);
for (const factory of factories) {
registry.register(factory);
}
for (const factory of factories) {
const api = registry.get(factory.implements);
expect(api).toBeDefined();
}
});
});
@@ -1,108 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* 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 {
alertApiRef,
errorApiRef,
ErrorApiForwarder,
AlertApi,
createApiFactory,
ErrorAlerter,
AlertApiForwarder,
oauthRequestApiRef,
OAuthRequestManager,
UrlPatternDiscovery,
discoveryApiRef,
GoogleAuth,
googleAuthApiRef,
GithubAuth,
githubAuthApiRef,
GitlabAuth,
gitlabAuthApiRef,
Auth0Auth,
auth0AuthApiRef,
} from '@backstage/core';
// TODO(rugvip): We should likely figure out how to reuse all of these between apps
// and plugin serve with minimal boilerplate. For example we might move everything
// to DI, and provide factories for the default implementations, so this just becomes
// a list of things like `[ErrorApiForwarder.factory, AlertApiDialog.factory]`.
export const alertApiFactory = createApiFactory({
implements: alertApiRef,
deps: {},
factory: (): AlertApi => new AlertApiForwarder(),
});
export const errorApiFactory = createApiFactory({
implements: errorApiRef,
deps: { alertApi: alertApiRef },
factory: ({ alertApi }) =>
new ErrorAlerter(alertApi, new ErrorApiForwarder()),
});
export const oauthRequestApiFactory = createApiFactory({
implements: oauthRequestApiRef,
deps: {},
factory: () => new OAuthRequestManager(),
});
export const discoveryApiFactory = createApiFactory({
implements: discoveryApiRef,
deps: {},
factory: () =>
UrlPatternDiscovery.compile(`http://localhost:7000/{{ pluginId }}`),
});
export const googleAuthApiFactory = createApiFactory({
implements: googleAuthApiRef,
deps: { discoveryApi: discoveryApiRef, oauthRequestApi: oauthRequestApiRef },
factory: ({ discoveryApi, oauthRequestApi }) =>
GoogleAuth.create({
discoveryApi,
oauthRequestApi,
}),
});
export const githubAuthApiFactory = createApiFactory({
implements: githubAuthApiRef,
deps: { discoveryApi: discoveryApiRef, oauthRequestApi: oauthRequestApiRef },
factory: ({ discoveryApi, oauthRequestApi }) =>
GithubAuth.create({
discoveryApi,
oauthRequestApi,
}),
});
export const gitlabAuthApiFactory = createApiFactory({
implements: gitlabAuthApiRef,
deps: { discoveryApi: discoveryApiRef, oauthRequestApi: oauthRequestApiRef },
factory: ({ discoveryApi, oauthRequestApi }) =>
GitlabAuth.create({
discoveryApi,
oauthRequestApi,
}),
});
export const auth0AuthApiFactory = createApiFactory({
implements: auth0AuthApiRef,
deps: { discoveryApi: discoveryApiRef, oauthRequestApi: oauthRequestApiRef },
factory: ({ discoveryApi, oauthRequestApi }) =>
Auth0Auth.create({
discoveryApi,
oauthRequestApi,
}),
});
@@ -0,0 +1,43 @@
/*
* Copyright 2020 Spotify AB
*
* 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 React from 'react';
import { render } from '@testing-library/react';
import { useApi, configApiRef } from '@backstage/core';
import { createDevApp } from './render';
const anyEnv = (process.env = { ...process.env }) as any;
describe('DevAppBuilder', () => {
it('should be able to render a component in a dev app', async () => {
anyEnv.APP_CONFIG = [
{ context: 'test', data: { app: { title: 'Test App' } } },
];
const MyComponent = () => {
const configApi = useApi(configApiRef);
return <div>My App: {configApi.getString('app.title')}</div>;
};
const DevApp = createDevApp()
.addRootChild(<MyComponent />)
.build();
const rendered = render(<DevApp />);
expect(await rendered.findByText('My App: Test App')).toBeInTheDocument();
});
});
+6 -32
View File
@@ -26,12 +26,10 @@ import {
SidebarSpacer,
ApiFactory,
createPlugin,
ApiTestRegistry,
ApiHolder,
AlertDisplay,
OAuthRequestDialog,
AnyApiFactory,
} from '@backstage/core';
import * as defaultApiFactories from './apiFactories';
import SentimentDissatisfiedIcon from '@material-ui/icons/SentimentDissatisfied';
// TODO(rugvip): export proper plugin type from core that isn't the plugin class
@@ -43,7 +41,7 @@ type BackstagePlugin = ReturnType<typeof createPlugin>;
*/
class DevAppBuilder {
private readonly plugins = new Array<BackstagePlugin>();
private readonly factories = new Array<ApiFactory<any, any, any>>();
private readonly apis = new Array<AnyApiFactory>();
private readonly rootChildren = new Array<ReactNode>();
/**
@@ -57,10 +55,10 @@ class DevAppBuilder {
/**
* Register an API factory to add to the app
*/
registerApiFactory<Api, Impl, Deps>(
factory: ApiFactory<Api, Impl, Deps>,
registerApi<Api, Deps extends { [name in string]: unknown }>(
factory: ApiFactory<Api, Deps>,
): DevAppBuilder {
this.factories.push(factory);
this.apis.push(factory);
return this;
}
@@ -79,7 +77,7 @@ class DevAppBuilder {
*/
build(): ComponentType<{}> {
const app = createApp({
apis: this.setupApiRegistry(this.factories),
apis: this.apis,
plugins: this.plugins,
});
@@ -170,30 +168,6 @@ class DevAppBuilder {
);
}
// Set up an API registry that merges together default implementations with ones provided through config.
private setupApiRegistry(
providedFactories: ApiFactory<any, any, any>[],
): ApiHolder {
const providedApis = new Set(
providedFactories.map(factory => factory.implements),
);
// Exlude any default API factory that we receive a factory for in the config
const defaultFactories = Object.values(defaultApiFactories).filter(
factory => !providedApis.has(factory.implements),
);
const allFactories = [...defaultFactories, ...providedFactories];
// Use a test registry with dependency injection so that the consumer
// can override APIs but still depend on the default implementations.
const registry = new ApiTestRegistry();
for (const factory of allFactories) {
registry.register(factory);
}
return registry;
}
private findPluginPaths(plugins: BackstagePlugin[]) {
const paths = new Array<string>();
@@ -14,12 +14,7 @@
* limitations under the License.
*/
import {
ErrorApi,
ErrorContext,
errorApiRef,
Observable,
} from '@backstage/core-api';
import { ErrorApi, ErrorContext, Observable } from '@backstage/core-api';
type Options = {
collect?: boolean;
@@ -40,12 +35,6 @@ const nullObservable = {
};
export class MockErrorApi implements ErrorApi {
static factory = {
implements: errorApiRef,
deps: {},
factory: () => new MockErrorApi(),
};
private readonly errors = new Array<ErrorWithContext>();
private readonly waiters = new Set<Waiter>();
@@ -17,7 +17,6 @@
import {
Observable,
StorageApi,
storageApiRef,
StorageValueChange,
} from '@backstage/core-api';
import ObservableImpl from 'zen-observable';
@@ -25,12 +24,6 @@ import ObservableImpl from 'zen-observable';
export type MockStorageBucket = { [key: string]: any };
export class MockStorageApi implements StorageApi {
static factory = {
implements: storageApiRef,
deps: {},
factory: () => MockStorageApi.create(),
};
private readonly namespace: string;
private readonly data: MockStorageBucket;
@@ -49,9 +49,6 @@ describe('wrapInTestApp', () => {
expect.stringMatching(
/^Warning: An update to %s inside a test was not wrapped in act\(...\)/,
),
expect.stringMatching(
/^Warning: An update to %s inside a test was not wrapped in act\(...\)/,
),
]);
});
@@ -24,7 +24,7 @@ import privateExports, {
} from '@backstage/core-api';
import { RenderResult } from '@testing-library/react';
import { renderWithEffects } from '@backstage/test-utils-core';
import { createMockApiRegistry } from './mockApiRegistry';
import { mockApis } from './mockApis';
const { PrivateAppImpl } = privateExports;
@@ -58,10 +58,9 @@ export function wrapInTestApp(
options: TestAppOptions = {},
): ReactElement {
const { routeEntries = ['/'] } = options;
const apis = createMockApiRegistry();
const app = new PrivateAppImpl({
apis,
apis: [],
components: {
NotFoundErrorPage,
BootErrorPage,
@@ -80,6 +79,7 @@ export function wrapInTestApp(
variant: 'light',
},
],
defaultApis: mockApis,
});
let Wrapper: ComponentType;
@@ -14,14 +14,14 @@
* limitations under the License.
*/
import { ApiTestRegistry } from '@backstage/core-api';
import {
storageApiRef,
errorApiRef,
createApiFactory,
} from '@backstage/core-api';
import { MockErrorApi, MockStorageApi } from './apis';
export function createMockApiRegistry(): ApiTestRegistry {
const registry = new ApiTestRegistry();
registry.register(MockErrorApi.factory);
registry.register(MockStorageApi.factory);
return registry;
}
export const mockApis = [
createApiFactory(errorApiRef, new MockErrorApi()),
createApiFactory(storageApiRef, MockStorageApi.create()),
];
+14 -1
View File
@@ -14,8 +14,21 @@
* limitations under the License.
*/
import { createPlugin } from '@backstage/core';
import {
createPlugin,
createApiFactory,
discoveryApiRef,
} from '@backstage/core';
import { catalogApiRef } from './api/types';
import { CatalogClient } from './api/CatalogClient';
export const plugin = createPlugin({
id: 'catalog',
apis: [
createApiFactory({
api: catalogApiRef,
deps: { discoveryApi: discoveryApiRef },
factory: ({ discoveryApi }) => new CatalogClient({ discoveryApi }),
}),
],
});
+2 -2
View File
@@ -20,9 +20,9 @@ import { circleCIApiRef, CircleCIApi } from '../src/api';
createDevApp()
.registerPlugin(plugin)
.registerApiFactory({
.registerApi({
api: circleCIApiRef,
deps: {},
factory: () => new CircleCIApi(),
implements: circleCIApiRef,
})
.render();
+12 -1
View File
@@ -13,13 +13,24 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createPlugin } from '@backstage/core';
import { createPlugin, createApiFactory, configApiRef } from '@backstage/core';
import { circleCIRouteRef, circleCIBuildRouteRef } from './route-refs';
import BuildsPage from './pages/BuildsPage/BuildsPage';
import BuildWithStepsPage from './pages/BuildWithStepsPage/BuildWithStepsPage';
import { circleCIApiRef, CircleCIApi } from './api';
export const plugin = createPlugin({
id: 'circleci',
apis: [
createApiFactory({
api: circleCIApiRef,
deps: { configApi: configApiRef },
factory: ({ configApi }) =>
new CircleCIApi(
`${configApi.getString('backend.baseUrl')}/proxy/circleci/api`,
),
}),
],
register({ router }) {
router.addRoute(circleCIRouteRef, BuildsPage);
router.addRoute(circleCIBuildRouteRef, BuildWithStepsPage);
+7 -1
View File
@@ -14,10 +14,15 @@
* limitations under the License.
*/
import { createPlugin, createRouteRef } from '@backstage/core';
import {
createPlugin,
createRouteRef,
createApiFactory,
} from '@backstage/core';
import { ProjectListPage } from './components/ProjectListPage';
import { ProjectDetailsPage } from './components/ProjectDetailsPage';
import { NewProjectPage } from './components/NewProjectPage';
import { GCPApiRef, GCPClient } from './api';
export const rootRouteRef = createRouteRef({
path: '/gcp-projects',
@@ -34,6 +39,7 @@ export const NewProjectRouteRef = createRouteRef({
export const plugin = createPlugin({
id: 'gcp-projects',
apis: [createApiFactory(GCPApiRef, new GCPClient())],
register({ router }) {
router.addRoute(rootRouteRef, ProjectListPage);
router.addRoute(ProjectRouteRef, ProjectDetailsPage);
+7 -1
View File
@@ -14,7 +14,12 @@
* limitations under the License.
*/
import { createPlugin, createRouteRef } from '@backstage/core';
import {
createPlugin,
createRouteRef,
createApiFactory,
} from '@backstage/core';
import { githubActionsApiRef, GithubActionsClient } from './api';
// TODO(freben): This is just a demo route for now
export const rootRouteRef = createRouteRef({
@@ -29,4 +34,5 @@ export const buildRouteRef = createRouteRef({
export const plugin = createPlugin({
id: 'github-actions',
apis: [createApiFactory(githubActionsApiRef, new GithubActionsClient())],
});
+5 -1
View File
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { createPlugin } from '@backstage/core';
import { createPlugin, createApiFactory } from '@backstage/core';
import ProfileCatalog from './components/ProfileCatalog';
import ClusterPage from './components/ClusterPage';
import ClusterList from './components/ClusterList';
@@ -23,9 +23,13 @@ import {
gitOpsClusterDetailsRoute,
gitOpsClusterCreateRoute,
} from './routes';
import { gitOpsApiRef, GitOpsRestApi } from './api';
export const plugin = createPlugin({
id: 'gitops-profiles',
apis: [
createApiFactory(gitOpsApiRef, new GitOpsRestApi('http://localhost:3008')),
],
register({ router }) {
router.addRoute(gitOpsClusterListRoute, ClusterList);
router.addRoute(gitOpsClusterDetailsRoute, ClusterPage);
+2 -2
View File
@@ -20,8 +20,8 @@ import { plugin, GraphQLEndpoints, graphQlBrowseApiRef } from '../src';
createDevApp()
.registerPlugin(plugin)
.registerApiFactory({
implements: graphQlBrowseApiRef,
.registerApi({
api: graphQlBrowseApiRef,
deps: {
errorApi: errorApiRef,
githubAuthApi: githubAuthApiRef,
+15 -1
View File
@@ -14,8 +14,22 @@
* limitations under the License.
*/
import { createPlugin } from '@backstage/core';
import { createPlugin, createApiFactory } from '@backstage/core';
import { graphQlBrowseApiRef, GraphQLEndpoints } from './lib/api';
export const plugin = createPlugin({
id: 'graphiql',
apis: [
// GitLab is used as an example endpoint, but most plug
createApiFactory(
graphQlBrowseApiRef,
GraphQLEndpoints.from([
GraphQLEndpoints.create({
id: 'gitlab',
title: 'GitLab',
url: 'https://gitlab.com/api/graphql',
}),
]),
),
],
});
+17 -1
View File
@@ -14,8 +14,14 @@
* limitations under the License.
*/
import { createPlugin, createRouteRef } from '@backstage/core';
import {
createPlugin,
createRouteRef,
createApiFactory,
configApiRef,
} from '@backstage/core';
import { DetailedViewPage } from './pages/BuildWithStepsPage';
import { jenkinsApiRef, JenkinsApi } from './api';
export const buildRouteRef = createRouteRef({
path: '/jenkins/job',
@@ -24,6 +30,16 @@ export const buildRouteRef = createRouteRef({
export const plugin = createPlugin({
id: 'jenkins',
apis: [
createApiFactory({
api: jenkinsApiRef,
deps: { configApi: configApiRef },
factory: ({ configApi }) =>
new JenkinsApi(
`${configApi.getString('backend.baseUrl')}/proxy/jenkins/api`,
),
}),
],
register({ router }) {
router.addRoute(buildRouteRef, DetailedViewPage);
},
+2 -2
View File
@@ -20,8 +20,8 @@ import { lighthouseApiRef, LighthouseRestApi } from '../src';
createDevApp()
.registerPlugin(plugin)
.registerApiFactory({
implements: lighthouseApiRef,
.registerApi({
api: lighthouseApiRef,
deps: {},
factory: () => new LighthouseRestApi('http://localhost:3003'),
})
+9 -1
View File
@@ -14,13 +14,21 @@
* limitations under the License.
*/
import { createPlugin } from '@backstage/core';
import { createPlugin, createApiFactory, configApiRef } from '@backstage/core';
import AuditList from './components/AuditList';
import AuditView from './components/AuditView';
import CreateAudit from './components/CreateAudit';
import { lighthouseApiRef, LighthouseRestApi } from './api';
export const plugin = createPlugin({
id: 'lighthouse',
apis: [
createApiFactory({
api: lighthouseApiRef,
deps: { configApi: configApiRef },
factory: ({ configApi }) => LighthouseRestApi.fromConfig(configApi),
}),
],
register({ router }) {
router.registerRoute('/lighthouse', AuditList);
router.registerRoute('/lighthouse/audit/:id', AuditView);
+14 -1
View File
@@ -14,13 +14,26 @@
* limitations under the License.
*/
import { createPlugin } from '@backstage/core';
import {
createPlugin,
createApiFactory,
discoveryApiRef,
} from '@backstage/core';
import { rootRouteRef, entityRouteRef } from './routes';
import { RollbarHome } from './components/RollbarHome/RollbarHome';
import { RollbarProjectPage } from './components/RollbarProjectPage/RollbarProjectPage';
import { rollbarApiRef } from './api/RollbarApi';
import { RollbarClient } from './api/RollbarClient';
export const plugin = createPlugin({
id: 'rollbar',
apis: [
createApiFactory({
api: rollbarApiRef,
deps: { discoveryApi: discoveryApiRef },
factory: ({ discoveryApi }) => new RollbarClient({ discoveryApi }),
}),
],
register({ router }) {
router.addRoute(rootRouteRef, RollbarHome);
router.addRoute(entityRouteRef, RollbarProjectPage);
+13 -1
View File
@@ -14,13 +14,25 @@
* limitations under the License.
*/
import { createPlugin } from '@backstage/core';
import {
createPlugin,
createApiFactory,
discoveryApiRef,
} from '@backstage/core';
import { ScaffolderPage } from './components/ScaffolderPage';
import { TemplatePage } from './components/TemplatePage';
import { rootRoute, templateRoute } from './routes';
import { scaffolderApiRef, ScaffolderApi } from './api';
export const plugin = createPlugin({
id: 'scaffolder',
apis: [
createApiFactory({
api: scaffolderApiRef,
deps: { discoveryApi: discoveryApiRef },
factory: ({ discoveryApi }) => new ScaffolderApi({ discoveryApi }),
}),
],
register({ router }) {
router.addRoute(rootRoute, ScaffolderPage);
router.addRoute(templateRoute, TemplatePage);
+2 -2
View File
@@ -20,13 +20,13 @@ import { TechDocsDevStorageApi } from './api';
import { techdocsStorageApiRef } from '../src';
createDevApp()
.registerApiFactory({
.registerApi({
api: techdocsStorageApiRef,
deps: {},
factory: () =>
new TechDocsDevStorageApi({
apiOrigin: 'http://localhost:3000/api',
}),
implements: techdocsStorageApiRef,
})
.registerPlugin(plugin)
.render();
+17 -1
View File
@@ -29,7 +29,13 @@
* limitations under the License.
*/
import { createPlugin, createRouteRef } from '@backstage/core';
import {
createPlugin,
createRouteRef,
createApiFactory,
configApiRef,
} from '@backstage/core';
import { techdocsStorageApiRef, TechDocsStorageApi } from './api';
export const rootRouteRef = createRouteRef({
path: '',
@@ -48,4 +54,14 @@ export const rootCatalogDocsRouteRef = createRouteRef({
export const plugin = createPlugin({
id: 'techdocs',
apis: [
createApiFactory({
api: techdocsStorageApiRef,
deps: { configApi: configApiRef },
factory: ({ configApi }) =>
new TechDocsStorageApi({
apiOrigin: configApi.getString('techdocs.storageUrl'),
}),
}),
],
});