packages/core: added api provider

This commit is contained in:
Patrik Oldsberg
2020-03-12 10:58:08 +01:00
parent a15458b0af
commit 64cdb6bbe1
13 changed files with 702 additions and 1 deletions
+2 -1
View File
@@ -33,6 +33,7 @@
"@spotify/web-scripts": "^6.0.0",
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^7.1.2"
"@testing-library/user-event": "^7.1.2",
"prop-types": "^15.7.2"
}
}
+5
View File
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import ApiRef, { ApiRefConfig } from './apis/ApiRef';
import AppBuilder from './app/AppBuilder';
import WidgetViewBuilder from './widgetView/WidgetViewBuilder';
import BackstagePlugin, { PluginConfig } from './plugin/Plugin';
@@ -22,6 +23,10 @@ export function createApp() {
return new AppBuilder();
}
export function createApiRef<T>(config: ApiRefConfig) {
return new ApiRef<T>(config);
}
export function createWidgetView() {
return new WidgetViewBuilder();
}
@@ -0,0 +1,148 @@
/*
* 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 ApiProvider, { useApi, withApis } from './ApiProvider';
import ApiRef from './ApiRef';
import ApiRegistry from './ApiRegistry';
import { render } from '@testing-library/react';
import { withLogCollector } from '../../testUtils';
describe('ApiProvider', () => {
type Api = () => string;
const apiRef = new ApiRef<Api>({ id: 'x', description: '' });
const registry = ApiRegistry.from([[apiRef, () => 'hello']]);
const MyHookConsumer = () => {
const api = useApi(apiRef);
return <p>hook message: {api()}</p>;
};
const MyHocConsumer = withApis({ getMessage: apiRef })(({ getMessage }) => {
return <p>hoc message: {getMessage()}</p>;
});
it('should provide apis', () => {
const renderedHook = render(
<ApiProvider apis={registry}>
<MyHookConsumer />
</ApiProvider>,
);
renderedHook.getByText('hook message: hello');
const renderedHoc = render(
<ApiProvider apis={registry}>
<MyHocConsumer />
</ApiProvider>,
);
renderedHoc.getByText('hoc message: hello');
});
it('should ignore deps in prototype', () => {
// 100% coverage + happy typescript = hasOwnProperty + this atrocity
const xRef = new ApiRef<number>({ id: 'x', description: '' });
const proto = { x: xRef };
const props = { getMessage: { enumerable: true, value: apiRef } };
const obj = Object.create(proto, props) as {
getMessage: typeof apiRef;
x: typeof xRef;
};
const MyWeirdHocConsumer = withApis(obj)(({ getMessage }) => {
return <p>hoc message: {getMessage()}</p>;
});
const renderedHoc = render(
<ApiProvider apis={registry}>
<MyWeirdHocConsumer />
</ApiProvider>,
);
renderedHoc.getByText('hoc message: hello');
});
it('should error if no provider is available', () => {
expect(
withLogCollector(['error'], () => {
expect(() => {
render(<MyHookConsumer />);
}).toThrow('No ApiProvider available in react context');
}).error,
).toEqual([
expect.stringMatching(
/^Error: Uncaught \[Error: No ApiProvider available in react context\]/,
),
expect.stringMatching(
/^The above error occurred in the <MyHookConsumer> component/,
),
]);
expect(
withLogCollector(['error'], () => {
expect(() => {
render(<MyHocConsumer />);
}).toThrow('No ApiProvider available in react context');
}).error,
).toEqual([
expect.stringMatching(
/^Error: Uncaught \[Error: No ApiProvider available in react context\]/,
),
expect.stringMatching(
/^The above error occurred in the <withApis\(Component\)> component/,
),
]);
});
it('should error if api is not available', () => {
expect(
withLogCollector(['error'], () => {
expect(() => {
render(
<ApiProvider apis={ApiRegistry.from([])}>
<MyHookConsumer />
</ApiProvider>,
);
}).toThrow('No implementation available for apiRef{x}');
}).error,
).toEqual([
expect.stringMatching(
/^Error: Uncaught \[Error: No implementation available for apiRef{x}\]/,
),
expect.stringMatching(
/^The above error occurred in the <MyHookConsumer> component/,
),
]);
expect(
withLogCollector(['error'], () => {
expect(() => {
render(
<ApiProvider apis={ApiRegistry.from([])}>
<MyHocConsumer />
</ApiProvider>,
);
}).toThrow('No implementation available for apiRef{x}');
}).error,
).toEqual([
expect.stringMatching(
/^Error: Uncaught \[Error: No implementation available for apiRef{x}\]/,
),
expect.stringMatching(
/^The above error occurred in the <withApis\(Component\)> component/,
),
]);
});
});
@@ -0,0 +1,88 @@
/*
* 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, { FC, createContext, useContext, ReactNode } from 'react';
import PropTypes from 'prop-types';
import ApiRef from './ApiRef';
import { ApiHolder, TypesToApiRefs } from './types';
type Props = {
apis: ApiHolder;
children: ReactNode;
};
const Context = createContext<ApiHolder | undefined>(undefined);
const ApiProvider: FC<Props> = ({ apis, children }) => {
return <Context.Provider value={apis} children={children} />;
};
ApiProvider.propTypes = {
apis: PropTypes.shape({ get: PropTypes.func.isRequired }).isRequired,
children: PropTypes.node,
};
export function useApi<T>(apiRef: ApiRef<T>): T {
const apiHolder = useContext(Context);
if (!apiHolder) {
throw new Error('No ApiProvider available in react context');
}
const api = apiHolder.get(apiRef);
if (!api) {
throw new Error(`No implementation available for ${apiRef}`);
}
return api;
}
export function withApis<T>(apis: TypesToApiRefs<T>) {
return function withApisWrapper<P extends T>(
WrappedComponent: React.ComponentType<P>,
) {
const Hoc: FC<Omit<P, keyof T>> = props => {
const apiHolder = useContext(Context);
if (!apiHolder) {
throw new Error('No ApiProvider available in react context');
}
const impls = {} as T;
for (const key in apis) {
if (apis.hasOwnProperty(key)) {
const ref = apis[key];
const api = apiHolder.get(ref);
if (!api) {
throw new Error(`No implementation available for ${ref}`);
}
impls[key] = api;
}
}
return <WrappedComponent {...(props as P)} {...impls} />;
};
const displayName =
WrappedComponent.displayName || WrappedComponent.name || 'Component';
Hoc.displayName = `withApis(${displayName})`;
return Hoc;
};
}
export default ApiProvider;
+39
View File
@@ -0,0 +1,39 @@
/*
* 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 ApiRef from './ApiRef';
describe('ApiRef', () => {
it('should be created', () => {
const ref = new ApiRef({ id: 'abc', description: '123' });
expect(ref.id).toBe('abc');
expect(ref.description).toBe('123');
expect(String(ref)).toBe('apiRef{abc}');
expect(() => ref.T).toThrow('tried to read ApiRef.T of apiRef{abc}');
});
it('should require a ascii letters only in id', () => {
for (const id of ['a', 'abc', 'ABC', 'aBC', 'aBc']) {
expect(new ApiRef({ id, description: '123' }).id).toBe(id);
}
for (const id of ['123', 'ab-c', 'ab_c', 'a2c', '', '_']) {
expect(() => new ApiRef({ id, description: '123' }).id).toThrow(
`API id must only contain ascii letters, got '${id}'`,
);
}
});
});
+47
View File
@@ -0,0 +1,47 @@
/*
* 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.
*/
export type ApiRefConfig = {
id: string;
description: string;
};
export default class ApiRef<T> {
constructor(private readonly config: ApiRefConfig) {
if (!config.id.match(/^[a-zA-Z]+$/)) {
throw new Error(
`API id must only contain ascii letters, got '${config.id}'`,
);
}
}
get id(): string {
return this.config.id;
}
get description(): string {
return this.config.description;
}
// Utility for getting type of an api, using `typeof apiRef.T`
get T(): T {
throw new Error(`tried to read ApiRef.T of ${this}`);
}
toString() {
return `apiRef{${this.config.id}}`;
}
}
@@ -0,0 +1,52 @@
/*
* 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 ApiRegistry from './ApiRegistry';
import ApiRef from './ApiRef';
describe('ApiRegistry', () => {
const x1Ref = new ApiRef<number>({ id: 'x', description: '' });
const x2Ref = new ApiRef<string>({ id: 'x', description: '' });
it('should be created', () => {
const registry = ApiRegistry.from([]);
expect(registry.get(x1Ref)).toBe(undefined);
});
it('should be created with APIs', () => {
const registry = ApiRegistry.from([
[x1Ref, 3],
[x2Ref, 'y'],
]);
expect(registry.get(x1Ref)).toBe(3);
expect(registry.get(x2Ref)).toBe('y');
});
it('should be built', () => {
const registry = ApiRegistry.builder().build();
expect(registry.get(x1Ref)).toBe(undefined);
});
it('should be built with APIs', () => {
const builder = ApiRegistry.builder();
builder.add(x1Ref, 3);
builder.add(x2Ref, 'y');
const registry = builder.build();
expect(registry.get(x1Ref)).toBe(3);
expect(registry.get(x2Ref)).toBe('y');
});
});
+50
View File
@@ -0,0 +1,50 @@
/*
* 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 ApiRef from './ApiRef';
import { ApiHolder } from './types';
type ApiImpl<T = unknown> = readonly [ApiRef<T>, T];
class ApiRegistryBuilder {
private apis: ApiImpl[] = [];
add<T>(api: ApiRef<T>, impl: T): T {
this.apis.push([api, impl]);
return impl;
}
build(): ApiRegistry {
// eslint-disable-next-line no-use-before-define
return new ApiRegistry(new Map(this.apis));
}
}
export default class ApiRegistry implements ApiHolder {
static builder() {
return new ApiRegistryBuilder();
}
static from(apis: ApiImpl[]) {
return new ApiRegistry(new Map(apis));
}
constructor(private readonly apis: Map<ApiRef<unknown>, unknown>) {}
get<T>(api: ApiRef<T>): T | undefined {
return this.apis.get(api) as T | undefined;
}
}
@@ -0,0 +1,121 @@
/*
* 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 ApiRef from './ApiRef';
describe('ApiTestRegistry', () => {
const aRef = new ApiRef<number>({ id: 'a', description: '' });
const bRef = new ApiRef<string>({ id: 'b', description: '' });
const cRef = new ApiRef<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(aRef, () => 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(aRef, () => 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(aRef, () => 3);
registry.save();
registry.register(bRef, () => '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(aRef, 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(aRef, 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);
});
});
@@ -0,0 +1,92 @@
/*
* 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 ApiRef from './ApiRef';
import { TypesToApiRefs, AnyApiRef, ApiHolder, ApiFactory } from './types';
export default class ApiTestRegistry 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>>();
get<T>(ref: ApiRef<T>): T | undefined {
return this.load(ref);
}
register<T>(ref: ApiRef<T>, factoryFunc: () => T): ApiTestRegistry;
register<A, I, D>(factory: ApiFactory<A, I, D>): ApiTestRegistry;
register<A, I, D, T>(factory: ApiRef<T> | ApiFactory<A, I, D>, factoryFunc?: () => T): ApiTestRegistry {
if (factory instanceof ApiRef) {
this.factories.set(factory, {
implements: factory,
deps: {},
factory: factoryFunc!,
});
} else {
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) {
return impl as T;
}
const factory = this.factories.get(ref);
if (!factory) {
return undefined;
}
if (loading.includes(factory.implements)) {
throw new Error(`Circular dependency of api factory for ${factory.implements}`);
}
const deps = this.loadDeps(ref, factory.deps, [...loading, factory.implements]);
const api = factory.factory(deps);
this.apis.set(ref, api);
return api as T;
}
private loadDeps<T>(dependent: ApiRef<unknown>, apis: TypesToApiRefs<T>, loading: AnyApiRef[]): T {
const impls = {} as T;
for (const key in apis) {
if (apis.hasOwnProperty(key)) {
const ref = apis[key];
const api = this.load(ref, loading);
if (!api) {
throw new Error(`No API factory available for dependency ${ref} of dependent ${dependent}`);
}
impls[key] = api;
}
}
return impls;
}
}
+20
View File
@@ -0,0 +1,20 @@
/*
* 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.
*/
export { default as ApiProvider, useApi } from './ApiProvider';
export { default as ApiRegistry } from './ApiRegistry';
export { default as ApiTestRegistry } from './ApiTestRegistry';
export * from './types';
+37
View File
@@ -0,0 +1,37 @@
/*
* 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 ApiRef from './ApiRef';
export type AnyApiRef = ApiRef<any>;
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> }> = {
[key in keyof T]: ApiRefType<T[key]>;
};
export type ApiHolder = {
get<T>(api: ApiRef<T>): T | undefined;
};
export type ApiFactory<A, I, D> = {
implements: ApiRef<A>;
deps: TypesToApiRefs<D>;
factory(deps: D): I extends A ? I : never;
};
+1
View File
@@ -15,4 +15,5 @@
*/
export * from './api';
export * from './apis';
export { useApp } from './app/AppContext';