Merge branch 'master' of https://github.com/spotify/backstage
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
FROM node:12 AS build
|
||||
|
||||
RUN mkdir /app
|
||||
COPY . /app
|
||||
WORKDIR /app
|
||||
|
||||
RUN yarn install
|
||||
RUN yarn workspace example-app build
|
||||
|
||||
# Contruct backstage-frontend image
|
||||
FROM nginx:mainline
|
||||
|
||||
RUN apt-get update && apt-get -y install jq && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy from build stage
|
||||
COPY --from=build /app/packages/app/dist /usr/share/nginx/html
|
||||
|
||||
COPY docker/default.conf.template /etc/nginx/conf.d/default.conf.template
|
||||
COPY docker/run.sh /usr/local/bin/run.sh
|
||||
|
||||
CMD run.sh
|
||||
|
||||
ENV PORT 80
|
||||
@@ -0,0 +1,19 @@
|
||||
# Standalone Dockerfile for frontend
|
||||
|
||||
This directory contains the resources which will help you build backstage without any requirements
|
||||
other than docker itself. It uses a multi-stage Dockerfile to build and ship backstage.
|
||||
|
||||
## Usage
|
||||
|
||||
You can simply run the following command to build backstage.
|
||||
|
||||
```
|
||||
# Make sure you are in the root directory of backstage then run
|
||||
docker build -t backstage-frontend -f ./contrib/docker/multi-stage-frontend/Dockerfile .
|
||||
```
|
||||
|
||||
After a successful build, You can simply run backstage frontend with the following command.
|
||||
|
||||
```
|
||||
docker run -it --rm -p 3080:80 backstage-frontend
|
||||
```
|
||||
@@ -41,7 +41,7 @@ From the terminal:
|
||||
ℹ 「wds」: Project is running at http://localhost:3000/
|
||||
```
|
||||
|
||||
Once the app compiles, a browser window should have popped with your stand alone
|
||||
Once the app compiles, a browser window should have popped with your stand-alone
|
||||
application loaded at `localhost:3000`. This could take a couple minutes.
|
||||
|
||||
```zsh
|
||||
|
||||
@@ -471,8 +471,8 @@ class Index extends React.Component {
|
||||
Cloud Native Computing Foundation
|
||||
</a>{' '}
|
||||
sandbox project
|
||||
<div className="cncf-logo" />
|
||||
</Block.SmallTitle>
|
||||
<div className="cncf-logo" />
|
||||
</Block.Container>
|
||||
</Block>
|
||||
</main>
|
||||
|
||||
@@ -1072,6 +1072,7 @@ code {
|
||||
}
|
||||
|
||||
.cncf-block {
|
||||
padding-top: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -1080,4 +1081,5 @@ code {
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
margin-bottom: 40px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@
|
||||
"docgen": "lerna run docgen",
|
||||
"docker-build:app": "yarn workspace example-app build && docker build . -t spotify/backstage",
|
||||
"docker-build": "yarn tsc && yarn workspace example-backend build-image",
|
||||
"create-plugin": "backstage-cli create-plugin",
|
||||
"create-plugin": "backstage-cli create-plugin --scope backstage --no-private",
|
||||
"remove-plugin": "backstage-cli remove-plugin",
|
||||
"release": "if [ \"$(git symbolic-ref --short HEAD)\" = master ]; then echo \"don't try to release master\"; exit 1; else lerna version --no-push --force-publish; fi",
|
||||
"prettier:check": "prettier --check .",
|
||||
|
||||
@@ -64,7 +64,6 @@ async function main() {
|
||||
const configs = await loadBackendConfig();
|
||||
const configReader = ConfigReader.fromConfigs(configs);
|
||||
const createEnv = makeCreateEnv(configs);
|
||||
|
||||
const healthcheckEnv = useHotMemoize(module, () => createEnv('healthcheck'));
|
||||
const catalogEnv = useHotMemoize(module, () => createEnv('catalog'));
|
||||
const scaffolderEnv = useHotMemoize(module, () => createEnv('scaffolder'));
|
||||
|
||||
@@ -30,10 +30,14 @@
|
||||
*/
|
||||
|
||||
import { createRouter } from '@backstage/plugin-graphql-backend';
|
||||
import type { PluginEnvironment } from '../types';
|
||||
import { PluginEnvironment } from '../types';
|
||||
|
||||
export default async function createPlugin({ logger }: PluginEnvironment) {
|
||||
export default async function createPlugin({
|
||||
logger,
|
||||
config,
|
||||
}: PluginEnvironment) {
|
||||
return await createRouter({
|
||||
logger,
|
||||
config,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import inquirer, { Answers, Question } from 'inquirer';
|
||||
import { exec as execCb } from 'child_process';
|
||||
import { resolve as resolvePath } from 'path';
|
||||
import os from 'os';
|
||||
import { Command } from 'commander';
|
||||
import {
|
||||
parseOwnerIds,
|
||||
addCodeownersEntry,
|
||||
@@ -32,12 +33,12 @@ import { version as backstageVersion } from '../../lib/version';
|
||||
|
||||
const exec = promisify(execCb);
|
||||
|
||||
async function checkExists(rootDir: string, id: string) {
|
||||
await Task.forItem('checking', id, async () => {
|
||||
const destination = resolvePath(rootDir, 'plugins', id);
|
||||
|
||||
async function checkExists(destination: string) {
|
||||
await Task.forItem('checking', destination, async () => {
|
||||
if (await fs.pathExists(destination)) {
|
||||
const existing = chalk.cyan(destination.replace(`${rootDir}/`, ''));
|
||||
const existing = chalk.cyan(
|
||||
destination.replace(`${paths.targetRoot}/`, ''),
|
||||
);
|
||||
throw new Error(
|
||||
`A plugin with the same name already exists: ${existing}\nPlease try again with a different plugin ID`,
|
||||
);
|
||||
@@ -86,10 +87,9 @@ export const addExportStatement = async (
|
||||
|
||||
export async function addPluginDependencyToApp(
|
||||
rootDir: string,
|
||||
pluginName: string,
|
||||
pluginPackage: string,
|
||||
versionStr: string,
|
||||
) {
|
||||
const pluginPackage = `@backstage/plugin-${pluginName}`;
|
||||
const packageFilePath = 'packages/app/package.json';
|
||||
const packageFile = resolvePath(rootDir, packageFilePath);
|
||||
|
||||
@@ -116,8 +116,11 @@ export async function addPluginDependencyToApp(
|
||||
});
|
||||
}
|
||||
|
||||
export async function addPluginToApp(rootDir: string, pluginName: string) {
|
||||
const pluginPackage = `@backstage/plugin-${pluginName}`;
|
||||
export async function addPluginToApp(
|
||||
rootDir: string,
|
||||
pluginName: string,
|
||||
pluginPackage: string,
|
||||
) {
|
||||
const pluginNameCapitalized = pluginName
|
||||
.split('-')
|
||||
.map(name => capitalize(name))
|
||||
@@ -175,7 +178,7 @@ export async function movePlugin(
|
||||
});
|
||||
}
|
||||
|
||||
export default async () => {
|
||||
export default async (cmd: Command) => {
|
||||
const codeownersPath = await getCodeownersFilePath(paths.targetRoot);
|
||||
|
||||
const questions: Question[] = [
|
||||
@@ -221,20 +224,29 @@ export default async () => {
|
||||
}
|
||||
|
||||
const answers: Answers = await inquirer.prompt(questions);
|
||||
|
||||
const name = cmd.scope
|
||||
? `@${cmd.scope.replace(/^@/, '')}/plugin-${answers.id}`
|
||||
: `plugin-${answers.id}`;
|
||||
const npmRegistry = cmd.npmRegistry && cmd.scope ? cmd.npmRegistry : '';
|
||||
const privatePackage = cmd.private === false ? false : true;
|
||||
const isMonoRepo = await fs.pathExists(paths.resolveTargetRoot('lerna.json'));
|
||||
const appPackage = paths.resolveTargetRoot('packages/app');
|
||||
const templateDir = paths.resolveOwn('templates/default-plugin');
|
||||
const tempDir = resolvePath(os.tmpdir(), answers.id);
|
||||
const pluginDir = paths.resolveTargetRoot('plugins', answers.id);
|
||||
const pluginDir = isMonoRepo
|
||||
? paths.resolveTargetRoot('plugins', answers.id)
|
||||
: paths.resolveTargetRoot(answers.id);
|
||||
const ownerIds = parseOwnerIds(answers.owner);
|
||||
const { version } = await fs.readJson(paths.resolveTargetRoot('lerna.json'));
|
||||
const { version } = isMonoRepo
|
||||
? await fs.readJson(paths.resolveTargetRoot('lerna.json'))
|
||||
: { version: '0.1.0' };
|
||||
|
||||
Task.log();
|
||||
Task.log('Creating the plugin...');
|
||||
|
||||
try {
|
||||
Task.section('Checking if the plugin ID is available');
|
||||
await checkExists(paths.targetRoot, answers.id);
|
||||
await checkExists(pluginDir);
|
||||
|
||||
Task.section('Creating a temporary plugin directory');
|
||||
await createTemporaryPluginFolder(tempDir);
|
||||
@@ -244,6 +256,9 @@ export default async () => {
|
||||
...answers,
|
||||
version,
|
||||
backstageVersion,
|
||||
name,
|
||||
privatePackage,
|
||||
npmRegistry,
|
||||
});
|
||||
|
||||
Task.section('Moving to final location');
|
||||
@@ -254,10 +269,10 @@ export default async () => {
|
||||
|
||||
if (await fs.pathExists(appPackage)) {
|
||||
Task.section('Adding plugin as dependency in app');
|
||||
await addPluginDependencyToApp(paths.targetRoot, answers.id, version);
|
||||
await addPluginDependencyToApp(paths.targetRoot, name, version);
|
||||
|
||||
Task.section('Import plugin in app');
|
||||
await addPluginToApp(paths.targetRoot, answers.id);
|
||||
await addPluginToApp(paths.targetRoot, answers.id, name);
|
||||
}
|
||||
|
||||
if (ownerIds && ownerIds.length) {
|
||||
@@ -269,11 +284,7 @@ export default async () => {
|
||||
}
|
||||
|
||||
Task.log();
|
||||
Task.log(
|
||||
`🥇 Successfully created ${chalk.cyan(
|
||||
`@backstage/plugin-${answers.id}`,
|
||||
)}`,
|
||||
);
|
||||
Task.log(`🥇 Successfully created ${chalk.cyan(`${name}`)}`);
|
||||
Task.log();
|
||||
Task.exit();
|
||||
} catch (error) {
|
||||
|
||||
@@ -62,6 +62,9 @@ export function registerCommands(program: CommanderStatic) {
|
||||
program
|
||||
.command('create-plugin')
|
||||
.description('Creates a new plugin in the current repository')
|
||||
.option('--scope <scope>', 'NPM scope')
|
||||
.option('--npm-registry <URL>', 'NPM registry URL')
|
||||
.option('--no-private', 'Public NPM Package')
|
||||
.action(
|
||||
lazy(() => import('./create-plugin/createPlugin').then(m => m.default)),
|
||||
);
|
||||
|
||||
@@ -30,6 +30,9 @@ import { version as backstageVersion } from '../../lib/version';
|
||||
export type PluginData = {
|
||||
id: string;
|
||||
name: string;
|
||||
privatePackage: string;
|
||||
version: string;
|
||||
npmRegistry: string;
|
||||
};
|
||||
|
||||
const fileHandlers = [
|
||||
@@ -62,11 +65,8 @@ export default async (cmd: Command) => {
|
||||
promptFunc = yesPromptFunc;
|
||||
}
|
||||
|
||||
const { version } = await fs.readJson(paths.resolveTargetRoot('lerna.json'));
|
||||
|
||||
const data = await readPluginData();
|
||||
const templateFiles = await diffTemplateFiles('default-plugin', {
|
||||
version,
|
||||
backstageVersion,
|
||||
...data,
|
||||
});
|
||||
@@ -77,9 +77,19 @@ export default async (cmd: Command) => {
|
||||
// Reads templating data from the existing plugin
|
||||
async function readPluginData(): Promise<PluginData> {
|
||||
let name: string;
|
||||
let privatePackage: string;
|
||||
let version: string;
|
||||
let npmRegistry: string;
|
||||
try {
|
||||
const pkg = require(paths.resolveTarget('package.json'));
|
||||
name = pkg.name;
|
||||
privatePackage = pkg.private;
|
||||
version = pkg.version;
|
||||
const scope = name.split('/')[0];
|
||||
if (`${scope}:registry` in pkg.publishConfig) {
|
||||
const registryURL = pkg.publishConfig[`${scope}:registry`];
|
||||
npmRegistry = `"${scope}:registry" : "${registryURL}"`;
|
||||
} else npmRegistry = '';
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to read target package, ${error}`);
|
||||
}
|
||||
@@ -96,5 +106,5 @@ async function readPluginData(): Promise<PluginData> {
|
||||
|
||||
const id = pluginIdMatch[1];
|
||||
|
||||
return { id, name };
|
||||
return { id, name, privatePackage, version, npmRegistry };
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
{
|
||||
"name": "@backstage/plugin-{{id}}",
|
||||
"name": "{{name}}",
|
||||
"version": "{{version}}",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
"private": true,
|
||||
{{#if privatePackage}} "private": {{privatePackage}},
|
||||
{{/if}}
|
||||
"publishConfig": {
|
||||
{{#if npmRegistry}} "registry": "{{npmRegistry}}",
|
||||
{{/if}}
|
||||
"access": "public",
|
||||
"main": "dist/index.esm.js",
|
||||
"types": "dist/index.d.ts"
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/config": "^0.1.1-alpha.22",
|
||||
"@backstage/core-api": "^0.1.1-alpha.22",
|
||||
"@backstage/core-api": "0.1.1-alpha.22",
|
||||
"@backstage/theme": "^0.1.1-alpha.22",
|
||||
"@material-ui/core": "^4.11.0",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
|
||||
+1
-2
@@ -1,4 +1,3 @@
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
@@ -31,4 +30,4 @@ dist-types
|
||||
site
|
||||
|
||||
# Local configuration files
|
||||
*.local.yaml
|
||||
*.local.yaml
|
||||
@@ -16,7 +16,7 @@
|
||||
"test:all": "lerna run test -- --coverage",
|
||||
"lint": "lerna run lint --since origin/master --",
|
||||
"lint:all": "lerna run lint --",
|
||||
"create-plugin": "backstage-cli create-plugin",
|
||||
"create-plugin": "backstage-cli create-plugin --scope backstage --no-private",
|
||||
"remove-plugin": "backstage-cli remove-plugin"
|
||||
},
|
||||
"workspaces": {
|
||||
|
||||
@@ -81,7 +81,11 @@ async function buildDistWorkspace(workspaceName: string, rootDir: string) {
|
||||
const path = paths.resolveOwnRoot(pkgJsonPath);
|
||||
const pkgTemplate = await fs.readFile(path, 'utf8');
|
||||
const { dependencies = {}, devDependencies = {} } = JSON.parse(
|
||||
handlebars.compile(pkgTemplate)({ version: '0.0.0' }),
|
||||
handlebars.compile(pkgTemplate)({
|
||||
version: '0.0.0',
|
||||
privatePackage: true,
|
||||
scopeName: '@backstage',
|
||||
}),
|
||||
);
|
||||
|
||||
Array<string>()
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
extends: [require.resolve('@backstage/cli/config/eslint.backend')],
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
# Catalog GraphQL Plugin
|
||||
|
||||
## Getting Started
|
||||
|
||||
This is the Catalog GraphQL plugin.
|
||||
|
||||
It provides the `catalog` part of the GraphQL schema.
|
||||
|
||||
To register it with the GraphQL backend, be sure to follow the [Getting Started](../graphql/README.md#getting-started) guide of the GraphQL plugin.
|
||||
|
||||
<!-- TODO: Need to make the GraphQL plugin compatible with non forked repos >
|
||||
@@ -0,0 +1,15 @@
|
||||
overwrite: true
|
||||
generates:
|
||||
./src/graphql/types.ts:
|
||||
schema: ./src/schema.js
|
||||
plugins:
|
||||
- typescript
|
||||
- typescript-resolvers
|
||||
hooks:
|
||||
afterOneFileWrite:
|
||||
- eslint --fix
|
||||
config:
|
||||
contextType: '@graphql-modules/core#ModuleContext'
|
||||
allowParentTypeOverride: true
|
||||
useIndexSignature: true
|
||||
defaultMapper: Partial<{T}>
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "@backstage/plugin-catalog-graphql",
|
||||
"version": "0.1.1-alpha.22",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"main": "dist/index.cjs.js",
|
||||
"types": "dist/index.d.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "backstage-cli backend:dev",
|
||||
"build": "backstage-cli backend:build",
|
||||
"lint": "backstage-cli lint",
|
||||
"test": "backstage-cli test",
|
||||
"prepack": "backstage-cli prepack",
|
||||
"postpack": "backstage-cli postpack",
|
||||
"generate:types": "graphql-codegen",
|
||||
"clean": "backstage-cli clean"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/catalog-model": "^0.1.1-alpha.22",
|
||||
"@backstage/backend-common": "^0.1.1-alpha.22",
|
||||
"@backstage/config": "^0.1.1-alpha.22",
|
||||
"@graphql-modules/core": "^0.7.17",
|
||||
"apollo-server": "^2.16.1",
|
||||
"graphql": "^15.3.0",
|
||||
"graphql-tag": "^2.11.0",
|
||||
"graphql-type-json": "^0.3.2",
|
||||
"node-fetch": "^2.6.0",
|
||||
"winston": "^3.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.1.1-alpha.22",
|
||||
"@types/express": "^4.17.7",
|
||||
"@types/supertest": "^2.0.8",
|
||||
"@graphql-codegen/cli": "^1.17.7",
|
||||
"@graphql-codegen/typescript": "^1.17.7",
|
||||
"@graphql-codegen/typescript-resolvers": "^1.17.7",
|
||||
"ts-node": "^8.10.2",
|
||||
"eslint-plugin-graphql": "^4.0.0",
|
||||
"msw": "^0.19.5",
|
||||
"supertest": "^4.0.2"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
/*
|
||||
* 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 { createModule } from './module';
|
||||
import { execute } from 'graphql';
|
||||
import { rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { ReaderEntity } from '../service/client';
|
||||
import { createLogger } from 'winston';
|
||||
import { gql } from 'apollo-server';
|
||||
|
||||
describe('Catalog Module', () => {
|
||||
const worker = setupServer();
|
||||
const mockCatalogBaseUrl = 'http://im.mock';
|
||||
const mockConfig = ConfigReader.fromConfigs([
|
||||
{
|
||||
context: '',
|
||||
data: {
|
||||
backend: {
|
||||
baseUrl: mockCatalogBaseUrl,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
beforeAll(() => worker.listen());
|
||||
afterAll(() => worker.close());
|
||||
|
||||
afterEach(() => worker.resetHandlers());
|
||||
|
||||
describe('Default Entity', () => {
|
||||
beforeEach(() => {
|
||||
const mockResponse: ReaderEntity[] = [
|
||||
{
|
||||
apiVersion: 'something',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
annotations: {},
|
||||
etag: '123',
|
||||
generation: 1,
|
||||
labels: {},
|
||||
name: 'Ben',
|
||||
namespace: 'Blames',
|
||||
uid: '123',
|
||||
},
|
||||
spec: {
|
||||
type: 'thing',
|
||||
lifecycle: 'something',
|
||||
owner: 'auser',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
worker.use(
|
||||
rest.get(`${mockCatalogBaseUrl}/catalog/entities`, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockResponse)),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should call the catalog client when requesting entities', async () => {
|
||||
const { schema } = await createModule({
|
||||
config: mockConfig,
|
||||
logger: createLogger(),
|
||||
});
|
||||
|
||||
const result = await execute({
|
||||
schema,
|
||||
document: gql`
|
||||
query {
|
||||
catalog {
|
||||
list {
|
||||
kind
|
||||
apiVersion
|
||||
metadata {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
const [catalogItem] = result.data?.catalog.list;
|
||||
expect(catalogItem.kind).toBe('Component');
|
||||
expect(catalogItem.apiVersion).toBe('something');
|
||||
expect(catalogItem.metadata.name).toBe('Ben');
|
||||
});
|
||||
|
||||
it('Defaults to empty annotations when none are provided', async () => {
|
||||
const mockResponse: ReaderEntity[] = [
|
||||
{
|
||||
apiVersion: 'something',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
annotations: null as any,
|
||||
etag: '123',
|
||||
generation: 1,
|
||||
labels: {},
|
||||
name: 'Ben',
|
||||
namespace: 'Blames',
|
||||
uid: '123',
|
||||
},
|
||||
spec: {
|
||||
type: 'thing',
|
||||
lifecycle: 'something',
|
||||
owner: 'auser',
|
||||
},
|
||||
},
|
||||
];
|
||||
worker.use(
|
||||
rest.get(`${mockCatalogBaseUrl}/catalog/entities`, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
const { schema } = await createModule({
|
||||
config: mockConfig,
|
||||
logger: createLogger(),
|
||||
});
|
||||
|
||||
const result = await execute({
|
||||
schema,
|
||||
document: gql`
|
||||
query {
|
||||
catalog {
|
||||
list {
|
||||
metadata {
|
||||
annotations
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
const [catalogItem] = result.data?.catalog.list;
|
||||
expect(catalogItem.metadata.annotations).toEqual({});
|
||||
});
|
||||
|
||||
it('Defaults to empty labels when none are provided', async () => {
|
||||
const mockResponse: ReaderEntity[] = [
|
||||
{
|
||||
apiVersion: 'something',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
annotations: {},
|
||||
etag: '123',
|
||||
generation: 1,
|
||||
labels: null as any,
|
||||
name: 'Ben',
|
||||
namespace: 'Blames',
|
||||
uid: '123',
|
||||
},
|
||||
spec: {
|
||||
type: 'thing',
|
||||
lifecycle: 'something',
|
||||
owner: 'auser',
|
||||
},
|
||||
},
|
||||
];
|
||||
worker.use(
|
||||
rest.get(`${mockCatalogBaseUrl}/catalog/entities`, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
const { schema } = await createModule({
|
||||
config: mockConfig,
|
||||
logger: createLogger(),
|
||||
});
|
||||
|
||||
const result = await execute({
|
||||
schema,
|
||||
document: gql`
|
||||
query {
|
||||
catalog {
|
||||
list {
|
||||
metadata {
|
||||
labels
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
const [catalogItem] = result.data?.catalog.list;
|
||||
expect(catalogItem.metadata.labels).toEqual({});
|
||||
});
|
||||
it('Returns the correct record from the annotation dictionary', async () => {
|
||||
const mockResponse: ReaderEntity[] = [
|
||||
{
|
||||
apiVersion: 'something',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
annotations: { lob: 'bloben' },
|
||||
etag: '123',
|
||||
generation: 1,
|
||||
labels: {},
|
||||
name: 'Ben',
|
||||
namespace: 'Blames',
|
||||
uid: '123',
|
||||
},
|
||||
spec: {
|
||||
type: 'thing',
|
||||
lifecycle: 'something',
|
||||
owner: 'auser',
|
||||
},
|
||||
},
|
||||
];
|
||||
worker.use(
|
||||
rest.get(`${mockCatalogBaseUrl}/catalog/entities`, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
const { schema } = await createModule({
|
||||
config: mockConfig,
|
||||
logger: createLogger(),
|
||||
});
|
||||
|
||||
const result = await execute({
|
||||
schema,
|
||||
document: gql`
|
||||
query {
|
||||
catalog {
|
||||
list {
|
||||
metadata {
|
||||
test: annotation(name: "lob")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
const [catalogItem] = result.data?.catalog.list;
|
||||
expect(catalogItem.metadata.test).toEqual('bloben');
|
||||
});
|
||||
it('Returns the correct record from the labels dictionary', async () => {
|
||||
const mockResponse: ReaderEntity[] = [
|
||||
{
|
||||
apiVersion: 'something',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
annotations: {},
|
||||
etag: '123',
|
||||
generation: 1,
|
||||
labels: { lob2: 'bloben' },
|
||||
name: 'Ben',
|
||||
namespace: 'Blames',
|
||||
uid: '123',
|
||||
},
|
||||
spec: {
|
||||
type: 'thing',
|
||||
lifecycle: 'something',
|
||||
owner: 'auser',
|
||||
},
|
||||
},
|
||||
];
|
||||
worker.use(
|
||||
rest.get(`${mockCatalogBaseUrl}/catalog/entities`, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
const { schema } = await createModule({
|
||||
config: mockConfig,
|
||||
logger: createLogger(),
|
||||
});
|
||||
|
||||
const result = await execute({
|
||||
schema,
|
||||
document: gql`
|
||||
query {
|
||||
catalog {
|
||||
list {
|
||||
metadata {
|
||||
test: label(name: "lob2")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
const [catalogItem] = result.data?.catalog.list;
|
||||
expect(catalogItem.metadata.test).toEqual('bloben');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* 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 { Logger } from 'winston';
|
||||
import { makeExecutableSchema } from 'apollo-server';
|
||||
import { GraphQLModule } from '@graphql-modules/core';
|
||||
import { Resolvers, CatalogQuery } from './types';
|
||||
import { Config } from '@backstage/config';
|
||||
import { CatalogClient } from '../service/client';
|
||||
import GraphQLJSON, { GraphQLJSONObject } from 'graphql-type-json';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
|
||||
export interface ModuleOptions {
|
||||
logger: Logger;
|
||||
config: Config;
|
||||
}
|
||||
|
||||
export async function createModule(
|
||||
options: ModuleOptions,
|
||||
): Promise<GraphQLModule> {
|
||||
const { default: typeDefs } = require('../schema');
|
||||
|
||||
const catalogClient = new CatalogClient(
|
||||
options.config.getString('backend.baseUrl'),
|
||||
);
|
||||
|
||||
const resolvers: Resolvers = {
|
||||
JSON: GraphQLJSON,
|
||||
JSONObject: GraphQLJSONObject,
|
||||
DefaultEntitySpec: {
|
||||
raw: rootValue => {
|
||||
const { entity } = rootValue as { entity: Entity };
|
||||
return entity.spec ?? null;
|
||||
},
|
||||
},
|
||||
Query: {
|
||||
catalog: () => ({} as CatalogQuery),
|
||||
},
|
||||
CatalogQuery: {
|
||||
list: async () => {
|
||||
return await catalogClient.list();
|
||||
},
|
||||
},
|
||||
CatalogEntity: {
|
||||
metadata: entity => ({ ...entity.metadata!, entity }),
|
||||
spec: entity => ({ ...entity.spec!, entity }),
|
||||
},
|
||||
EntityMetadata: {
|
||||
__resolveType: rootValue => {
|
||||
const {
|
||||
entity: { kind },
|
||||
} = rootValue as { entity: Entity };
|
||||
switch (kind) {
|
||||
case 'Component':
|
||||
return 'ComponentMetadata';
|
||||
case 'Template':
|
||||
return 'TemplateMetadata';
|
||||
default:
|
||||
return 'DefaultEntityMetadata';
|
||||
}
|
||||
},
|
||||
annotation: (e, { name }) => e.annotations?.[name] ?? null,
|
||||
labels: e => e.labels ?? {},
|
||||
annotations: e => e.annotations ?? {},
|
||||
label: (e, { name }) => e.labels?.[name] ?? null,
|
||||
},
|
||||
EntitySpec: {
|
||||
__resolveType: rootValue => {
|
||||
const {
|
||||
entity: { kind },
|
||||
} = rootValue as { entity: Entity };
|
||||
|
||||
switch (kind) {
|
||||
case 'Component':
|
||||
return 'ComponentEntitySpec';
|
||||
case 'Template':
|
||||
return 'TemplateEntitySpec';
|
||||
default:
|
||||
return 'DefaultEntitySpec';
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const schema = makeExecutableSchema({
|
||||
typeDefs,
|
||||
resolvers,
|
||||
inheritResolversFromInterfaces: true,
|
||||
});
|
||||
|
||||
const module = new GraphQLModule({
|
||||
extraSchemas: [schema],
|
||||
logger: options.logger as any,
|
||||
});
|
||||
|
||||
return module;
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
/*
|
||||
* 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 {
|
||||
GraphQLResolveInfo,
|
||||
GraphQLScalarType,
|
||||
GraphQLScalarTypeConfig,
|
||||
} from 'graphql';
|
||||
import { ModuleContext } from '@graphql-modules/core';
|
||||
|
||||
export type Maybe<T> = T | null;
|
||||
export type Exact<T extends { [key: string]: unknown }> = {
|
||||
[K in keyof T]: T[K];
|
||||
};
|
||||
export type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
|
||||
export type RequireFields<T, K extends keyof T> = {
|
||||
[X in Exclude<keyof T, K>]?: T[X];
|
||||
} &
|
||||
{ [P in K]-?: NonNullable<T[P]> };
|
||||
/** All built-in and custom scalars, mapped to their actual values */
|
||||
export type Scalars = {
|
||||
ID: string;
|
||||
String: string;
|
||||
Boolean: boolean;
|
||||
Int: number;
|
||||
Float: number;
|
||||
JSON: any;
|
||||
JSONObject: any;
|
||||
};
|
||||
|
||||
export type EntityMetadata = {
|
||||
name: Scalars['String'];
|
||||
annotations: Scalars['JSONObject'];
|
||||
annotation?: Maybe<Scalars['JSON']>;
|
||||
labels: Scalars['JSONObject'];
|
||||
label?: Maybe<Scalars['JSON']>;
|
||||
uid: Scalars['String'];
|
||||
etag: Scalars['String'];
|
||||
generation: Scalars['Int'];
|
||||
};
|
||||
|
||||
export type EntityMetadataAnnotationArgs = {
|
||||
name: Scalars['String'];
|
||||
};
|
||||
|
||||
export type EntityMetadataLabelArgs = {
|
||||
name: Scalars['String'];
|
||||
};
|
||||
|
||||
export type DefaultEntityMetadata = EntityMetadata & {
|
||||
__typename?: 'DefaultEntityMetadata';
|
||||
name: Scalars['String'];
|
||||
annotations: Scalars['JSONObject'];
|
||||
annotation?: Maybe<Scalars['JSON']>;
|
||||
labels: Scalars['JSONObject'];
|
||||
label?: Maybe<Scalars['JSON']>;
|
||||
uid: Scalars['String'];
|
||||
etag: Scalars['String'];
|
||||
generation: Scalars['Int'];
|
||||
};
|
||||
|
||||
export type DefaultEntityMetadataAnnotationArgs = {
|
||||
name: Scalars['String'];
|
||||
};
|
||||
|
||||
export type DefaultEntityMetadataLabelArgs = {
|
||||
name: Scalars['String'];
|
||||
};
|
||||
|
||||
export type ComponentMetadata = EntityMetadata & {
|
||||
__typename?: 'ComponentMetadata';
|
||||
name: Scalars['String'];
|
||||
annotations: Scalars['JSONObject'];
|
||||
annotation?: Maybe<Scalars['JSON']>;
|
||||
labels: Scalars['JSONObject'];
|
||||
label?: Maybe<Scalars['JSON']>;
|
||||
uid: Scalars['String'];
|
||||
etag: Scalars['String'];
|
||||
generation: Scalars['Int'];
|
||||
relationships?: Maybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
export type ComponentMetadataAnnotationArgs = {
|
||||
name: Scalars['String'];
|
||||
};
|
||||
|
||||
export type ComponentMetadataLabelArgs = {
|
||||
name: Scalars['String'];
|
||||
};
|
||||
|
||||
export type TemplateMetadata = EntityMetadata & {
|
||||
__typename?: 'TemplateMetadata';
|
||||
name: Scalars['String'];
|
||||
annotations: Scalars['JSONObject'];
|
||||
annotation?: Maybe<Scalars['JSON']>;
|
||||
labels: Scalars['JSONObject'];
|
||||
label?: Maybe<Scalars['JSON']>;
|
||||
uid: Scalars['String'];
|
||||
etag: Scalars['String'];
|
||||
generation: Scalars['Int'];
|
||||
updatedBy?: Maybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
export type TemplateMetadataAnnotationArgs = {
|
||||
name: Scalars['String'];
|
||||
};
|
||||
|
||||
export type TemplateMetadataLabelArgs = {
|
||||
name: Scalars['String'];
|
||||
};
|
||||
|
||||
export type TemplateEntitySpec = {
|
||||
__typename?: 'TemplateEntitySpec';
|
||||
type: Scalars['String'];
|
||||
path?: Maybe<Scalars['String']>;
|
||||
schema: Scalars['JSONObject'];
|
||||
templater: Scalars['String'];
|
||||
};
|
||||
|
||||
export type ComponentEntitySpec = {
|
||||
__typename?: 'ComponentEntitySpec';
|
||||
title: Scalars['String'];
|
||||
lifecycle: Scalars['String'];
|
||||
owner: Scalars['String'];
|
||||
};
|
||||
|
||||
export type LocationEntitySpec = {
|
||||
__typename?: 'LocationEntitySpec';
|
||||
type: Scalars['String'];
|
||||
target?: Maybe<Scalars['String']>;
|
||||
targets: Array<Scalars['String']>;
|
||||
};
|
||||
|
||||
export type DefaultEntitySpec = {
|
||||
__typename?: 'DefaultEntitySpec';
|
||||
raw?: Maybe<Scalars['JSONObject']>;
|
||||
};
|
||||
|
||||
export type EntitySpec =
|
||||
| DefaultEntitySpec
|
||||
| TemplateEntitySpec
|
||||
| LocationEntitySpec
|
||||
| ComponentEntitySpec;
|
||||
|
||||
export type CatalogEntity = {
|
||||
__typename?: 'CatalogEntity';
|
||||
apiVersion: Scalars['String'];
|
||||
kind: Scalars['String'];
|
||||
metadata?: Maybe<EntityMetadata>;
|
||||
spec: EntitySpec;
|
||||
};
|
||||
|
||||
export type CatalogQuery = {
|
||||
__typename?: 'CatalogQuery';
|
||||
list: Array<CatalogEntity>;
|
||||
};
|
||||
|
||||
export type Query = {
|
||||
__typename?: 'Query';
|
||||
catalog: CatalogQuery;
|
||||
};
|
||||
|
||||
export type WithIndex<TObject> = TObject & Record<string, any>;
|
||||
export type ResolversObject<TObject> = WithIndex<TObject>;
|
||||
|
||||
export type ResolverTypeWrapper<T> = Promise<T> | T;
|
||||
|
||||
export type LegacyStitchingResolver<TResult, TParent, TContext, TArgs> = {
|
||||
fragment: string;
|
||||
resolve: ResolverFn<TResult, TParent, TContext, TArgs>;
|
||||
};
|
||||
|
||||
export type NewStitchingResolver<TResult, TParent, TContext, TArgs> = {
|
||||
selectionSet: string;
|
||||
resolve: ResolverFn<TResult, TParent, TContext, TArgs>;
|
||||
};
|
||||
export type StitchingResolver<TResult, TParent, TContext, TArgs> =
|
||||
| LegacyStitchingResolver<TResult, TParent, TContext, TArgs>
|
||||
| NewStitchingResolver<TResult, TParent, TContext, TArgs>;
|
||||
export type Resolver<TResult, TParent = {}, TContext = {}, TArgs = {}> =
|
||||
| ResolverFn<TResult, TParent, TContext, TArgs>
|
||||
| StitchingResolver<TResult, TParent, TContext, TArgs>;
|
||||
|
||||
export type ResolverFn<TResult, TParent, TContext, TArgs> = (
|
||||
parent: TParent,
|
||||
args: TArgs,
|
||||
context: TContext,
|
||||
info: GraphQLResolveInfo,
|
||||
) => Promise<TResult> | TResult;
|
||||
|
||||
export type SubscriptionSubscribeFn<TResult, TParent, TContext, TArgs> = (
|
||||
parent: TParent,
|
||||
args: TArgs,
|
||||
context: TContext,
|
||||
info: GraphQLResolveInfo,
|
||||
) => AsyncIterator<TResult> | Promise<AsyncIterator<TResult>>;
|
||||
|
||||
export type SubscriptionResolveFn<TResult, TParent, TContext, TArgs> = (
|
||||
parent: TParent,
|
||||
args: TArgs,
|
||||
context: TContext,
|
||||
info: GraphQLResolveInfo,
|
||||
) => TResult | Promise<TResult>;
|
||||
|
||||
export interface SubscriptionSubscriberObject<
|
||||
TResult,
|
||||
TKey extends string,
|
||||
TParent,
|
||||
TContext,
|
||||
TArgs
|
||||
> {
|
||||
subscribe: SubscriptionSubscribeFn<
|
||||
{ [key in TKey]: TResult },
|
||||
TParent,
|
||||
TContext,
|
||||
TArgs
|
||||
>;
|
||||
resolve?: SubscriptionResolveFn<
|
||||
TResult,
|
||||
{ [key in TKey]: TResult },
|
||||
TContext,
|
||||
TArgs
|
||||
>;
|
||||
}
|
||||
|
||||
export interface SubscriptionResolverObject<TResult, TParent, TContext, TArgs> {
|
||||
subscribe: SubscriptionSubscribeFn<any, TParent, TContext, TArgs>;
|
||||
resolve: SubscriptionResolveFn<TResult, any, TContext, TArgs>;
|
||||
}
|
||||
|
||||
export type SubscriptionObject<
|
||||
TResult,
|
||||
TKey extends string,
|
||||
TParent,
|
||||
TContext,
|
||||
TArgs
|
||||
> =
|
||||
| SubscriptionSubscriberObject<TResult, TKey, TParent, TContext, TArgs>
|
||||
| SubscriptionResolverObject<TResult, TParent, TContext, TArgs>;
|
||||
|
||||
export type SubscriptionResolver<
|
||||
TResult,
|
||||
TKey extends string,
|
||||
TParent = {},
|
||||
TContext = {},
|
||||
TArgs = {}
|
||||
> =
|
||||
| ((
|
||||
...args: any[]
|
||||
) => SubscriptionObject<TResult, TKey, TParent, TContext, TArgs>)
|
||||
| SubscriptionObject<TResult, TKey, TParent, TContext, TArgs>;
|
||||
|
||||
export type TypeResolveFn<TTypes, TParent = {}, TContext = {}> = (
|
||||
parent: TParent,
|
||||
context: TContext,
|
||||
info: GraphQLResolveInfo,
|
||||
) => Maybe<TTypes> | Promise<Maybe<TTypes>>;
|
||||
|
||||
export type IsTypeOfResolverFn<T = {}> = (
|
||||
obj: T,
|
||||
info: GraphQLResolveInfo,
|
||||
) => boolean | Promise<boolean>;
|
||||
|
||||
export type NextResolverFn<T> = () => Promise<T>;
|
||||
|
||||
export type DirectiveResolverFn<
|
||||
TResult = {},
|
||||
TParent = {},
|
||||
TContext = {},
|
||||
TArgs = {}
|
||||
> = (
|
||||
next: NextResolverFn<TResult>,
|
||||
parent: TParent,
|
||||
args: TArgs,
|
||||
context: TContext,
|
||||
info: GraphQLResolveInfo,
|
||||
) => TResult | Promise<TResult>;
|
||||
|
||||
/** Mapping between all available schema types and the resolvers types */
|
||||
export type ResolversTypes = ResolversObject<{
|
||||
JSON: ResolverTypeWrapper<Partial<Scalars['JSON']>>;
|
||||
JSONObject: ResolverTypeWrapper<Partial<Scalars['JSONObject']>>;
|
||||
EntityMetadata:
|
||||
| ResolversTypes['DefaultEntityMetadata']
|
||||
| ResolversTypes['ComponentMetadata']
|
||||
| ResolversTypes['TemplateMetadata'];
|
||||
String: ResolverTypeWrapper<Partial<Scalars['String']>>;
|
||||
Int: ResolverTypeWrapper<Partial<Scalars['Int']>>;
|
||||
DefaultEntityMetadata: ResolverTypeWrapper<Partial<DefaultEntityMetadata>>;
|
||||
ComponentMetadata: ResolverTypeWrapper<Partial<ComponentMetadata>>;
|
||||
TemplateMetadata: ResolverTypeWrapper<Partial<TemplateMetadata>>;
|
||||
TemplateEntitySpec: ResolverTypeWrapper<Partial<TemplateEntitySpec>>;
|
||||
ComponentEntitySpec: ResolverTypeWrapper<Partial<ComponentEntitySpec>>;
|
||||
LocationEntitySpec: ResolverTypeWrapper<Partial<LocationEntitySpec>>;
|
||||
DefaultEntitySpec: ResolverTypeWrapper<Partial<DefaultEntitySpec>>;
|
||||
EntitySpec: Partial<
|
||||
| ResolversTypes['DefaultEntitySpec']
|
||||
| ResolversTypes['TemplateEntitySpec']
|
||||
| ResolversTypes['LocationEntitySpec']
|
||||
| ResolversTypes['ComponentEntitySpec']
|
||||
>;
|
||||
CatalogEntity: ResolverTypeWrapper<
|
||||
Partial<
|
||||
Omit<CatalogEntity, 'spec'> & { spec: ResolversTypes['EntitySpec'] }
|
||||
>
|
||||
>;
|
||||
CatalogQuery: ResolverTypeWrapper<Partial<CatalogQuery>>;
|
||||
Query: ResolverTypeWrapper<{}>;
|
||||
Boolean: ResolverTypeWrapper<Partial<Scalars['Boolean']>>;
|
||||
}>;
|
||||
|
||||
/** Mapping between all available schema types and the resolvers parents */
|
||||
export type ResolversParentTypes = ResolversObject<{
|
||||
JSON: Partial<Scalars['JSON']>;
|
||||
JSONObject: Partial<Scalars['JSONObject']>;
|
||||
EntityMetadata:
|
||||
| ResolversParentTypes['DefaultEntityMetadata']
|
||||
| ResolversParentTypes['ComponentMetadata']
|
||||
| ResolversParentTypes['TemplateMetadata'];
|
||||
String: Partial<Scalars['String']>;
|
||||
Int: Partial<Scalars['Int']>;
|
||||
DefaultEntityMetadata: Partial<DefaultEntityMetadata>;
|
||||
ComponentMetadata: Partial<ComponentMetadata>;
|
||||
TemplateMetadata: Partial<TemplateMetadata>;
|
||||
TemplateEntitySpec: Partial<TemplateEntitySpec>;
|
||||
ComponentEntitySpec: Partial<ComponentEntitySpec>;
|
||||
LocationEntitySpec: Partial<LocationEntitySpec>;
|
||||
DefaultEntitySpec: Partial<DefaultEntitySpec>;
|
||||
EntitySpec: Partial<
|
||||
| ResolversParentTypes['DefaultEntitySpec']
|
||||
| ResolversParentTypes['TemplateEntitySpec']
|
||||
| ResolversParentTypes['LocationEntitySpec']
|
||||
| ResolversParentTypes['ComponentEntitySpec']
|
||||
>;
|
||||
CatalogEntity: Partial<
|
||||
Omit<CatalogEntity, 'spec'> & { spec: ResolversParentTypes['EntitySpec'] }
|
||||
>;
|
||||
CatalogQuery: Partial<CatalogQuery>;
|
||||
Query: {};
|
||||
Boolean: Partial<Scalars['Boolean']>;
|
||||
}>;
|
||||
|
||||
export interface JsonScalarConfig
|
||||
extends GraphQLScalarTypeConfig<ResolversTypes['JSON'], any> {
|
||||
name: 'JSON';
|
||||
}
|
||||
|
||||
export interface JsonObjectScalarConfig
|
||||
extends GraphQLScalarTypeConfig<ResolversTypes['JSONObject'], any> {
|
||||
name: 'JSONObject';
|
||||
}
|
||||
|
||||
export type EntityMetadataResolvers<
|
||||
ContextType = ModuleContext,
|
||||
ParentType = ResolversParentTypes['EntityMetadata']
|
||||
> = ResolversObject<{
|
||||
__resolveType: TypeResolveFn<
|
||||
'DefaultEntityMetadata' | 'ComponentMetadata' | 'TemplateMetadata',
|
||||
ParentType,
|
||||
ContextType
|
||||
>;
|
||||
name?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
annotations?: Resolver<ResolversTypes['JSONObject'], ParentType, ContextType>;
|
||||
annotation?: Resolver<
|
||||
Maybe<ResolversTypes['JSON']>,
|
||||
ParentType,
|
||||
ContextType,
|
||||
RequireFields<EntityMetadataAnnotationArgs, 'name'>
|
||||
>;
|
||||
labels?: Resolver<ResolversTypes['JSONObject'], ParentType, ContextType>;
|
||||
label?: Resolver<
|
||||
Maybe<ResolversTypes['JSON']>,
|
||||
ParentType,
|
||||
ContextType,
|
||||
RequireFields<EntityMetadataLabelArgs, 'name'>
|
||||
>;
|
||||
uid?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
etag?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
generation?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
|
||||
}>;
|
||||
|
||||
export type DefaultEntityMetadataResolvers<
|
||||
ContextType = ModuleContext,
|
||||
ParentType = ResolversParentTypes['DefaultEntityMetadata']
|
||||
> = ResolversObject<{
|
||||
name?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
annotations?: Resolver<ResolversTypes['JSONObject'], ParentType, ContextType>;
|
||||
annotation?: Resolver<
|
||||
Maybe<ResolversTypes['JSON']>,
|
||||
ParentType,
|
||||
ContextType,
|
||||
RequireFields<DefaultEntityMetadataAnnotationArgs, 'name'>
|
||||
>;
|
||||
labels?: Resolver<ResolversTypes['JSONObject'], ParentType, ContextType>;
|
||||
label?: Resolver<
|
||||
Maybe<ResolversTypes['JSON']>,
|
||||
ParentType,
|
||||
ContextType,
|
||||
RequireFields<DefaultEntityMetadataLabelArgs, 'name'>
|
||||
>;
|
||||
uid?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
etag?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
generation?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType>;
|
||||
}>;
|
||||
|
||||
export type ComponentMetadataResolvers<
|
||||
ContextType = ModuleContext,
|
||||
ParentType = ResolversParentTypes['ComponentMetadata']
|
||||
> = ResolversObject<{
|
||||
name?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
annotations?: Resolver<ResolversTypes['JSONObject'], ParentType, ContextType>;
|
||||
annotation?: Resolver<
|
||||
Maybe<ResolversTypes['JSON']>,
|
||||
ParentType,
|
||||
ContextType,
|
||||
RequireFields<ComponentMetadataAnnotationArgs, 'name'>
|
||||
>;
|
||||
labels?: Resolver<ResolversTypes['JSONObject'], ParentType, ContextType>;
|
||||
label?: Resolver<
|
||||
Maybe<ResolversTypes['JSON']>,
|
||||
ParentType,
|
||||
ContextType,
|
||||
RequireFields<ComponentMetadataLabelArgs, 'name'>
|
||||
>;
|
||||
uid?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
etag?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
generation?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
|
||||
relationships?: Resolver<
|
||||
Maybe<ResolversTypes['String']>,
|
||||
ParentType,
|
||||
ContextType
|
||||
>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType>;
|
||||
}>;
|
||||
|
||||
export type TemplateMetadataResolvers<
|
||||
ContextType = ModuleContext,
|
||||
ParentType = ResolversParentTypes['TemplateMetadata']
|
||||
> = ResolversObject<{
|
||||
name?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
annotations?: Resolver<ResolversTypes['JSONObject'], ParentType, ContextType>;
|
||||
annotation?: Resolver<
|
||||
Maybe<ResolversTypes['JSON']>,
|
||||
ParentType,
|
||||
ContextType,
|
||||
RequireFields<TemplateMetadataAnnotationArgs, 'name'>
|
||||
>;
|
||||
labels?: Resolver<ResolversTypes['JSONObject'], ParentType, ContextType>;
|
||||
label?: Resolver<
|
||||
Maybe<ResolversTypes['JSON']>,
|
||||
ParentType,
|
||||
ContextType,
|
||||
RequireFields<TemplateMetadataLabelArgs, 'name'>
|
||||
>;
|
||||
uid?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
etag?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
generation?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
|
||||
updatedBy?: Resolver<
|
||||
Maybe<ResolversTypes['String']>,
|
||||
ParentType,
|
||||
ContextType
|
||||
>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType>;
|
||||
}>;
|
||||
|
||||
export type TemplateEntitySpecResolvers<
|
||||
ContextType = ModuleContext,
|
||||
ParentType = ResolversParentTypes['TemplateEntitySpec']
|
||||
> = ResolversObject<{
|
||||
type?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
path?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
schema?: Resolver<ResolversTypes['JSONObject'], ParentType, ContextType>;
|
||||
templater?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType>;
|
||||
}>;
|
||||
|
||||
export type ComponentEntitySpecResolvers<
|
||||
ContextType = ModuleContext,
|
||||
ParentType = ResolversParentTypes['ComponentEntitySpec']
|
||||
> = ResolversObject<{
|
||||
title?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
lifecycle?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
owner?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType>;
|
||||
}>;
|
||||
|
||||
export type LocationEntitySpecResolvers<
|
||||
ContextType = ModuleContext,
|
||||
ParentType = ResolversParentTypes['LocationEntitySpec']
|
||||
> = ResolversObject<{
|
||||
type?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
target?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
targets?: Resolver<Array<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType>;
|
||||
}>;
|
||||
|
||||
export type DefaultEntitySpecResolvers<
|
||||
ContextType = ModuleContext,
|
||||
ParentType = ResolversParentTypes['DefaultEntitySpec']
|
||||
> = ResolversObject<{
|
||||
raw?: Resolver<Maybe<ResolversTypes['JSONObject']>, ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType>;
|
||||
}>;
|
||||
|
||||
export type EntitySpecResolvers<
|
||||
ContextType = ModuleContext,
|
||||
ParentType = ResolversParentTypes['EntitySpec']
|
||||
> = ResolversObject<{
|
||||
__resolveType: TypeResolveFn<
|
||||
| 'DefaultEntitySpec'
|
||||
| 'TemplateEntitySpec'
|
||||
| 'LocationEntitySpec'
|
||||
| 'ComponentEntitySpec',
|
||||
ParentType,
|
||||
ContextType
|
||||
>;
|
||||
}>;
|
||||
|
||||
export type CatalogEntityResolvers<
|
||||
ContextType = ModuleContext,
|
||||
ParentType = ResolversParentTypes['CatalogEntity']
|
||||
> = ResolversObject<{
|
||||
apiVersion?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
kind?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
metadata?: Resolver<
|
||||
Maybe<ResolversTypes['EntityMetadata']>,
|
||||
ParentType,
|
||||
ContextType
|
||||
>;
|
||||
spec?: Resolver<ResolversTypes['EntitySpec'], ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType>;
|
||||
}>;
|
||||
|
||||
export type CatalogQueryResolvers<
|
||||
ContextType = ModuleContext,
|
||||
ParentType = ResolversParentTypes['CatalogQuery']
|
||||
> = ResolversObject<{
|
||||
list?: Resolver<
|
||||
Array<ResolversTypes['CatalogEntity']>,
|
||||
ParentType,
|
||||
ContextType
|
||||
>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType>;
|
||||
}>;
|
||||
|
||||
export type QueryResolvers<
|
||||
ContextType = ModuleContext,
|
||||
ParentType = ResolversParentTypes['Query']
|
||||
> = ResolversObject<{
|
||||
catalog?: Resolver<ResolversTypes['CatalogQuery'], ParentType, ContextType>;
|
||||
}>;
|
||||
|
||||
export type Resolvers<ContextType = ModuleContext> = ResolversObject<{
|
||||
JSON?: GraphQLScalarType;
|
||||
JSONObject?: GraphQLScalarType;
|
||||
EntityMetadata?: EntityMetadataResolvers<ContextType>;
|
||||
DefaultEntityMetadata?: DefaultEntityMetadataResolvers<ContextType>;
|
||||
ComponentMetadata?: ComponentMetadataResolvers<ContextType>;
|
||||
TemplateMetadata?: TemplateMetadataResolvers<ContextType>;
|
||||
TemplateEntitySpec?: TemplateEntitySpecResolvers<ContextType>;
|
||||
ComponentEntitySpec?: ComponentEntitySpecResolvers<ContextType>;
|
||||
LocationEntitySpec?: LocationEntitySpecResolvers<ContextType>;
|
||||
DefaultEntitySpec?: DefaultEntitySpecResolvers<ContextType>;
|
||||
EntitySpec?: EntitySpecResolvers<ContextType>;
|
||||
CatalogEntity?: CatalogEntityResolvers<ContextType>;
|
||||
CatalogQuery?: CatalogQueryResolvers<ContextType>;
|
||||
Query?: QueryResolvers<ContextType>;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* Use "Resolvers" root object instead. If you wish to get "IResolvers", add "typesPrefix: I" to your config.
|
||||
*/
|
||||
export type IResolvers<ContextType = ModuleContext> = Resolvers<ContextType>;
|
||||
@@ -14,20 +14,4 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { getRootLogger } from '@backstage/backend-common';
|
||||
import yn from 'yn';
|
||||
import { startStandaloneServer } from './service/standaloneServer';
|
||||
|
||||
const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7000;
|
||||
const enableCors = yn(process.env.PLUGIN_CORS, { default: false });
|
||||
const logger = getRootLogger();
|
||||
|
||||
startStandaloneServer({ port, enableCors, logger }).catch(err => {
|
||||
logger.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
logger.info('CTRL+C pressed; exiting.');
|
||||
process.exit(0);
|
||||
});
|
||||
export * from './graphql/module';
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
const metadataFields = /* GraphQL */ `
|
||||
name: String!
|
||||
annotations: JSONObject!
|
||||
annotation(name: String!): JSON
|
||||
labels: JSONObject!
|
||||
label(name: String!): JSON
|
||||
uid: String!
|
||||
etag: String!
|
||||
generation: Int!
|
||||
`;
|
||||
|
||||
const schema = /* GraphQL */ `
|
||||
scalar JSON
|
||||
scalar JSONObject
|
||||
|
||||
interface EntityMetadata {
|
||||
${metadataFields}
|
||||
}
|
||||
|
||||
type DefaultEntityMetadata implements EntityMetadata {
|
||||
${metadataFields}
|
||||
}
|
||||
|
||||
type ComponentMetadata implements EntityMetadata {
|
||||
${metadataFields}
|
||||
# mock field to prove extensions working
|
||||
relationships: String
|
||||
}
|
||||
|
||||
type TemplateMetadata implements EntityMetadata {
|
||||
${metadataFields}
|
||||
# mock field to prove extensions working
|
||||
updatedBy: String
|
||||
}
|
||||
|
||||
# TODO: move this definition into plugin-scaffolder-graphql
|
||||
type TemplateEntitySpec {
|
||||
type: String!
|
||||
path: String
|
||||
schema: JSONObject!
|
||||
templater: String!
|
||||
}
|
||||
|
||||
type ComponentEntitySpec {
|
||||
type: String!
|
||||
lifecycle: String!
|
||||
owner: String!
|
||||
}
|
||||
|
||||
type DefaultEntitySpec {
|
||||
raw: JSONObject
|
||||
}
|
||||
|
||||
union EntitySpec = DefaultEntitySpec | TemplateEntitySpec | ComponentEntitySpec
|
||||
|
||||
type CatalogEntity {
|
||||
apiVersion: String!
|
||||
kind: String!
|
||||
metadata: EntityMetadata
|
||||
spec: EntitySpec!
|
||||
}
|
||||
|
||||
type CatalogQuery {
|
||||
list: [CatalogEntity!]!
|
||||
}
|
||||
|
||||
type Query {
|
||||
catalog: CatalogQuery!
|
||||
}
|
||||
`;
|
||||
|
||||
export default schema;
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* 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 { CatalogClient } from './client';
|
||||
import { rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
|
||||
describe('Catalog GraphQL Module', () => {
|
||||
const worker = setupServer();
|
||||
|
||||
beforeAll(() => worker.listen());
|
||||
afterAll(() => worker.close());
|
||||
|
||||
afterEach(() => worker.resetHandlers());
|
||||
|
||||
const baseUrl = 'http://localhost:1234';
|
||||
|
||||
it('will return the entities', async () => {
|
||||
const expectedResponse = [{ id: 'something' }];
|
||||
|
||||
worker.use(
|
||||
rest.get(`${baseUrl}/catalog/entities`, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(expectedResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
const client = new CatalogClient(baseUrl);
|
||||
|
||||
const response = await client.list();
|
||||
|
||||
expect(response).toEqual(expectedResponse);
|
||||
});
|
||||
|
||||
it('throws an error with the text', async () => {
|
||||
const expectedResponse = 'something broke';
|
||||
|
||||
worker.use(
|
||||
rest.get(`${baseUrl}/catalog/entities`, (_, res, ctx) =>
|
||||
res(ctx.status(500), ctx.text(expectedResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
const client = new CatalogClient(baseUrl);
|
||||
|
||||
await expect(() => client.list()).rejects.toThrow(expectedResponse);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* 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 { Entity, EntityMeta } from '@backstage/catalog-model';
|
||||
import fetch from 'node-fetch';
|
||||
import { JsonObject } from '@backstage/config';
|
||||
|
||||
export interface ReaderEntityMeta extends EntityMeta {
|
||||
uid: string;
|
||||
etag: string;
|
||||
generation: number;
|
||||
namespace: string;
|
||||
annotations: Record<string, string>;
|
||||
labels: Record<string, string>;
|
||||
}
|
||||
export interface ReaderEntity extends Entity {
|
||||
metadata: JsonObject & ReaderEntityMeta;
|
||||
}
|
||||
export class CatalogClient {
|
||||
constructor(private baseUrl: string) {}
|
||||
async list(): Promise<ReaderEntity[]> {
|
||||
const res = await fetch(`${this.baseUrl}/catalog/entities`);
|
||||
if (!res.ok) {
|
||||
// todo(blam): need some better way to handle errors here
|
||||
// experiment with throwing the input errors etc and having graphql versions of that
|
||||
throw new Error(await res.text());
|
||||
}
|
||||
|
||||
const entities: ReaderEntity[] = await res.json();
|
||||
return entities;
|
||||
}
|
||||
}
|
||||
@@ -13,21 +13,4 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { getRootLogger } from '@backstage/backend-common';
|
||||
import yn from 'yn';
|
||||
import { startStandaloneServer } from './service/standaloneServer';
|
||||
|
||||
const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7000;
|
||||
const enableCors = yn(process.env.PLUGIN_CORS, { default: false });
|
||||
const logger = getRootLogger();
|
||||
|
||||
startStandaloneServer({ port, enableCors, logger }).catch(err => {
|
||||
logger.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
logger.info('CTRL+C pressed; exiting.');
|
||||
process.exit(0);
|
||||
});
|
||||
export {};
|
||||
@@ -46,6 +46,9 @@ const useStyles = makeStyles(theme => ({
|
||||
gridTemplateColumns: '250px 1fr',
|
||||
gridColumnGap: theme.spacing(2),
|
||||
},
|
||||
buttonSpacing: {
|
||||
marginLeft: theme.spacing(2),
|
||||
},
|
||||
}));
|
||||
|
||||
const CatalogPageContents = () => {
|
||||
@@ -56,6 +59,7 @@ const CatalogPageContents = () => {
|
||||
reload,
|
||||
matchingEntities,
|
||||
availableTags,
|
||||
isCatalogEmpty,
|
||||
} = useFilteredEntities();
|
||||
const configApi = useApi(configApiRef);
|
||||
const catalogApi = useApi(catalogApiRef);
|
||||
@@ -141,6 +145,9 @@ const CatalogPageContents = () => {
|
||||
[isStarredEntity, userId, orgName],
|
||||
);
|
||||
|
||||
const showAddExampleEntities =
|
||||
configApi.has('catalog.exampleEntityLocations') && isCatalogEmpty;
|
||||
|
||||
return (
|
||||
<CatalogLayout>
|
||||
<CatalogTabs
|
||||
@@ -158,6 +165,16 @@ const CatalogPageContents = () => {
|
||||
>
|
||||
Create Component
|
||||
</Button>
|
||||
{showAddExampleEntities && (
|
||||
<Button
|
||||
className={styles.buttonSpacing}
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={addMockData}
|
||||
>
|
||||
Add example components
|
||||
</Button>
|
||||
)}
|
||||
<SupportButton>All your software catalog entities</SupportButton>
|
||||
</ContentHeader>
|
||||
<div className={styles.contentWrapper}>
|
||||
@@ -174,7 +191,6 @@ const CatalogPageContents = () => {
|
||||
entities={matchingEntities}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onAddMockData={addMockData}
|
||||
/>
|
||||
</div>
|
||||
</Content>
|
||||
|
||||
@@ -46,7 +46,6 @@ describe('CatalogTable component', () => {
|
||||
entities={[]}
|
||||
loading={false}
|
||||
error={{ code: 'error' }}
|
||||
onAddMockData={() => {}}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
@@ -63,7 +62,6 @@ describe('CatalogTable component', () => {
|
||||
titlePreamble="Owned"
|
||||
entities={entities}
|
||||
loading={false}
|
||||
onAddMockData={() => {}}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -16,11 +16,10 @@
|
||||
import { Entity, LocationSpec } from '@backstage/catalog-model';
|
||||
import { Table, TableColumn, TableProps } from '@backstage/core';
|
||||
import { Chip, Link } from '@material-ui/core';
|
||||
import Add from '@material-ui/icons/Add';
|
||||
import Edit from '@material-ui/icons/Edit';
|
||||
import GitHub from '@material-ui/icons/GitHub';
|
||||
import { Alert } from '@material-ui/lab';
|
||||
import React, { Dispatch } from 'react';
|
||||
import React from 'react';
|
||||
import { generatePath, Link as RouterLink } from 'react-router-dom';
|
||||
import { findLocationForEntityMeta } from '../../data/utils';
|
||||
import { useStarredEntities } from '../../hooks/useStarredEntites';
|
||||
@@ -87,7 +86,6 @@ type CatalogTableProps = {
|
||||
titlePreamble: string;
|
||||
loading: boolean;
|
||||
error?: any;
|
||||
onAddMockData: Dispatch<void>;
|
||||
};
|
||||
|
||||
export const CatalogTable = ({
|
||||
@@ -95,7 +93,6 @@ export const CatalogTable = ({
|
||||
loading,
|
||||
error,
|
||||
titlePreamble,
|
||||
onAddMockData,
|
||||
}: CatalogTableProps) => {
|
||||
const { isStarredEntity, toggleStarredEntity } = useStarredEntities();
|
||||
|
||||
@@ -151,13 +148,6 @@ export const CatalogTable = ({
|
||||
onClick: () => toggleStarredEntity(rowData),
|
||||
};
|
||||
},
|
||||
{
|
||||
icon: () => <Add />,
|
||||
tooltip: 'Add example components',
|
||||
isFreeAction: true,
|
||||
onClick: onAddMockData,
|
||||
hidden: !(entities && entities.length === 0),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -47,7 +47,7 @@ export const EntityFilterGroupsProvider = ({
|
||||
function useProvideEntityFilters(): FilterGroupsContext {
|
||||
const catalogApi = useApi(catalogApiRef);
|
||||
const [{ value: entities, error }, doReload] = useAsyncFn(() =>
|
||||
catalogApi.getEntities(),
|
||||
catalogApi.getEntities({ kind: 'Component' }),
|
||||
);
|
||||
|
||||
const filterGroups = useRef<{
|
||||
@@ -62,6 +62,7 @@ function useProvideEntityFilters(): FilterGroupsContext {
|
||||
}>({});
|
||||
const [matchingEntities, setMatchingEntities] = useState<Entity[]>([]);
|
||||
const [availableTags, setAvailableTags] = useState<string[]>([]);
|
||||
const [isCatalogEmpty, setCatalogEmpty] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
doReload();
|
||||
@@ -86,6 +87,7 @@ function useProvideEntityFilters(): FilterGroupsContext {
|
||||
),
|
||||
);
|
||||
setAvailableTags(collectTags(entities));
|
||||
setCatalogEmpty(entities !== undefined && entities.length === 0);
|
||||
}, [entities, error]);
|
||||
|
||||
const register = useCallback(
|
||||
@@ -143,6 +145,7 @@ function useProvideEntityFilters(): FilterGroupsContext {
|
||||
filterGroupStates,
|
||||
matchingEntities,
|
||||
availableTags,
|
||||
isCatalogEmpty,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ export type FilterGroupsContext = {
|
||||
filterGroupStates: { [filterGroupId: string]: FilterGroupStates };
|
||||
matchingEntities: Entity[];
|
||||
availableTags: string[];
|
||||
isCatalogEmpty: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -31,6 +31,7 @@ export function useFilteredEntities() {
|
||||
error: context.error,
|
||||
matchingEntities: context.matchingEntities,
|
||||
availableTags: context.availableTags,
|
||||
isCatalogEmpty: context.isCatalogEmpty,
|
||||
reload: context.reload,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,124 +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 { GCPApi } from './GCPApi';
|
||||
import { Project, Operation, Status } from './types';
|
||||
|
||||
const BaseURL =
|
||||
'https://content-cloudresourcemanager.googleapis.com/v1/projects';
|
||||
|
||||
export class GCPClient implements GCPApi {
|
||||
async listProjects({ token }: { token: string }): Promise<Project[]> {
|
||||
const response = await fetch(BaseURL, {
|
||||
headers: new Headers({
|
||||
Accept: '*/*',
|
||||
Authorization: `Bearer ${token}`,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return [
|
||||
{
|
||||
name: 'Error',
|
||||
projectNumber: 'Response status is not OK',
|
||||
projectId: 'Error',
|
||||
lifecycleState: 'error',
|
||||
createTime: 'Error',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return data.projects;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async getProject(
|
||||
projectId: string,
|
||||
token: Promise<string>,
|
||||
): Promise<Project> {
|
||||
const url = `${BaseURL}/${projectId}`;
|
||||
const response = await fetch(url, {
|
||||
headers: new Headers({
|
||||
Authorization: `Bearer ${await token}`,
|
||||
}),
|
||||
});
|
||||
|
||||
const dataBlank: Project = {
|
||||
name: 'Error',
|
||||
projectNumber: `Response status is ${response.status}`,
|
||||
projectId: 'Error',
|
||||
lifecycleState: 'error',
|
||||
createTime: 'Error',
|
||||
};
|
||||
|
||||
if (!response.ok) {
|
||||
return dataBlank;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const newData: Project = data;
|
||||
|
||||
return newData;
|
||||
}
|
||||
|
||||
async createProject(
|
||||
projectName: string,
|
||||
projectId: string,
|
||||
token: string,
|
||||
): Promise<Operation> {
|
||||
const status: Status = {
|
||||
code: 0,
|
||||
message: '',
|
||||
details: [],
|
||||
};
|
||||
|
||||
const op: Operation = {
|
||||
name: '',
|
||||
metadata: '',
|
||||
done: true,
|
||||
error: status,
|
||||
response: '',
|
||||
};
|
||||
|
||||
const newProject: Project = {
|
||||
name: projectName,
|
||||
projectId: projectId,
|
||||
};
|
||||
|
||||
const body = JSON.stringify(newProject);
|
||||
|
||||
const response = await fetch(BaseURL, {
|
||||
headers: new Headers({
|
||||
Accept: '*/*',
|
||||
Authorization: `Bearer ${token}`,
|
||||
}),
|
||||
body: body,
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
status.code = response.status;
|
||||
return op;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -17,18 +17,16 @@
|
||||
import { createApiRef } from '@backstage/core';
|
||||
import { Project, Operation } from './types';
|
||||
|
||||
export const GCPApiRef = createApiRef<GCPApi>({
|
||||
export const gcpApiRef = createApiRef<GcpApi>({
|
||||
id: 'plugin.gcpprojects.service',
|
||||
description: 'Used by the GCP Projects plugin to make requests',
|
||||
});
|
||||
|
||||
export type GCPApi = {
|
||||
listProjects: ({ token }: { token: string }) => Promise<Project[]>;
|
||||
getProject: (projectId: string, token: Promise<string>) => Promise<Project>;
|
||||
createProject: (
|
||||
projectName: string,
|
||||
projectId: string,
|
||||
owner: string,
|
||||
token: string,
|
||||
) => Promise<Operation>;
|
||||
export type GcpApi = {
|
||||
listProjects(): Promise<Project[]>;
|
||||
getProject(projectId: string): Promise<Project>;
|
||||
createProject(options: {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
}): Promise<Operation>;
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* 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 { OAuthApi } from '@backstage/core';
|
||||
import { GcpApi } from './GcpApi';
|
||||
import { Operation, Project } from './types';
|
||||
|
||||
const BASE_URL =
|
||||
'https://content-cloudresourcemanager.googleapis.com/v1/projects';
|
||||
|
||||
export class GcpClient implements GcpApi {
|
||||
constructor(private readonly googleAuthApi: OAuthApi) {}
|
||||
|
||||
async listProjects(): Promise<Project[]> {
|
||||
const response = await fetch(BASE_URL, {
|
||||
headers: {
|
||||
Accept: '*/*',
|
||||
Authorization: `Bearer ${await this.getToken()}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`List request failed to ${BASE_URL} with ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const { projects } = await response.json();
|
||||
return projects;
|
||||
}
|
||||
|
||||
async getProject(projectId: string): Promise<Project> {
|
||||
const url = `${BASE_URL}/${projectId}`;
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${await this.getToken()}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Get request failed to ${url} with ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
async createProject(options: {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
}): Promise<Operation> {
|
||||
const newProject: Project = {
|
||||
name: options.projectName,
|
||||
projectId: options.projectId,
|
||||
};
|
||||
|
||||
const response = await fetch(BASE_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: '*/*',
|
||||
Authorization: `Bearer ${await this.getToken()}`,
|
||||
},
|
||||
body: JSON.stringify(newProject),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Create request failed to ${BASE_URL} with ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
async getToken(): Promise<string> {
|
||||
// NOTE(freben): There's a .read-only variant of this scope that we could
|
||||
// use for readonly operations, but that means we would ask the user for a
|
||||
// second auth during creation and I decided to keep the wider scope for
|
||||
// all ops for now
|
||||
return this.googleAuthApi.getAccessToken(
|
||||
'https://www.googleapis.com/auth/cloud-platform',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export * from './GCPApi';
|
||||
export * from './GCPClient';
|
||||
export * from './GcpApi';
|
||||
export * from './GcpClient';
|
||||
export * from './types';
|
||||
|
||||
@@ -14,24 +14,23 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { FC, useState } from 'react';
|
||||
import { Grid, Button, TextField } from '@material-ui/core';
|
||||
|
||||
import {
|
||||
InfoCard,
|
||||
Content,
|
||||
ContentHeader,
|
||||
Header,
|
||||
HeaderLabel,
|
||||
InfoCard,
|
||||
Page,
|
||||
pageTheme,
|
||||
SimpleStepper,
|
||||
SimpleStepperStep,
|
||||
StructuredMetadataTable,
|
||||
HeaderLabel,
|
||||
Page,
|
||||
Header,
|
||||
pageTheme,
|
||||
SupportButton,
|
||||
} from '@backstage/core';
|
||||
import { Button, Grid, TextField } from '@material-ui/core';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
export const Project: FC<{}> = () => {
|
||||
export const Project = () => {
|
||||
const [projectName, setProjectName] = useState('');
|
||||
const [projectId, setProjectId] = useState('');
|
||||
const [disabled, setDisabled] = useState(true);
|
||||
|
||||
@@ -14,6 +14,17 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
Content,
|
||||
ContentHeader,
|
||||
Header,
|
||||
HeaderLabel,
|
||||
Page,
|
||||
pageTheme,
|
||||
SupportButton,
|
||||
useApi,
|
||||
WarningPanel,
|
||||
} from '@backstage/core';
|
||||
import {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
@@ -27,20 +38,9 @@ import {
|
||||
Theme,
|
||||
Typography,
|
||||
} from '@material-ui/core';
|
||||
import {
|
||||
useApi,
|
||||
googleAuthApiRef,
|
||||
HeaderLabel,
|
||||
Page,
|
||||
Header,
|
||||
pageTheme,
|
||||
SupportButton,
|
||||
Content,
|
||||
ContentHeader,
|
||||
} from '@backstage/core';
|
||||
import React from 'react';
|
||||
import { useAsync } from 'react-use';
|
||||
import { GCPApiRef } from '../../api';
|
||||
import { gcpApiRef } from '../../api';
|
||||
|
||||
const useStyles = makeStyles<Theme>(theme => ({
|
||||
root: {
|
||||
@@ -56,34 +56,27 @@ const useStyles = makeStyles<Theme>(theme => ({
|
||||
}));
|
||||
|
||||
const DetailsPage = () => {
|
||||
const api = useApi(GCPApiRef);
|
||||
const googleApi = useApi(googleAuthApiRef);
|
||||
const token = googleApi.getAccessToken(
|
||||
'https://www.googleapis.com/auth/cloud-platform.read-only',
|
||||
);
|
||||
|
||||
const api = useApi(gcpApiRef);
|
||||
const classes = useStyles();
|
||||
const status = useAsync(
|
||||
() =>
|
||||
|
||||
const { loading, error, value: details } = useAsync(
|
||||
async () =>
|
||||
api.getProject(
|
||||
decodeURIComponent(location.search.split('projectId=')[1]),
|
||||
token,
|
||||
),
|
||||
[location.search],
|
||||
);
|
||||
|
||||
if (status.loading) {
|
||||
if (loading) {
|
||||
return <LinearProgress />;
|
||||
} else if (status.error) {
|
||||
} else if (error) {
|
||||
return (
|
||||
<Typography variant="h6" color="error">
|
||||
Failed to load build, {status.error.message}
|
||||
</Typography>
|
||||
<WarningPanel title="Failed to load project">
|
||||
{error.toString()}
|
||||
</WarningPanel>
|
||||
);
|
||||
}
|
||||
|
||||
const details = status.value;
|
||||
|
||||
return (
|
||||
<Table component={Paper} className={classes.table}>
|
||||
<Table>
|
||||
|
||||
@@ -17,18 +17,19 @@
|
||||
// NEEDS WORK
|
||||
|
||||
import {
|
||||
Link,
|
||||
useApi,
|
||||
googleAuthApiRef,
|
||||
HeaderLabel,
|
||||
Page,
|
||||
Header,
|
||||
pageTheme,
|
||||
SupportButton,
|
||||
Content,
|
||||
ContentHeader,
|
||||
Header,
|
||||
HeaderLabel,
|
||||
Link,
|
||||
Page,
|
||||
pageTheme,
|
||||
SupportButton,
|
||||
useApi,
|
||||
WarningPanel,
|
||||
} from '@backstage/core';
|
||||
import {
|
||||
Button,
|
||||
LinearProgress,
|
||||
Paper,
|
||||
Table,
|
||||
@@ -38,11 +39,10 @@ import {
|
||||
TableRow,
|
||||
Tooltip,
|
||||
Typography,
|
||||
Button,
|
||||
} from '@material-ui/core';
|
||||
import React from 'react';
|
||||
import { useAsync } from 'react-use';
|
||||
import { GCPApiRef, Project } from '../../api';
|
||||
import { gcpApiRef, Project } from '../../api';
|
||||
|
||||
const LongText = ({ text, max }: { text: string; max: number }) => {
|
||||
if (text.length < max) {
|
||||
@@ -63,27 +63,17 @@ const labels = (
|
||||
);
|
||||
|
||||
const PageContents = () => {
|
||||
const api = useApi(GCPApiRef);
|
||||
const googleApi = useApi(googleAuthApiRef);
|
||||
const api = useApi(gcpApiRef);
|
||||
|
||||
const { loading, error, value } = useAsync(async () => {
|
||||
const token = await googleApi.getAccessToken(
|
||||
'https://www.googleapis.com/auth/cloud-platform.read-only',
|
||||
);
|
||||
|
||||
const projects = api.listProjects({ token });
|
||||
return projects;
|
||||
});
|
||||
const { loading, error, value } = useAsync(() => api.listProjects());
|
||||
|
||||
if (loading) {
|
||||
return <LinearProgress />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
} else if (error) {
|
||||
return (
|
||||
<Typography variant="h2" color="error">
|
||||
{error.message}{' '}
|
||||
</Typography>
|
||||
<WarningPanel title="Failed to load projects">
|
||||
{error.toString()}
|
||||
</WarningPanel>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,34 +15,43 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
createApiFactory,
|
||||
createPlugin,
|
||||
createRouteRef,
|
||||
createApiFactory,
|
||||
googleAuthApiRef,
|
||||
} from '@backstage/core';
|
||||
import { ProjectListPage } from './components/ProjectListPage';
|
||||
import { ProjectDetailsPage } from './components/ProjectDetailsPage';
|
||||
import { gcpApiRef, GcpClient } from './api';
|
||||
import { NewProjectPage } from './components/NewProjectPage';
|
||||
import { GCPApiRef, GCPClient } from './api';
|
||||
import { ProjectDetailsPage } from './components/ProjectDetailsPage';
|
||||
import { ProjectListPage } from './components/ProjectListPage';
|
||||
|
||||
export const rootRouteRef = createRouteRef({
|
||||
path: '/gcp-projects',
|
||||
title: 'GCP Projects',
|
||||
});
|
||||
export const ProjectRouteRef = createRouteRef({
|
||||
export const projectRouteRef = createRouteRef({
|
||||
path: '/gcp-projects/project',
|
||||
title: 'GCP Project Page',
|
||||
});
|
||||
export const NewProjectRouteRef = createRouteRef({
|
||||
export const newProjectRouteRef = createRouteRef({
|
||||
path: '/gcp-projects/new',
|
||||
title: 'GCP Project Page',
|
||||
});
|
||||
|
||||
export const plugin = createPlugin({
|
||||
id: 'gcp-projects',
|
||||
apis: [createApiFactory(GCPApiRef, new GCPClient())],
|
||||
apis: [
|
||||
createApiFactory({
|
||||
api: gcpApiRef,
|
||||
deps: { googleAuthApi: googleAuthApiRef },
|
||||
factory({ googleAuthApi }) {
|
||||
return new GcpClient(googleAuthApi);
|
||||
},
|
||||
}),
|
||||
],
|
||||
register({ router }) {
|
||||
router.addRoute(rootRouteRef, ProjectListPage);
|
||||
router.addRoute(ProjectRouteRef, ProjectDetailsPage);
|
||||
router.addRoute(NewProjectRouteRef, NewProjectPage);
|
||||
router.addRoute(projectRouteRef, ProjectDetailsPage);
|
||||
router.addRoute(newProjectRouteRef, NewProjectPage);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
## Getting Started
|
||||
|
||||
This backend plugin can be started in a standalone mode from directly in this package
|
||||
with `yarn start`. However, it will have limited functionality and that process is
|
||||
most convenient when developing the plugin itself.
|
||||
This is the GraphQL Backend plugin.
|
||||
|
||||
It is responsible for merging different `graphql-plugins` together to provide the end schema.
|
||||
|
||||
To run it within the backend do:
|
||||
|
||||
|
||||
@@ -19,14 +19,19 @@
|
||||
"clean": "backstage-cli clean"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/config": "^0.1.1-alpha.22",
|
||||
"@backstage/plugin-catalog-graphql": "^0.1.1-alpha.22",
|
||||
"@graphql-modules/core": "^0.7.17",
|
||||
"@backstage/backend-common": "^0.1.1-alpha.22",
|
||||
"@types/express": "^4.17.6",
|
||||
"apollo-server": "^2.16.0",
|
||||
"apollo-server-express": "^2.16.0",
|
||||
"apollo-server": "^2.16.1",
|
||||
"apollo-server-express": "^2.16.1",
|
||||
"express": "^4.17.1",
|
||||
"express-promise-router": "^3.0.3",
|
||||
"graphql": "^15.3.0",
|
||||
"whatwg-fetch": "^3.4.0",
|
||||
"helmet": "^4.0.0",
|
||||
"node-fetch": "^2.6.0",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"winston": "^3.2.1",
|
||||
"yn": "^4.0.0"
|
||||
},
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
type CatalogEntity {
|
||||
id: String
|
||||
}
|
||||
|
||||
type CatalogQuery {
|
||||
list: [CatalogEntity!]!
|
||||
}
|
||||
|
||||
type Query {
|
||||
catalog: CatalogQuery!
|
||||
}
|
||||
|
||||
@@ -14,6 +14,4 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
require('whatwg-fetch');
|
||||
|
||||
export * from './service/router';
|
||||
|
||||
@@ -13,10 +13,25 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import './router';
|
||||
import { createRouter } from './router';
|
||||
import supertest from 'supertest';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { createLogger } from 'winston';
|
||||
import express from 'express';
|
||||
|
||||
describe('Router', () => {
|
||||
it('should pass the test', () => {
|
||||
expect(true).toBeTruthy();
|
||||
describe('/health', () => {
|
||||
it('should return ok', async () => {
|
||||
const config = ConfigReader.fromConfigs([
|
||||
{ data: { backend: { baseUrl: 'lol' } }, context: 'something' },
|
||||
]);
|
||||
|
||||
const router = await createRouter({ config, logger: createLogger() });
|
||||
const app = express().use(router);
|
||||
|
||||
const { body } = await supertest(app).get('/health');
|
||||
|
||||
expect(body).toEqual({ status: 'ok' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,7 +19,11 @@ import express from 'express';
|
||||
import Router from 'express-promise-router';
|
||||
import { Logger } from 'winston';
|
||||
import fs from 'fs';
|
||||
import { GraphQLModule } from '@graphql-modules/core';
|
||||
import { ApolloServer } from 'apollo-server-express';
|
||||
import { createModule as createCatalogModule } from '@backstage/plugin-catalog-graphql';
|
||||
import { Config } from '@backstage/config';
|
||||
import helmet from 'helmet';
|
||||
|
||||
const schemaPath = resolvePackagePath(
|
||||
'@backstage/plugin-graphql-backend',
|
||||
@@ -28,6 +32,7 @@ const schemaPath = resolvePackagePath(
|
||||
|
||||
export interface RouterOptions {
|
||||
logger: Logger;
|
||||
config: Config;
|
||||
}
|
||||
|
||||
export async function createRouter(
|
||||
@@ -35,16 +40,39 @@ export async function createRouter(
|
||||
): Promise<express.Router> {
|
||||
const typeDefs = await fs.promises.readFile(schemaPath, 'utf-8');
|
||||
|
||||
const server = new ApolloServer({ typeDefs, logger: options.logger });
|
||||
const router = Router();
|
||||
const catalogModule = await createCatalogModule(options);
|
||||
|
||||
const apolloMiddleware = server.getMiddleware({ path: '/' });
|
||||
router.use(apolloMiddleware);
|
||||
const { schema } = new GraphQLModule({
|
||||
imports: [catalogModule],
|
||||
typeDefs,
|
||||
});
|
||||
|
||||
const server = new ApolloServer({
|
||||
schema,
|
||||
logger: options.logger,
|
||||
introspection: true,
|
||||
playground: process.env.NODE_ENV === 'development',
|
||||
});
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/health', (_, response) => {
|
||||
response.send({ status: 'ok' });
|
||||
});
|
||||
|
||||
const apolloMiddleware = server.getMiddleware({ path: '/' });
|
||||
|
||||
if (process.env.NODE_ENV === 'development')
|
||||
router.use(
|
||||
helmet.contentSecurityPolicy({
|
||||
directives: {
|
||||
defaultSrc: ["'self'", "'unsafe-inline'", 'http://*'],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
router.use(apolloMiddleware);
|
||||
|
||||
router.use(errorHandler());
|
||||
|
||||
return router;
|
||||
|
||||
@@ -1,62 +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.
|
||||
*/
|
||||
/*
|
||||
* 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 { createServiceBuilder } from '@backstage/backend-common';
|
||||
import { Server } from 'http';
|
||||
import { Logger } from 'winston';
|
||||
import { createRouter } from './router';
|
||||
|
||||
export interface ServerOptions {
|
||||
port: number;
|
||||
enableCors: boolean;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
export async function startStandaloneServer(
|
||||
options: ServerOptions,
|
||||
): Promise<Server> {
|
||||
const logger = options.logger.child({ service: 'graphql-backend' });
|
||||
logger.debug('Starting application server...');
|
||||
const router = await createRouter({
|
||||
logger,
|
||||
});
|
||||
|
||||
const service = createServiceBuilder(module)
|
||||
.enableCors({ origin: 'http://localhost:3000' })
|
||||
.addRouter('/graphql', router);
|
||||
|
||||
return await service.start().catch(err => {
|
||||
logger.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.hot?.accept();
|
||||
@@ -13,6 +13,5 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
require('whatwg-fetch');
|
||||
|
||||
export {};
|
||||
|
||||
Reference in New Issue
Block a user