diff --git a/packages/app/src/apis.ts b/packages/app/src/apis.ts index b79a6850bd..7f57f6f970 100644 --- a/packages/app/src/apis.ts +++ b/packages/app/src/apis.ts @@ -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()), +]; diff --git a/packages/core-api/src/apis/ApiFactoryRegistry.test.ts b/packages/core-api/src/apis/ApiFactoryRegistry.test.ts new file mode 100644 index 0000000000..9f44adaef3 --- /dev/null +++ b/packages/core-api/src/apis/ApiFactoryRegistry.test.ts @@ -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({ id: 'a', description: '' }); +const aFactory1 = { api: aRef, deps: {}, factory: () => 1 }; +const aFactory2 = { api: aRef, deps: {}, factory: () => 2 }; +const bRef = createApiRef({ id: 'b', description: '' }); +const bFactory = { api: bRef, deps: {}, factory: () => 'x' }; +const cRef = createApiRef({ 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])); + }); +}); diff --git a/packages/core-api/src/apis/ApiFactoryRegistry.ts b/packages/core-api/src/apis/ApiFactoryRegistry.ts new file mode 100644 index 0000000000..0d36e6c550 --- /dev/null +++ b/packages/core-api/src/apis/ApiFactoryRegistry.ts @@ -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(); + + /** + * 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( + scope: ApiFactoryScope, + factory: ApiFactory, + ) { + 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(api: ApiRef): ApiFactory | undefined { + const tuple = this.factories.get(api); + if (!tuple) { + return undefined; + } + return tuple.factory as ApiFactory; + } + + getAllApis(): Set { + return new Set(this.factories.keys()); + } +} diff --git a/packages/core-api/src/apis/ApiResolver.test.ts b/packages/core-api/src/apis/ApiResolver.test.ts new file mode 100644 index 0000000000..3cd40f289e --- /dev/null +++ b/packages/core-api/src/apis/ApiResolver.test.ts @@ -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({ id: 'a', description: '' }); +const bRef = createApiRef({ 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); + }); +}); diff --git a/packages/core-api/src/apis/ApiTestRegistry.ts b/packages/core-api/src/apis/ApiResolver.ts similarity index 57% rename from packages/core-api/src/apis/ApiTestRegistry.ts rename to packages/core-api/src/apis/ApiResolver.ts index 7aed3c7917..cb65186a37 100644 --- a/packages/core-api/src/apis/ApiTestRegistry.ts +++ b/packages/core-api/src/apis/ApiResolver.ts @@ -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(); - private factories = new Map< - AnyApiRef, - ApiFactory - >(); - private savedFactories = new Map< - AnyApiRef, - ApiFactory - >(); + + /** + * Validate factories by making sure that each of the apis can be created + * without hitting any circular dependencies. + */ + static validateFactories( + factories: ApiFactoryHolder, + apis: Iterable, + ) { + for (const api of apis) { + const heap = [api]; + const allDeps = new Set(); + + 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(ref: ApiRef): T | undefined { return this.load(ref); } - register(factory: ApiFactory): 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(ref: ApiRef, 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; diff --git a/packages/core-api/src/apis/ApiTestRegistry.test.ts b/packages/core-api/src/apis/ApiTestRegistry.test.ts deleted file mode 100644 index fa42e64707..0000000000 --- a/packages/core-api/src/apis/ApiTestRegistry.test.ts +++ /dev/null @@ -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({ id: 'a', description: '' }); - const bRef = createApiRef({ id: 'b', description: '' }); - const cRef = createApiRef({ 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); - }); -}); diff --git a/packages/core-api/src/apis/helpers.ts b/packages/core-api/src/apis/helpers.ts index d8f8e8ac11..7d616acd01 100644 --- a/packages/core-api/src/apis/helpers.ts +++ b/packages/core-api/src/apis/helpers.ts @@ -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( - factory: ApiFactory, -): ApiFactory { +export function createApiFactory< + Api, + Impl extends Api, + Deps extends { [name in string]: unknown } +>(factory: ApiFactory): ApiFactory; +export function createApiFactory( + api: ApiRef, + instance: Api, +): ApiFactory; +export function createApiFactory< + Api, + Deps extends { [name in string]: unknown } +>( + factory: ApiFactory | ApiRef, + instance?: Api, +): ApiFactory { + if ('id' in factory) { + return { + api: factory, + deps: {} as TypesToApiRefs, + factory: () => instance!, + }; + } return factory; } diff --git a/packages/core-api/src/apis/index.ts b/packages/core-api/src/apis/index.ts index c3689b6703..c856a13668 100644 --- a/packages/core-api/src/apis/index.ts +++ b/packages/core-api/src/apis/index.ts @@ -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'; diff --git a/packages/core-api/src/apis/types.ts b/packages/core-api/src/apis/types.ts index 4ee9b39ca9..61c229b18e 100644 --- a/packages/core-api/src/apis/types.ts +++ b/packages/core-api/src/apis/types.ts @@ -16,13 +16,13 @@ import { ApiRef } from './ApiRef'; -export type AnyApiRef = ApiRef; +export type AnyApiRef = ApiRef; export type ApiRefType = T extends ApiRef ? U : never; export type TypesToApiRefs = { [key in keyof T]: ApiRef }; -export type ApiRefsToTypes }> = { +export type ApiRefsToTypes }> = { [key in keyof T]: ApiRefType; }; @@ -30,8 +30,16 @@ export type ApiHolder = { get(api: ApiRef): T | undefined; }; -export type ApiFactory = { - implements: ApiRef; +export type ApiFactory = { + api: ApiRef; deps: TypesToApiRefs; - factory(deps: Deps): Impl extends Api ? Impl : never; + factory(deps: Deps): Api; +}; + +export type AnyApiFactory = ApiFactory; + +export type ApiFactoryHolder = { + get( + api: ApiRef, + ): ApiFactory | undefined; }; diff --git a/packages/core-api/src/app/App.tsx b/packages/core-api/src/app/App.tsx index ccdfc4fc7f..9dd2fb138f 100644 --- a/packages/core-api/src/app/App.tsx +++ b/packages/core-api/src/app/App.tsx @@ -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; icons: SystemIcons; plugins: BackstagePlugin[]; components: AppComponents; themes: AppTheme[]; configLoader?: AppConfigLoader; + defaultApis: Iterable; }; 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; private readonly icons: SystemIcons; private readonly plugins: BackstagePlugin[]; private readonly components: AppComponents; private readonly themes: AppTheme[]; private readonly configLoader?: AppConfigLoader; + private readonly defaultApis: Iterable; 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(} />); @@ -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 ( - + {children} @@ -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(); diff --git a/packages/core-api/src/app/types.ts b/packages/core-api/src/app/types.ts index db7a0897b1..565e073aed 100644 --- a/packages/core-api/src/app/types.ts +++ b/packages/core-api/src/app/types.ts @@ -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; -// 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; /** * 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. */ diff --git a/packages/core-api/src/plugin/Plugin.tsx b/packages/core-api/src/plugin/Plugin.tsx index bf98919c2b..1835ec03ad 100644 --- a/packages/core-api/src/plugin/Plugin.tsx +++ b/packages/core-api/src/plugin/Plugin.tsx @@ -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; register?(hooks: PluginHooks): void; }; @@ -65,6 +67,10 @@ export class PluginImpl { return this.config.id; } + getApis(): Iterable { + return this.config.apis ?? []; + } + output(): PluginOutput[] { if (this.storedOutput) { return this.storedOutput; diff --git a/packages/core-api/src/plugin/types.ts b/packages/core-api/src/plugin/types.ts index fabe97434e..12992f3620 100644 --- a/packages/core-api/src/plugin/types.ts +++ b/packages/core-api/src/plugin/types.ts @@ -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; }; diff --git a/packages/core/src/api-wrappers/createApp.tsx b/packages/core/src/api-wrappers/createApp.tsx index 9d2096cfff..7fe77423e2 100644 --- a/packages/core/src/api-wrappers/createApp.tsx +++ b/packages/core/src/api-wrappers/createApp.tsx @@ -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(); diff --git a/packages/core/src/api-wrappers/defaultApis.ts b/packages/core/src/api-wrappers/defaultApis.ts new file mode 100644 index 0000000000..a3f0cb0251 --- /dev/null +++ b/packages/core/src/api-wrappers/defaultApis.ts @@ -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 }), + }), +]; diff --git a/packages/create-app/templates/default-app/packages/app/src/apis.ts b/packages/create-app/templates/default-app/packages/app/src/apis.ts index ba4343801f..e0503e504f 100644 --- a/packages/create-app/templates/default-app/packages/app/src/apis.ts +++ b/packages/create-app/templates/default-app/packages/app/src/apis.ts @@ -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 }}`, + ), + }), +]; diff --git a/packages/dev-utils/src/devApp/apiFactories.test.ts b/packages/dev-utils/src/devApp/apiFactories.test.ts deleted file mode 100644 index be3ae2cdbe..0000000000 --- a/packages/dev-utils/src/devApp/apiFactories.test.ts +++ /dev/null @@ -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[] = 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(); - } - }); -}); diff --git a/packages/dev-utils/src/devApp/apiFactories.ts b/packages/dev-utils/src/devApp/apiFactories.ts deleted file mode 100644 index 4cf208f5d2..0000000000 --- a/packages/dev-utils/src/devApp/apiFactories.ts +++ /dev/null @@ -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, - }), -}); diff --git a/packages/dev-utils/src/devApp/render.test.tsx b/packages/dev-utils/src/devApp/render.test.tsx new file mode 100644 index 0000000000..fbebf2039b --- /dev/null +++ b/packages/dev-utils/src/devApp/render.test.tsx @@ -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
My App: {configApi.getString('app.title')}
; + }; + + const DevApp = createDevApp() + .addRootChild() + .build(); + + const rendered = render(); + + expect(await rendered.findByText('My App: Test App')).toBeInTheDocument(); + }); +}); diff --git a/packages/dev-utils/src/devApp/render.tsx b/packages/dev-utils/src/devApp/render.tsx index cde39fdd05..f223fcd4e7 100644 --- a/packages/dev-utils/src/devApp/render.tsx +++ b/packages/dev-utils/src/devApp/render.tsx @@ -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; */ class DevAppBuilder { private readonly plugins = new Array(); - private readonly factories = new Array>(); + private readonly apis = new Array(); private readonly rootChildren = new Array(); /** @@ -57,10 +55,10 @@ class DevAppBuilder { /** * Register an API factory to add to the app */ - registerApiFactory( - factory: ApiFactory, + registerApi( + factory: ApiFactory, ): 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[], - ): 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(); diff --git a/packages/test-utils/src/testUtils/apis/ErrorApi/MockErrorApi.ts b/packages/test-utils/src/testUtils/apis/ErrorApi/MockErrorApi.ts index 9b22a352e8..7bf50314d0 100644 --- a/packages/test-utils/src/testUtils/apis/ErrorApi/MockErrorApi.ts +++ b/packages/test-utils/src/testUtils/apis/ErrorApi/MockErrorApi.ts @@ -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(); private readonly waiters = new Set(); diff --git a/packages/test-utils/src/testUtils/apis/StorageApi/MockStorageApi.ts b/packages/test-utils/src/testUtils/apis/StorageApi/MockStorageApi.ts index a3a4ef16d6..006b44a49c 100644 --- a/packages/test-utils/src/testUtils/apis/StorageApi/MockStorageApi.ts +++ b/packages/test-utils/src/testUtils/apis/StorageApi/MockStorageApi.ts @@ -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; diff --git a/packages/test-utils/src/testUtils/appWrappers.test.tsx b/packages/test-utils/src/testUtils/appWrappers.test.tsx index 19c02c2158..464c547ecc 100644 --- a/packages/test-utils/src/testUtils/appWrappers.test.tsx +++ b/packages/test-utils/src/testUtils/appWrappers.test.tsx @@ -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\(...\)/, - ), ]); }); diff --git a/packages/test-utils/src/testUtils/appWrappers.tsx b/packages/test-utils/src/testUtils/appWrappers.tsx index 2e3a73e346..182de1ef63 100644 --- a/packages/test-utils/src/testUtils/appWrappers.tsx +++ b/packages/test-utils/src/testUtils/appWrappers.tsx @@ -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; diff --git a/packages/test-utils/src/testUtils/mockApiRegistry.ts b/packages/test-utils/src/testUtils/mockApis.ts similarity index 70% rename from packages/test-utils/src/testUtils/mockApiRegistry.ts rename to packages/test-utils/src/testUtils/mockApis.ts index 15733ead88..e05a8e6cac 100644 --- a/packages/test-utils/src/testUtils/mockApiRegistry.ts +++ b/packages/test-utils/src/testUtils/mockApis.ts @@ -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()), +]; diff --git a/plugins/catalog/src/plugin.ts b/plugins/catalog/src/plugin.ts index 4bfbf2453b..dfe12fd500 100644 --- a/plugins/catalog/src/plugin.ts +++ b/plugins/catalog/src/plugin.ts @@ -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 }), + }), + ], }); diff --git a/plugins/circleci/dev/index.tsx b/plugins/circleci/dev/index.tsx index ed7dd5de9c..4bf67d5cb2 100644 --- a/plugins/circleci/dev/index.tsx +++ b/plugins/circleci/dev/index.tsx @@ -20,9 +20,9 @@ import { circleCIApiRef, CircleCIApi } from '../src/api'; createDevApp() .registerPlugin(plugin) - .registerApiFactory({ + .registerApi({ + api: circleCIApiRef, deps: {}, factory: () => new CircleCIApi(), - implements: circleCIApiRef, }) .render(); diff --git a/plugins/circleci/src/plugin.ts b/plugins/circleci/src/plugin.ts index 47ecccef4f..86be898366 100644 --- a/plugins/circleci/src/plugin.ts +++ b/plugins/circleci/src/plugin.ts @@ -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); diff --git a/plugins/gcp-projects/src/plugin.ts b/plugins/gcp-projects/src/plugin.ts index 29c7d74434..41aacde597 100644 --- a/plugins/gcp-projects/src/plugin.ts +++ b/plugins/gcp-projects/src/plugin.ts @@ -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); diff --git a/plugins/github-actions/src/plugin.ts b/plugins/github-actions/src/plugin.ts index 7e08229d6d..9e3c965d46 100644 --- a/plugins/github-actions/src/plugin.ts +++ b/plugins/github-actions/src/plugin.ts @@ -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())], }); diff --git a/plugins/gitops-profiles/src/plugin.ts b/plugins/gitops-profiles/src/plugin.ts index af180aff20..45820644f7 100644 --- a/plugins/gitops-profiles/src/plugin.ts +++ b/plugins/gitops-profiles/src/plugin.ts @@ -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); diff --git a/plugins/graphiql/dev/index.tsx b/plugins/graphiql/dev/index.tsx index efcf19a89d..b93995a5dc 100644 --- a/plugins/graphiql/dev/index.tsx +++ b/plugins/graphiql/dev/index.tsx @@ -20,8 +20,8 @@ import { plugin, GraphQLEndpoints, graphQlBrowseApiRef } from '../src'; createDevApp() .registerPlugin(plugin) - .registerApiFactory({ - implements: graphQlBrowseApiRef, + .registerApi({ + api: graphQlBrowseApiRef, deps: { errorApi: errorApiRef, githubAuthApi: githubAuthApiRef, diff --git a/plugins/graphiql/src/plugin.ts b/plugins/graphiql/src/plugin.ts index 7763284d81..28d27802d0 100644 --- a/plugins/graphiql/src/plugin.ts +++ b/plugins/graphiql/src/plugin.ts @@ -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', + }), + ]), + ), + ], }); diff --git a/plugins/jenkins/src/plugin.ts b/plugins/jenkins/src/plugin.ts index 979b0a31cf..4d1af322c4 100644 --- a/plugins/jenkins/src/plugin.ts +++ b/plugins/jenkins/src/plugin.ts @@ -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); }, diff --git a/plugins/lighthouse/dev/index.tsx b/plugins/lighthouse/dev/index.tsx index 6496fb658a..bf761965f1 100644 --- a/plugins/lighthouse/dev/index.tsx +++ b/plugins/lighthouse/dev/index.tsx @@ -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'), }) diff --git a/plugins/lighthouse/src/plugin.ts b/plugins/lighthouse/src/plugin.ts index f8da8d84ed..677b5f682a 100644 --- a/plugins/lighthouse/src/plugin.ts +++ b/plugins/lighthouse/src/plugin.ts @@ -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); diff --git a/plugins/rollbar/src/plugin.ts b/plugins/rollbar/src/plugin.ts index 2cf464a8f6..c3ca6173ff 100644 --- a/plugins/rollbar/src/plugin.ts +++ b/plugins/rollbar/src/plugin.ts @@ -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); diff --git a/plugins/scaffolder/src/plugin.ts b/plugins/scaffolder/src/plugin.ts index 8c4ed5badd..41f7a03249 100644 --- a/plugins/scaffolder/src/plugin.ts +++ b/plugins/scaffolder/src/plugin.ts @@ -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); diff --git a/plugins/techdocs/dev/index.tsx b/plugins/techdocs/dev/index.tsx index b415256fde..12974ef8b7 100644 --- a/plugins/techdocs/dev/index.tsx +++ b/plugins/techdocs/dev/index.tsx @@ -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(); diff --git a/plugins/techdocs/src/plugin.ts b/plugins/techdocs/src/plugin.ts index e75d488595..d00fcac485 100644 --- a/plugins/techdocs/src/plugin.ts +++ b/plugins/techdocs/src/plugin.ts @@ -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'), + }), + }), + ], });