This commit is contained in:
Tim Hansen
2020-09-21 10:01:22 -06:00
51 changed files with 2649 additions and 469 deletions
@@ -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
```
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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>
+2
View File
@@ -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
View File
@@ -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 .",
-1
View File
@@ -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'));
+6 -2
View File
@@ -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) {
+3
View File
@@ -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)),
);
+14 -4
View File
@@ -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"
+1 -1
View File
@@ -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,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": {
+5 -1
View File
@@ -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>()
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint.backend')],
};
+11
View File
@@ -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 >
+15
View File
@@ -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}>
+49
View File
@@ -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';
+88
View File
@@ -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,
};
}
+1
View File
@@ -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,
};
}
-124
View File
@@ -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>;
};
+98
View File
@@ -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',
);
}
}
+2 -2
View File
@@ -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>
);
}
+18 -9
View File
@@ -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);
},
});
+3 -3
View File
@@ -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:
+8 -3
View File
@@ -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"
},
-11
View File
@@ -1,11 +0,0 @@
type CatalogEntity {
id: String
}
type CatalogQuery {
list: [CatalogEntity!]!
}
type Query {
catalog: CatalogQuery!
}
-2
View File
@@ -14,6 +14,4 @@
* limitations under the License.
*/
require('whatwg-fetch');
export * from './service/router';
+18 -3
View File
@@ -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' });
});
});
});
+32 -4
View File
@@ -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();
-1
View File
@@ -13,6 +13,5 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
require('whatwg-fetch');
export {};
+1004 -84
View File
File diff suppressed because it is too large Load Diff