Merge pull request #646 from spotify/rugvip/graphiql

[Plugin] Add GraphiQL Plugin
This commit is contained in:
Patrik Oldsberg
2020-04-26 14:18:52 +02:00
committed by GitHub
23 changed files with 1333 additions and 6 deletions
+7
View File
@@ -0,0 +1,7 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
rules: {
// Prefer to use rendered.getBy*, which will throw an error
'jest/expect-expect': 0,
},
};
+64
View File
@@ -0,0 +1,64 @@
# @backstage/plugin-graphiql
This plugin integrates [GraphiQL](https://github.com/graphql/graphiql) as a tool to browse GraphiQL endpoints inside Backstage.
The purpose of the plugin is to provide a convenient way for developers to try out GraphQL queries in their own environment.
By exposing GraphiQL as a plugin instead of a standalone app, it's possible to provide a preconfigured environment for engineers, and also tie into authentication providers already inside Backstage.
## Getting Started
### Installing the plugin
Start out by installing the plugin in your Backstage app:
```bash
yarn add @backstage/plugin-graphiql
```
Then add an entry to your App's `plugins.ts` to import the plugin.
The plugin registers a `/graphiql` route, which you can link to from the Sidebar if desired.
### Adding GraphQL endpoints
For the plugin to function, you need to supply GraphQL endpoints through the GraphQLBrowse API, which is done by implementing the `GraphQLBrowseApi` exported by this plugin.
If all you need is a static list of endpoints, the plugin exports a `GraphQLEndpoints` class that implements the `GraphQLBrowseApi` for you. Here's and example of how you could expose two GraphQL endpoints in your App:
```tsx
import {
graphQlBrowseApiRef,
GraphQLEndpoints,
} from '@backstage/plugin-graphiql';
// Implement the Graph QL browse API using a static list of endpoints
const graphQlBrowseApi = GraphQLEndpoints.from([
// Use the .create function if all you need is a static URL and headers.
GraphQLEndpoints.create({
id: 'gitlab',
title: 'GitLab',
url: 'https://gitlab.com/api/graphql',
// Optional extra headers
headers: { Extra: 'Header' },
}),
{
id: 'hooli-search',
title: 'Hooli Search',
// Custom fetch function, this one is equivalent to using GraphQLEndpoints.create()
// with url set to https://internal.hooli.com/search
fetcher: async (params: any) => {
return fetch('https://internal.hooli.com/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
}).then(res => res.json());
},
},
]);
// ApiRegistry builder created somewhere in your App
const builder = ApiRegistry.builder();
// Add the instance to the API registry
builder.add(graphQlBrowseApiRef, graphQlBrowseApi);
```
+69
View File
@@ -0,0 +1,69 @@
/*
* 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 } from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter } from 'react-router-dom';
import HomeIcon from '@material-ui/icons/Home';
import { ThemeProvider, CssBaseline } from '@material-ui/core';
import {
createApp,
SidebarPage,
Sidebar,
SidebarItem,
SidebarSpacer,
ApiRegistry,
} from '@backstage/core';
import { lightTheme } from '@backstage/theme';
import { plugin, GraphQLEndpoints, graphQlBrowseApiRef } from '../src';
const graphQlBrowseApi = GraphQLEndpoints.from([
GraphQLEndpoints.create({
id: 'gitlab',
title: 'GitLab',
url: 'https://gitlab.com/api/graphql',
}),
GraphQLEndpoints.create({
id: 'countries',
title: 'Countries',
url: 'https://countries.trevorblades.com/',
}),
]);
const app = createApp();
app.registerApis(ApiRegistry.from([[graphQlBrowseApiRef, graphQlBrowseApi]]));
app.registerPlugin(plugin);
const AppComponent = app.build();
const App: FC<{}> = () => {
return (
<ThemeProvider theme={lightTheme}>
<CssBaseline>
<BrowserRouter>
<SidebarPage>
<Sidebar>
<SidebarSpacer />
<SidebarItem icon={HomeIcon} to="/graphiql" text="Home" />
</Sidebar>
<AppComponent />
</SidebarPage>
</BrowserRouter>
</CssBaseline>
</ThemeProvider>
);
};
ReactDOM.render(<App />, document.getElementById('root'));
+55
View File
@@ -0,0 +1,55 @@
{
"name": "@backstage/plugin-graphiql",
"description": "Backstage plugin for browsing GraphQL APIs",
"version": "0.1.1-alpha.4",
"private": false,
"publishConfig": {
"access": "public"
},
"homepage": "https://github.com/spotify/backstage/tree/master/plugins/graphiql#readme",
"repository": {
"type": "git",
"url": "https://github.com/spotify/backstage",
"directory": "plugins/graphiql"
},
"keywords": [
"backstage"
],
"license": "Apache-2.0",
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "backstage-cli plugin:build",
"start": "backstage-cli plugin:serve",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"clean": "backstage-cli clean"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.4",
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^7.1.2",
"@types/jest": "^24.0.0",
"@types/node": "^12.0.0",
"@types/testing-library__jest-dom": "5.0.2",
"jest-fetch-mock": "^3.0.3",
"react-router-dom": "^5.1.2"
},
"dependencies": {
"@backstage/core": "^0.1.1-alpha.4",
"@backstage/test-utils": "^0.1.1-alpha.4",
"@backstage/theme": "^0.1.1-alpha.4",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"graphiql": "^1.0.0-alpha.8",
"graphql": "15.0.0",
"react": "16.13.1",
"react-dom": "16.13.1",
"react-use": "^13.0.0"
},
"files": [
"dist"
]
}
@@ -0,0 +1,57 @@
/*
* 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 { GraphiQLBrowser } from './GraphiQLBrowser';
import { ThemeProvider } from '@material-ui/core';
import { lightTheme } from '@backstage/theme';
jest.mock('graphiql', () => () => '<GraphiQL />');
describe('GraphiQLBrowser', () => {
it('should render error text if there are no endpoints', () => {
const rendered = render(
<ThemeProvider theme={lightTheme}>
<GraphiQLBrowser endpoints={[]} />
</ThemeProvider>,
);
rendered.getByText('No endpoints available');
});
it('should render endpoint tabs', () => {
const rendered = render(
<ThemeProvider theme={lightTheme}>
<GraphiQLBrowser
endpoints={[
{
id: 'a',
title: 'Endpoint A',
async fetcher() {},
},
{
id: 'b',
title: 'Endpoint B',
async fetcher() {},
},
]}
/>
</ThemeProvider>,
);
rendered.getByText('Endpoint A');
rendered.getByText('Endpoint B');
});
});
@@ -0,0 +1,77 @@
/*
* 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, useState } from 'react';
import { Tabs, Tab, makeStyles, Typography, Divider } from '@material-ui/core';
import 'graphiql/graphiql.css';
import GraphiQL from 'graphiql';
import { StorageBucket } from 'lib/storage';
import { GraphQLEndpoint } from 'lib/api';
import { BackstageTheme } from '@backstage/theme';
const useStyles = makeStyles<BackstageTheme>(theme => ({
root: {
height: '100%',
display: 'flex',
flexFlow: 'column nowrap',
},
tabs: {
background: theme.palette.background.paper,
},
graphiQlWrapper: {
flex: 1,
'@global': {
'.graphiql-container': {
boxSizing: 'initial',
},
},
},
}));
type GraphiQLBrowserProps = {
endpoints: GraphQLEndpoint[];
};
export const GraphiQLBrowser: FC<GraphiQLBrowserProps> = ({ endpoints }) => {
const classes = useStyles();
const [tabIndex, setTabIndex] = useState(0);
if (!endpoints.length) {
return <Typography variant="h4">No endpoints available</Typography>;
}
const { id, fetcher } = endpoints[tabIndex];
const storage = StorageBucket.forLocalStorage(`plugin/graphiql/data/${id}`);
return (
<div className={classes.root}>
<Tabs
classes={{ root: classes.tabs }}
value={tabIndex}
onChange={(_, value) => setTabIndex(value)}
indicatorColor="primary"
>
{endpoints.map(({ title }, index) => (
<Tab key={index} label={title} value={index} />
))}
</Tabs>
<Divider />
<div className={classes.graphiQlWrapper}>
<GraphiQL key={tabIndex} fetcher={fetcher} storage={storage} />
</div>
</div>
);
};
@@ -0,0 +1,17 @@
/*
* 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 * from './GraphiQLBrowser';
@@ -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 from 'react';
import { GraphiQLPage } from './GraphiQLPage';
import { ThemeProvider } from '@material-ui/core';
import { lightTheme } from '@backstage/theme';
import { ApiProvider, ApiRegistry } from '@backstage/core';
import { renderWithEffects } from '@backstage/test-utils';
import { GraphQLBrowseApi, graphQlBrowseApiRef } from 'lib/api';
jest.mock('components/GraphiQLBrowser', () => ({
GraphiQLBrowser: () => '<GraphiQLBrowser />',
}));
describe('GraphiQLPage', () => {
it('should show progress', async () => {
const loadingApi: GraphQLBrowseApi = {
async getEndpoints() {
await new Promise(() => {});
return [];
},
};
const rendered = await renderWithEffects(
<ApiProvider apis={ApiRegistry.from([[graphQlBrowseApiRef, loadingApi]])}>
<ThemeProvider theme={lightTheme}>
<GraphiQLPage />
</ThemeProvider>
,
</ApiProvider>,
);
rendered.getByText('GraphiQL');
rendered.getByTestId('progress');
});
it('should show error', async () => {
const loadingApi: GraphQLBrowseApi = {
async getEndpoints() {
throw new Error('NOPE');
},
};
const rendered = await renderWithEffects(
<ApiProvider apis={ApiRegistry.from([[graphQlBrowseApiRef, loadingApi]])}>
<ThemeProvider theme={lightTheme}>
<GraphiQLPage />
</ThemeProvider>
</ApiProvider>,
);
rendered.getByText('GraphiQL');
rendered.getByText('Failed to load GraphQL endpoints, Error: NOPE');
});
it('should show GraphiQLBrowser', async () => {
const loadingApi: GraphQLBrowseApi = {
async getEndpoints() {
return [];
},
};
const rendered = await renderWithEffects(
<ApiProvider apis={ApiRegistry.from([[graphQlBrowseApiRef, loadingApi]])}>
<ThemeProvider theme={lightTheme}>
<GraphiQLPage />
</ThemeProvider>
</ApiProvider>,
);
rendered.getByText('GraphiQL');
rendered.getByText('<GraphiQLBrowser />');
});
});
@@ -0,0 +1,71 @@
/*
* 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 } from 'react';
import {
Content,
Header,
HeaderLabel,
Page,
Progress,
pageTheme,
useApi,
} from '@backstage/core';
import { useAsync } from 'react-use';
import 'graphiql/graphiql.css';
import { graphQlBrowseApiRef } from 'lib/api';
import { GraphiQLBrowser } from 'components';
import { Typography } from '@material-ui/core';
export const GraphiQLPage: FC<{}> = () => {
const graphQlBrowseApi = useApi(graphQlBrowseApiRef);
const endpoints = useAsync(() => graphQlBrowseApi.getEndpoints());
let content: JSX.Element;
if (endpoints.loading) {
content = (
<Content>
<Progress />
</Content>
);
} else if (endpoints.error) {
content = (
<Content>
<Typography variant="h4" color="error">
{/* TODO: provide a proper error component */}
Failed to load GraphQL endpoints, {String(endpoints.error)}
</Typography>
</Content>
);
} else {
content = (
<Content noPadding>
<GraphiQLBrowser endpoints={endpoints.value!} />
</Content>
);
}
return (
<Page theme={pageTheme.tool}>
<Header title="GraphiQL">
<HeaderLabel label="Owner" value="Spotify" />
<HeaderLabel label="Lifecycle" value="Alpha" />
</Header>
{content}
</Page>
);
};
@@ -0,0 +1,17 @@
/*
* 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 * from './GraphiQLPage';
+18
View File
@@ -0,0 +1,18 @@
/*
* 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 * from './GraphiQLPage';
export * from './GraphiQLBrowser';
+18
View File
@@ -0,0 +1,18 @@
/*
* 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 { plugin } from './plugin';
export * from './lib/api';
@@ -0,0 +1,63 @@
/*
* 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 { GraphQLBrowseApi, GraphQLEndpoint } from './types';
// Helper for generic http endpoints
export type EndpointConfig = {
id: string;
title: string;
// Endpoint URL
url: string;
// only supports POST right now
method?: 'POST';
// Defaults to setting Content-Type to application/json
headers?: { [name in string]: string };
};
export class GraphQLEndpoints implements GraphQLBrowseApi {
// Create a support
static create(config: EndpointConfig): GraphQLEndpoint {
const { id, title, url, method = 'POST' } = config;
return {
id,
title,
fetcher: async (params: any) => {
const body = JSON.stringify(params);
const headers = {
'Content-Type': 'application/json',
...config.headers,
};
const res = await fetch(url, {
method,
headers,
body,
});
return res.json();
},
};
}
static from(endpoints: GraphQLEndpoint[]) {
return new GraphQLEndpoints(endpoints);
}
private constructor(private readonly endpoints: GraphQLEndpoint[]) {}
async getEndpoints() {
return this.endpoints;
}
}
+18
View File
@@ -0,0 +1,18 @@
/*
* 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 * from './GraphQLEndpoints';
export * from './types';
+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 '@backstage/core';
export type GraphQLEndpoint = {
// Will be used as unique key for storing history and query data
id: string;
// Displayed to the user to identify the source.
title: string;
// Method used send a GraphQL query.
// The body parameter is equivalent to the POST body to be JSON-serialized, and the
// return value should be the equivalent of a parsed JSON response from that POST.
fetcher: (body: any) => Promise<any>;
};
export type GraphQLBrowseApi = {
getEndpoints(): Promise<GraphQLEndpoint[]>;
};
export const graphQlBrowseApiRef = new ApiRef<GraphQLBrowseApi>({
id: 'plugin.graphiql.browse',
description: 'Used to supply GraphQL endpoints for browsing',
});
@@ -0,0 +1,143 @@
/*
* 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 { StorageBucket } from './StorageBucket';
describe('StorageBucket', () => {
it('should forbid access to unknown keys', () => {
const bucket = StorageBucket.forStorage(localStorage, 'hello');
expect(() => {
bucket['dunno-this-one'] = 'nope';
}).toThrow('Direct property access is not allowed for StorageBuckets');
expect(() => {
return bucket['dunno-this-one'];
}).toThrow('Direct property access is not allowed for StorageBuckets');
});
it('should not implement all methods', () => {
const bucket = StorageBucket.forLocalStorage('hello');
expect(() => bucket.length).toThrow('Method not implemented.');
expect(() => bucket.key()).toThrow('Method not implemented.');
});
describe('with mocked underlying storage', () => {
const mockStorage = {
getItem: jest.fn(),
setItem: jest.fn(),
removeItem: jest.fn(),
};
const bucket = StorageBucket.forStorage(
(mockStorage as unknown) as Storage,
'my-bucket',
);
afterEach(() => {
jest.resetAllMocks();
});
it('should set a first item', () => {
bucket.setItem('x', 'a');
expect(mockStorage.getItem).toHaveBeenCalledTimes(1);
expect(mockStorage.getItem).toHaveBeenLastCalledWith('my-bucket');
expect(mockStorage.setItem).toHaveBeenCalledTimes(1);
expect(mockStorage.setItem).toHaveBeenLastCalledWith(
'my-bucket',
JSON.stringify({ x: 'a' }),
);
expect(mockStorage.removeItem).toHaveBeenCalledTimes(0);
});
it('should set a second item', () => {
mockStorage.getItem.mockReturnValueOnce(JSON.stringify({ y: 'b' }));
bucket.setItem('x', 'a');
expect(mockStorage.getItem).toHaveBeenCalledTimes(1);
expect(mockStorage.getItem).toHaveBeenLastCalledWith('my-bucket');
expect(mockStorage.setItem).toHaveBeenCalledTimes(1);
expect(mockStorage.setItem).toHaveBeenLastCalledWith(
'my-bucket',
JSON.stringify({ y: 'b', x: 'a' }),
);
expect(mockStorage.removeItem).toHaveBeenCalledTimes(0);
});
it('should clear the bucket', () => {
bucket.clear();
expect(mockStorage.getItem).toHaveBeenCalledTimes(0);
expect(mockStorage.setItem).toHaveBeenCalledTimes(0);
expect(mockStorage.removeItem).toHaveBeenCalledTimes(1);
expect(mockStorage.removeItem).toHaveBeenLastCalledWith('my-bucket');
});
it('should get an item', () => {
mockStorage.getItem.mockReturnValueOnce(JSON.stringify({ x: 'X' }));
expect(bucket.getItem('x')).toBe('X');
expect(mockStorage.getItem).toHaveBeenCalledTimes(1);
expect(mockStorage.getItem).toHaveBeenLastCalledWith('my-bucket');
expect(mockStorage.setItem).toHaveBeenCalledTimes(0);
expect(mockStorage.removeItem).toHaveBeenCalledTimes(0);
});
it('should remove an item', () => {
mockStorage.getItem.mockReturnValueOnce(
JSON.stringify({ x: 'X', y: 'Y' }),
);
bucket.removeItem('x');
expect(mockStorage.getItem).toHaveBeenCalledTimes(1);
expect(mockStorage.getItem).toHaveBeenLastCalledWith('my-bucket');
expect(mockStorage.setItem).toHaveBeenCalledTimes(1);
expect(mockStorage.setItem).toHaveBeenLastCalledWith(
'my-bucket',
JSON.stringify({ y: 'Y' }),
);
expect(mockStorage.removeItem).toHaveBeenCalledTimes(0);
});
it('should not bother to write when deleting a missing key', () => {
mockStorage.getItem.mockReturnValueOnce(JSON.stringify({ y: 'Y' }));
bucket.removeItem('x');
expect(mockStorage.getItem).toHaveBeenCalledTimes(1);
expect(mockStorage.getItem).toHaveBeenLastCalledWith('my-bucket');
expect(mockStorage.setItem).toHaveBeenCalledTimes(0);
expect(mockStorage.removeItem).toHaveBeenCalledTimes(0);
});
it('should ignore bad data', () => {
mockStorage.getItem.mockReturnValue('derp');
expect(bucket.getItem('x')).toBe(null);
expect(mockStorage.getItem).toHaveBeenCalledTimes(1);
expect(mockStorage.getItem).toHaveBeenLastCalledWith('my-bucket');
expect(mockStorage.setItem).toHaveBeenCalledTimes(0);
expect(mockStorage.removeItem).toHaveBeenCalledTimes(0);
bucket.removeItem('x');
expect(mockStorage.getItem).toHaveBeenCalledTimes(2);
expect(mockStorage.getItem).toHaveBeenLastCalledWith('my-bucket');
expect(mockStorage.setItem).toHaveBeenCalledTimes(0);
expect(mockStorage.removeItem).toHaveBeenCalledTimes(0);
});
});
});
@@ -0,0 +1,102 @@
/*
* 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.
*/
type BucketData = { [key in string]: string };
export class StorageBucket implements Storage {
private static noPropAccessProxyHandler: ProxyHandler<StorageBucket> = {
get(target, prop) {
if (prop in target) {
return target[prop as any];
}
throw new Error(
'Direct property access is not allowed for StorageBuckets',
);
},
set() {
throw new Error(
'Direct property access is not allowed for StorageBuckets',
);
},
};
static forStorage(storage: Storage, bucket: string) {
const storageBucket = new StorageBucket(storage, bucket);
return new Proxy(storageBucket, StorageBucket.noPropAccessProxyHandler);
}
static forLocalStorage(bucket: string): StorageBucket {
return StorageBucket.forStorage(localStorage, bucket);
}
private constructor(
private readonly storage: Storage,
private readonly bucket: string,
) {}
[name: string]: any;
get length(): number {
throw new Error('Method not implemented.');
}
clear(): void {
this.storage.removeItem(this.bucket);
}
getItem(key: string): string | null {
return this.read()?.[key] ?? null;
}
key(): never {
throw new Error('Method not implemented.');
}
removeItem(key: string): void {
const data = this.read();
if (!data) {
return;
}
if (key in data) {
delete data[key];
this.write(data);
}
}
setItem(key: string, value: string): void {
const data = this.read() ?? {};
data[key] = value;
this.write(data);
}
private read(): BucketData | undefined {
const bucketValue = this.storage.getItem(this.bucket);
if (!bucketValue) {
return undefined;
}
try {
return JSON.parse(bucketValue);
} catch {
return undefined;
}
}
private write(data: BucketData) {
this.storage.setItem(this.bucket, JSON.stringify(data));
}
}
+17
View File
@@ -0,0 +1,17 @@
/*
* 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 * from './StorageBucket';
+23
View File
@@ -0,0 +1,23 @@
/*
* 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 { plugin } from './plugin';
describe('graphiql', () => {
it('should export plugin', () => {
expect(plugin).toBeDefined();
});
});
+25
View File
@@ -0,0 +1,25 @@
/*
* 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 { createPlugin } from '@backstage/core';
import { GraphiQLPage } from './components';
export const plugin = createPlugin({
id: 'graphiql',
register({ router }) {
router.registerRoute('/graphiql', GraphiQLPage);
},
});
+18
View File
@@ -0,0 +1,18 @@
/*
* 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 '@testing-library/jest-dom/extend-expect';
require('jest-fetch-mock').enableMocks();
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.json",
"include": ["src", "dev"],
"compilerOptions": {
"baseUrl": "src"
}
}