From 33222ffec93f98addd1081a01e5049f30a5e71ce Mon Sep 17 00:00:00 2001 From: Dmitriy Lazarev Date: Tue, 3 Oct 2023 15:30:49 +0400 Subject: [PATCH 1/2] Update @frontside/hydraphql to 0.1.1 Signed-off-by: Dmitriy Lazarev --- .changeset/friendly-yaks-rhyme.md | 7 ++ .../package.json | 2 +- .../src/__snapshots__/codegen.test.ts.snap | 7 +- .../src/__testUtils__/createApi.ts | 5 +- .../src/catalog/catalog.ts | 25 ++--- .../src/relation/relation.ts | 25 ++--- .../src/relationDirectiveMapper.test.ts | 6 +- .../src/relationDirectiveMapper.ts | 104 ++---------------- .../src/schema.ts | 6 +- plugins/graphql-backend-node/package.json | 2 +- .../src/extensions/graphqlModulesExtension.ts | 5 +- plugins/graphql-backend/package.json | 5 +- plugins/graphql-backend/src/graphql.ts | 10 +- plugins/graphql-backend/src/router.ts | 6 +- yarn.lock | 15 +-- 15 files changed, 67 insertions(+), 163 deletions(-) create mode 100644 .changeset/friendly-yaks-rhyme.md diff --git a/.changeset/friendly-yaks-rhyme.md b/.changeset/friendly-yaks-rhyme.md new file mode 100644 index 0000000000..5f6cdff7ea --- /dev/null +++ b/.changeset/friendly-yaks-rhyme.md @@ -0,0 +1,7 @@ +--- +'@frontside/backstage-plugin-graphql-backend-module-catalog': patch +'@frontside/backstage-plugin-graphql-backend-node': patch +'@frontside/backstage-plugin-graphql-backend': patch +--- + +Update @frontside/hydraphql to 0.1.1 diff --git a/plugins/graphql-backend-module-catalog/package.json b/plugins/graphql-backend-module-catalog/package.json index 56b1018475..6b59a6ca11 100644 --- a/plugins/graphql-backend-module-catalog/package.json +++ b/plugins/graphql-backend-module-catalog/package.json @@ -46,7 +46,7 @@ "@backstage/catalog-model": "^1.4.1", "@backstage/plugin-catalog-node": "^1.4.3", "@frontside/backstage-plugin-graphql-backend": "^0.1.0", - "@frontside/hydraphql": "^0.0.1", + "@frontside/hydraphql": "^0.1.1", "@graphql-tools/load-files": "^7.0.0", "@graphql-tools/utils": "^10.0.0", "dataloader": "^2.1.0", diff --git a/plugins/graphql-backend-module-catalog/src/__snapshots__/codegen.test.ts.snap b/plugins/graphql-backend-module-catalog/src/__snapshots__/codegen.test.ts.snap index dedccb77c7..c3a69184cc 100644 --- a/plugins/graphql-backend-module-catalog/src/__snapshots__/codegen.test.ts.snap +++ b/plugins/graphql-backend-module-catalog/src/__snapshots__/codegen.test.ts.snap @@ -5,13 +5,13 @@ exports[`graphql-catalog codegen should generate the correct code: graphql 1`] = directive @discriminationAlias(type: String!, value: String!) repeatable on INTERFACE -directive @field(at: _DirectiveArgument_!, default: _DirectiveArgument_) on FIELD_DEFINITION +directive @field(at: _DirectiveArgument_, default: _DirectiveArgument_) on FIELD_DEFINITION directive @implements(interface: String!) on INTERFACE | OBJECT directive @relation(kind: String, name: String, nodeType: String) on FIELD_DEFINITION -directive @resolve(at: _DirectiveArgument_, from: String) on FIELD_DEFINITION +directive @resolve(at: _DirectiveArgument_, from: String, nodeType: String) on FIELD_DEFINITION interface API implements Entity & Node & Ownable { annotations: [KeyValuePair!] @@ -2436,7 +2436,7 @@ export type DiscriminationAliasDirectiveArgs = { export type DiscriminationAliasDirectiveResolver = DirectiveResolverFn; export type FieldDirectiveArgs = { - at: Scalars['_DirectiveArgument_']['input']; + at?: Maybe; default?: Maybe; }; @@ -2459,6 +2459,7 @@ export type RelationDirectiveResolver; from?: Maybe; + nodeType?: Maybe; }; export type ResolveDirectiveResolver = DirectiveResolverFn; diff --git a/plugins/graphql-backend-module-catalog/src/__testUtils__/createApi.ts b/plugins/graphql-backend-module-catalog/src/__testUtils__/createApi.ts index 8a14d2856f..7d454c3671 100644 --- a/plugins/graphql-backend-module-catalog/src/__testUtils__/createApi.ts +++ b/plugins/graphql-backend-module-catalog/src/__testUtils__/createApi.ts @@ -3,7 +3,6 @@ import type { JsonObject } from '@backstage/types'; import { createGraphQLApp, GraphQLContext, - CoreSync, } from '@frontside/hydraphql'; import * as graphql from 'graphql'; @@ -12,7 +11,7 @@ import { Module } from 'graphql-modules'; import { envelop, useEngine } from '@envelop/core'; import { useDataLoader } from '@envelop/dataloader'; import { useGraphQLModules } from '@envelop/graphql-modules'; -import { RelationSync } from '../relation'; +import { Relation } from '../relation'; export async function createGraphQLAPI( TestModule: Module, @@ -20,7 +19,7 @@ export async function createGraphQLAPI( generateOpaqueTypes?: boolean, ) { const application = await createGraphQLApp({ - modules: [CoreSync(), RelationSync(), TestModule], + modules: [Relation(), TestModule], generateOpaqueTypes, }); diff --git a/plugins/graphql-backend-module-catalog/src/catalog/catalog.ts b/plugins/graphql-backend-module-catalog/src/catalog/catalog.ts index fea48d75c6..b96dc854bf 100644 --- a/plugins/graphql-backend-module-catalog/src/catalog/catalog.ts +++ b/plugins/graphql-backend-module-catalog/src/catalog/catalog.ts @@ -1,12 +1,12 @@ -import { createModule, TypeDefs } from 'graphql-modules'; +import { createModule } from 'graphql-modules'; import GraphQLJSON, { GraphQLJSONObject } from 'graphql-type-json'; import { relationDirectiveMapper } from '../relationDirectiveMapper'; import { - createDirectiveMapperProvider, + GraphQLModule, encodeId, } from '@frontside/hydraphql'; import { stringifyEntityRef } from '@backstage/catalog-model'; -import { loadFiles, loadFilesSync } from '@graphql-tools/load-files'; +import { loadFilesSync } from '@graphql-tools/load-files'; import { resolvePackagePath } from '@backstage/backend-common'; import { CATALOG_SOURCE } from '../constants'; @@ -16,12 +16,11 @@ const catalogSchemaPath = resolvePackagePath( ); /** @public */ -export const CatalogSync = ( - typeDefs: TypeDefs = loadFilesSync(catalogSchemaPath), -) => - createModule({ +export const Catalog = (): GraphQLModule => ({ + mappers: { relation: relationDirectiveMapper }, + module: createModule({ id: 'catalog', - typeDefs, + typeDefs: loadFilesSync(catalogSchemaPath), resolvers: { JSON: GraphQLJSON, JSONObject: GraphQLJSONObject, @@ -55,11 +54,5 @@ export const CatalogSync = ( }), }, }, - providers: [ - createDirectiveMapperProvider('relation', relationDirectiveMapper), - ], - }); - -/** @public */ -export const Catalog = async () => - CatalogSync(await loadFiles(catalogSchemaPath)); + }) +}); diff --git a/plugins/graphql-backend-module-catalog/src/relation/relation.ts b/plugins/graphql-backend-module-catalog/src/relation/relation.ts index 71980bee49..98a9378ae9 100644 --- a/plugins/graphql-backend-module-catalog/src/relation/relation.ts +++ b/plugins/graphql-backend-module-catalog/src/relation/relation.ts @@ -1,7 +1,7 @@ -import { TypeDefs, createModule } from 'graphql-modules'; +import { createModule } from 'graphql-modules'; import { relationDirectiveMapper } from '../relationDirectiveMapper'; -import { createDirectiveMapperProvider } from '@frontside/hydraphql'; -import { loadFiles, loadFilesSync } from '@graphql-tools/load-files'; +import { GraphQLModule } from '@frontside/hydraphql'; +import { loadFilesSync } from '@graphql-tools/load-files'; import { resolvePackagePath } from '@backstage/backend-common'; const relationSchemaPath = resolvePackagePath( @@ -10,17 +10,10 @@ const relationSchemaPath = resolvePackagePath( ); /** @public */ -export const RelationSync = ( - typeDefs: TypeDefs = loadFilesSync(relationSchemaPath), -) => - createModule({ +export const Relation = (): GraphQLModule => ({ + mappers: { relation: relationDirectiveMapper }, + module: createModule({ id: 'relation', - typeDefs, - providers: [ - createDirectiveMapperProvider('relation', relationDirectiveMapper), - ], - }); - -/** @public */ -export const Relation = async () => - RelationSync(await loadFiles(relationSchemaPath)); + typeDefs: loadFilesSync(relationSchemaPath), + }) +}); diff --git a/plugins/graphql-backend-module-catalog/src/relationDirectiveMapper.test.ts b/plugins/graphql-backend-module-catalog/src/relationDirectiveMapper.test.ts index 96665d9dd8..507e3f9b9d 100644 --- a/plugins/graphql-backend-module-catalog/src/relationDirectiveMapper.test.ts +++ b/plugins/graphql-backend-module-catalog/src/relationDirectiveMapper.test.ts @@ -1,5 +1,4 @@ import { - CoreSync, transformSchema, encodeId, decodeId, @@ -8,13 +7,12 @@ import DataLoader from 'dataloader'; import { DocumentNode, GraphQLNamedType, printType } from 'graphql'; import { createModule, gql } from 'graphql-modules'; import { createGraphQLAPI } from './__testUtils__'; -import { RelationSync } from './relation/relation'; +import { Relation } from './relation/relation'; describe('mapRelationDirective', () => { const transform = (source: DocumentNode) => transformSchema([ - CoreSync(), - RelationSync(), + Relation(), createModule({ id: 'mapRelationDirective', typeDefs: source, diff --git a/plugins/graphql-backend-module-catalog/src/relationDirectiveMapper.ts b/plugins/graphql-backend-module-catalog/src/relationDirectiveMapper.ts index 863a6f4ce9..5114215484 100644 --- a/plugins/graphql-backend-module-catalog/src/relationDirectiveMapper.ts +++ b/plugins/graphql-backend-module-catalog/src/relationDirectiveMapper.ts @@ -2,35 +2,23 @@ import { connectionFromArray } from 'graphql-relay'; import { Entity, parseEntityRef } from '@backstage/catalog-model'; import { GraphQLFieldConfig, - GraphQLID, GraphQLInt, GraphQLInterfaceType, - GraphQLList, - GraphQLNonNull, - GraphQLObjectType, GraphQLString, - isInputType, - isInterfaceType, isListType, isNonNullType, - isObjectType, - isUnionType, } from 'graphql'; import { DirectiveMapperAPI, ResolverContext, unboxNamedType, encodeId, + isConnectionType, + createConnectionType, + getNodeTypeForConnection } from '@frontside/hydraphql'; import { CATALOG_SOURCE } from './constants'; -function isConnectionType(type: unknown): type is GraphQLInterfaceType { - return ( - (isInterfaceType(type) && type.name === 'Connection') || - (isNonNullType(type) && isConnectionType(type.ofType)) - ); -} - function filterEntityRefs( entity: Entity | undefined, relationType?: string, @@ -49,42 +37,6 @@ function filterEntityRefs( ); } -function createConnectionType( - nodeType: GraphQLInterfaceType | GraphQLObjectType, - fieldType: GraphQLInterfaceType, -): GraphQLObjectType { - const wrappedEdgeType = fieldType.getFields().edges.type as GraphQLNonNull< - GraphQLList> - >; - const edgeType = wrappedEdgeType.ofType.ofType.ofType as GraphQLInterfaceType; - - return new GraphQLObjectType({ - name: `${nodeType.name}Connection`, - fields: { - ...fieldType.toConfig().fields, - edges: { - type: new GraphQLNonNull( - new GraphQLList( - new GraphQLNonNull( - new GraphQLObjectType({ - name: `${nodeType.name}Edge`, - fields: { - ...edgeType.toConfig().fields, - node: { - type: new GraphQLNonNull(nodeType), - }, - }, - interfaces: [edgeType], - }), - ), - ), - ), - }, - }, - interfaces: [fieldType], - }); -} - export function relationDirectiveMapper( _fieldName: string, field: GraphQLFieldConfig<{ id: string }, ResolverContext>, @@ -108,51 +60,13 @@ export function relationDirectiveMapper( if (isConnectionType(fieldType)) { if (directive.nodeType) { - const nodeType = api.typeMap[directive.nodeType]; - - if (!nodeType) { - throw new Error( - `The interface "${directive.nodeType}" is not defined in the schema`, - ); - } - if (isInputType(nodeType)) { - throw new Error( - `The interface "${directive.nodeType}" is an input type and can't be used in a Connection`, - ); - } - if (isUnionType(nodeType)) { - const resolveType = nodeType.resolveType; - if (resolveType) - throw new Error( - `The "resolveType" function has already been implemented for "${nodeType.name}" union which may lead to undefined behavior`, - ); - const iface = (api.typeMap[directive.nodeType] = - new GraphQLInterfaceType({ - name: directive.nodeType, - interfaces: [api.typeMap.Node as GraphQLInterfaceType], - fields: { id: { type: new GraphQLNonNull(GraphQLID) } }, - resolveType: (...args) => - (api.typeMap.Node as GraphQLInterfaceType).resolveType?.(...args), - })); - const types = nodeType.getTypes().map(type => type.name); - types.forEach(typeName => { - const type = api.typeMap[typeName]; - if (isInterfaceType(type)) { - api.typeMap[typeName] = new GraphQLInterfaceType({ - ...type.toConfig(), - interfaces: [...type.getInterfaces(), iface], - }); - } - if (isObjectType(type)) { - api.typeMap[typeName] = new GraphQLObjectType({ - ...type.toConfig(), - interfaces: [...type.getInterfaces(), iface], - }); - } - }); + const nodeType = getNodeTypeForConnection( + directive.nodeType, + (name) => api.typeMap[name], + (name, type) => (api.typeMap[name] = type), + ); - field.type = createConnectionType(iface, fieldType); - } else { + if (nodeType) { field.type = createConnectionType(nodeType, fieldType); } } else { diff --git a/plugins/graphql-backend-module-catalog/src/schema.ts b/plugins/graphql-backend-module-catalog/src/schema.ts index ba7e8631c2..2c734d24fa 100644 --- a/plugins/graphql-backend-module-catalog/src/schema.ts +++ b/plugins/graphql-backend-module-catalog/src/schema.ts @@ -1,7 +1,7 @@ -import { CoreSync, transformSchema } from '@frontside/hydraphql'; +import { transformSchema } from '@frontside/hydraphql'; import { printSchemaWithDirectives } from '@graphql-tools/utils'; -import { CatalogSync } from './catalog'; +import { Catalog } from './catalog'; export const schema = printSchemaWithDirectives( - transformSchema([CoreSync(), CatalogSync()]), + transformSchema([Catalog()]), ); diff --git a/plugins/graphql-backend-node/package.json b/plugins/graphql-backend-node/package.json index 0a78d57fe8..b8ed526744 100644 --- a/plugins/graphql-backend-node/package.json +++ b/plugins/graphql-backend-node/package.json @@ -34,7 +34,7 @@ }, "dependencies": { "@backstage/backend-plugin-api": "^0.6.2", - "@frontside/hydraphql": "^0.0.1", + "@frontside/hydraphql": "^0.1.1", "dataloader": "^2.1.0", "graphql-modules": "^2.1.0", "graphql-yoga": "^4.0.3" diff --git a/plugins/graphql-backend-node/src/extensions/graphqlModulesExtension.ts b/plugins/graphql-backend-node/src/extensions/graphqlModulesExtension.ts index f6edabbaf8..246a3e009a 100644 --- a/plugins/graphql-backend-node/src/extensions/graphqlModulesExtension.ts +++ b/plugins/graphql-backend-node/src/extensions/graphqlModulesExtension.ts @@ -1,10 +1,11 @@ import { createExtensionPoint } from '@backstage/backend-plugin-api'; +import { GraphQLModule } from '@frontside/hydraphql'; import { Module } from 'graphql-modules'; /** @public */ -export interface GraphQLModulesExtensionPoint { +export interface GraphQLModulesExtensionPoint { addModules( - modules: ((() => Module | Promise) | Module | Promise)[], + modules: ((() => T | Promise) | T | Promise)[], ): void; } diff --git a/plugins/graphql-backend/package.json b/plugins/graphql-backend/package.json index 7b74b4856e..ff7c6eb742 100644 --- a/plugins/graphql-backend/package.json +++ b/plugins/graphql-backend/package.json @@ -39,7 +39,7 @@ "@envelop/dataloader": "^5.0.0", "@envelop/graphql-modules": "^5.0.0", "@frontside/backstage-plugin-graphql-backend-node": "^0.1.0", - "@frontside/hydraphql": "^0.0.1", + "@frontside/hydraphql": "^0.1.1", "dataloader": "^2.1.0", "express": "^4.17.1", "express-promise-router": "^4.1.0", @@ -55,6 +55,7 @@ "supertest": "^6.1.3" }, "files": [ - "dist" + "dist", + "docs" ] } diff --git a/plugins/graphql-backend/src/graphql.ts b/plugins/graphql-backend/src/graphql.ts index 5d7ffd9fc2..3ce31f6edc 100644 --- a/plugins/graphql-backend/src/graphql.ts +++ b/plugins/graphql-backend/src/graphql.ts @@ -2,6 +2,7 @@ import { Module } from 'graphql-modules'; import { GraphQLContext, BatchLoadFn, + GraphQLModule } from '@frontside/hydraphql'; import { Plugin } from 'graphql-yoga'; import { Options as DataLoaderOptions } from 'dataloader'; @@ -38,19 +39,20 @@ export const graphqlPlugin = createBackendPlugin({ }, }); - const modules = new Map(); + const modules = new Map(); env.registerExtensionPoint(graphqlModulesExtensionPoint, { async addModules(newModules) { for (const module of newModules) { const resolvedModule = await (typeof module === 'function' ? module() : module); - if (modules.has(resolvedModule.id)) { + const moduleId = 'id' in resolvedModule ? resolvedModule.id : resolvedModule.module.id + if (modules.has(moduleId)) { throw new Error( - `A module with id "${resolvedModule.id}" has already been registered`, + `A module with id "${moduleId}" has already been registered`, ); } - modules.set(resolvedModule.id, resolvedModule); + modules.set(moduleId, resolvedModule); } }, }); diff --git a/plugins/graphql-backend/src/router.ts b/plugins/graphql-backend/src/router.ts index dc3bbd6c8f..6b74a484dc 100644 --- a/plugins/graphql-backend/src/router.ts +++ b/plugins/graphql-backend/src/router.ts @@ -15,14 +15,14 @@ import { createGraphQLApp, GraphQLContext, BatchLoadFn, - Core, + GraphQLModule, } from '@frontside/hydraphql'; import { LoggerService } from '@backstage/backend-plugin-api'; export interface RouterOptions { appOptions?: GraphQLAppOptions; schemas?: string[]; - modules?: Module[]; + modules?: (Module| GraphQLModule)[]; plugins?: Plugin[]; loaders?: Record>; dataloaderOptions?: Options; @@ -49,7 +49,7 @@ export async function createRouter({ let yoga: YogaServerInstance | null = null; const application = await createGraphQLApp({ - modules: [await Core(), ...modules], + modules, schema: [...schemas], ...appOptions, }); diff --git a/yarn.lock b/yarn.lock index 0809463bb1..59d1eae06a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4584,10 +4584,10 @@ graphql "16.5.0" hash.js "1.1.7" -"@frontside/hydraphql@^0.0.1": - version "0.0.1" - resolved "https://registry.yarnpkg.com/@frontside/hydraphql/-/hydraphql-0.0.1.tgz#90cc11fbc8c8d19bff5e4f24fc29d8fc1040d608" - integrity sha512-VE/2yog1rVnkWBGcZHMtVaJGbKDLh71nDImc0SC0Z9TIqlw3yJds7ngfz8psX1qPz+IBqwxfRDaVM9aTWtLUeg== +"@frontside/hydraphql@^0.1.1": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@frontside/hydraphql/-/hydraphql-0.1.1.tgz#d903c426efe6c33d5d3682ff32eab4ebd801d5d1" + integrity sha512-giXQHJGor097xqAA9r3yqgu+T/DhCZ7SZSBSY+W670lHZxF5zPCL/+0SE3fK+qTiZV9MUU3nWHfbDs+MxT5pVw== dependencies: "@graphql-tools/code-file-loader" "^8.0.0" "@graphql-tools/graphql-file-loader" "^8.0.0" @@ -4596,9 +4596,9 @@ "@graphql-tools/merge" "^9.0.0" "@graphql-tools/schema" "^10.0.0" "@graphql-tools/utils" "^10.0.0" + graphql-relay "^0.10.0" lodash "^4.17.21" pascal-case "^3.1.2" - reflect-metadata "^0.1.13" tslib "^2.6.2" "@frontside/tsconfig@^1.2.0": @@ -22615,11 +22615,6 @@ redux@^4.0.0, redux@^4.0.4, redux@^4.1.2: dependencies: "@babel/runtime" "^7.9.2" -reflect-metadata@^0.1.13: - version "0.1.13" - resolved "https://registry.yarnpkg.com/reflect-metadata/-/reflect-metadata-0.1.13.tgz#67ae3ca57c972a2aa1642b10fe363fe32d49dc08" - integrity sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg== - reflect.getprototypeof@^1.0.3: version "1.0.4" resolved "https://registry.yarnpkg.com/reflect.getprototypeof/-/reflect.getprototypeof-1.0.4.tgz#aaccbf41aca3821b87bb71d9dcbc7ad0ba50a3f3" From 5cf89a0ad7a4ea0e90d253aa97835470f6b4aa9a Mon Sep 17 00:00:00 2001 From: Dmitriy Lazarev Date: Wed, 4 Oct 2023 16:55:00 +0400 Subject: [PATCH 2/2] Update graphql-backend/catalog plugin docs Signed-off-by: Dmitriy Lazarev --- .../graphql-backend-module-catalog/README.md | 39 ++-- plugins/graphql-backend/README.md | 184 ++++++++++++++-- .../graphql-backend/docs/backend-plugins.md | 197 ++++++++++++++++++ 3 files changed, 381 insertions(+), 39 deletions(-) create mode 100644 plugins/graphql-backend/docs/backend-plugins.md diff --git a/plugins/graphql-backend-module-catalog/README.md b/plugins/graphql-backend-module-catalog/README.md index c19ced26bb..53c1d80c24 100644 --- a/plugins/graphql-backend-module-catalog/README.md +++ b/plugins/graphql-backend-module-catalog/README.md @@ -16,46 +16,51 @@ Some key features are currently missing. These features may change the schema in 1. `viewer` query for retrieving data for the current user. - [GraphQL modules](#graphql-modules) - - [Catalog module](#catalog-module) - - [Relation module](#relation-module) + - [Backstage Plugins](#backstage-plugins) + - [Experimental Backend System](#experimental-backend-system) - [Directives API](#directives-api) - [`@relation` directive](#relation-directive) - [Catalog Data loader](#catalog-data-loader-advanced) ## GraphQL modules -### Catalog module +This package provides two GraphQL modules: +- `Catalog` module – provides basic Catalog GraphQL types and `@relation` directive +- `Relation` module – provides only `@relation` directive -The `Catalog` module is installed just as any other Backstage Module: -[`@frontside/backstage-plugin-graphql-backend`](../graphql-backend/README.md) +### Backstage Plugins +For the [Backstage plugin system](https://backstage.io/docs/plugins/backend-plugin), +you can learn how to add GraphQL modules by checking out [GraphQL Modules](../graphql-backend/README.md#graphql-modules) +section in `@frontside/backstage-plugin-graphql-backend` package. + +This package exports `Catalog` and `Relation` modules + +### Experimental Backend System + +For the [experimental backend system](https://backstage.io/docs/plugins/experimental-backend), +you can add them as a plugin modules: + +- To use `Catalog` GraphQL module ```ts // packages/backend/src/index.ts import { graphqlModuleCatalog } from '@frontside/backstage-plugin-graphql-backend-module-catalog'; const backend = createBackend(); - -// GraphQL -backend.use(graphqlPlugin()); backend.use(graphqlModuleCatalog()); ``` -### Relation module - -If you don't want to use basic Catalog types for some reason, but -still want to use `@relation` directive, you can install `Relation` module - +- To use `Relation` GraphQL module ```ts -// packages/backend/src/index.ts import { graphqlModuleRelationResolver } from '@frontside/backstage-plugin-graphql-backend-module-catalog'; const backend = createBackend(); - -// GraphQL -backend.use(graphqlPlugin()); backend.use(graphqlModuleRelationResolver()); ``` +If you don't want to use basic Catalog types for some reason, but +still want to use `@relation` directive, you can install `Relation` module + ## Directives API ### `@relation` diff --git a/plugins/graphql-backend/README.md b/plugins/graphql-backend/README.md index 18c7d20257..ca2869ebbb 100644 --- a/plugins/graphql-backend/README.md +++ b/plugins/graphql-backend/README.md @@ -8,20 +8,29 @@ It uses [GraphQL Modules][graphql-modules] and [Envelop][] plugins so you can compose pieces of schema and middleware from many different places (including other plugins) into a single, complete GraphQL server. -At a minimum, you should install the [graphql-catalog][] which adds basic +At a minimum, you should install the [graphql-backend-module-catalog][] which adds basic schema elements to access the [Backstage Catalog][backstage-catalog] via GraphQL -- [Getting started](#getting-started) -- [GraphQL Modules](#graphql-modules) -- [Extending Schema](../graphql-common/README.md#extending-your-schema-with-a-custom-module) -- [Envelop Plugins](#envelop-plugins) -- [GraphQL Context](#graphql-context) -- [Custom Data loaders](#custom-data-loaders-advanced) +- [Backstage Plugins](./docs/backend-plugins.md#getting-started) +- [Experimental Backend System](#experimental-backend-system) + - [Getting started](#getting-started) + - [GraphQL Modules](#graphql-modules) + - [Custom GraphQL Module](#custom-graphql-module) + - [Envelop Plugins](#envelop-plugins) + - [GraphQL Context](#graphql-context) + - [Custom Data loaders](#custom-data-loaders-advanced) +- [Extending Schema](https://github.com/thefrontside/HydraphQL/blob/main/README.md#extending-your-schema-with-a-custom-module) - [Integrations](#integrations) - [Backstage GraphiQL Plugin](#backstage-graphiql-plugin) - [Backstage API Docs](#backstage-api-docs) +- [Questions](#questions) -## Getting Started +## Experimental Backend System + +This approach is suitable for the new [Backstage backend system](https://backstage.io/docs/backend-system/). +For the current [Backstage plugins system](https://backstage.io/docs/plugins/backend-plugin) see [Backstage Plugins](./docs/backend-plugins.md#getting-started) + +### Getting Started To install the GraphQL Backend onto your server: @@ -45,13 +54,13 @@ yarn workspace example-backend start This will launch the full example backend. However, without any modules installed, you won't be able to do much with it. -## GraphQL Modules +### GraphQL Modules The way to add new types and new resolvers to your GraphQL backend is with [GraphQL Modules][graphql-modules]. These are portable little bundles of schema that you can drop into place and have them extend your GraphQL server. The most important of these that is maintained by -the Backstage team is the [graphql-catalog][] module that makes your +the Backstage team is the [graphql-backend-module-catalog][] module that makes your Catalog accessible via GraphQL. Add this module to your backend: ```ts @@ -65,9 +74,85 @@ backend.use(graphqlPlugin()); backend.use(graphqlModuleCatalog()); ``` -To learn more about adding your own modules, see the [graphql-common][] package. +#### Custom GraphQL Module + +To learn more about adding your own modules, see the [HydraphQL][] package. + +To extend your schema, you will define it using the GraphQL Schema Definition +Language, and then (optionally) write resolvers to handle the various types +which you defined. + +1. Create modules directory where you'll store all your GraphQL modules, for example in `packages/backend/src/modules` +1. Create a module directory `my-module` there +1. Create a GraphQL schema file `my-module.graphql` in the module directory + +```graphql +extend type Query { + hello: String! +} +``` + +This code adds a `hello` field to the global `Query` type. Next, we are going to +write a module containing this schema and its resolvers. + +4. Create a GraphQL module file `my-module.ts` in the module directory + +```ts +import { resolvePackagePath } from "@backstage/backend-common"; +import { loadFilesSync } from "@graphql-tools/load-files"; +import { createModule } from "graphql-modules"; + +export const myModule = createModule({ + id: "my-module", + dirname: resolvePackagePath("backend", "src/modules/my-module"), + typeDefs: loadFilesSync( + resolvePackagePath("backend", "src/modules/my-module/my-module.graphql"), + ), + resolvers: { + Query: { + hello: () => "world", + }, + }, +}); +``` + +5. Now we can pass your GraphQL module to GraphQL Application backend + module -## Envelop Plugins +```ts +// packages/backend/src/modules/graphqlMyModule.ts +import { createBackendModule } from "@backstage/backend-plugin-api"; +import { graphqlModulesExtensionPoint } from "@backstage/plugin-graphql-backend-node"; +import { MyModule } from "../modules/my-module/my-module"; + +export const graphqlModuleMyModule = createBackendModule({ + pluginId: "graphql", + moduleId: "myModule", + register(env) { + env.registerInit({ + deps: { modules: graphqlModulesExtensionPoint }, + async init({ modules }) { + await modules.addModules([MyModule]); + }, + }); + }, +}); +``` + +6. And then add it to your backend + +```ts +// packages/backend/src/index.ts +import { graphqlModuleMyModule } from "./modules/graphqlMyModule"; + +const backend = createBackend(); + +// GraphQL +backend.use(graphqlPlugin()); +backend.use(graphqlModuleMyModule()); +``` + +### Envelop Plugins Whereas [Graphql Modules][graphql-modules] are used to extend the schema and resolvers of your GraphQL server, [Envelop][] plugins are @@ -112,7 +197,7 @@ backend.use(graphqlPlugin()); backend.use(graphqlModulePlugins()); ``` -## GraphQL Context +### GraphQL Context The GraphQL context is an object that is passed to every resolver function. It is a convenient place to store data that is needed by @@ -139,7 +224,7 @@ export const graphqlModuleContext = createBackendModule({ }); ``` -## Custom Data Loaders (Advanced) +### Custom Data Loaders (Advanced) By default, your graphql context will contain a `Dataloader` for retrieving records from the Catalog by their GraphQL ID. Most of the time this is all you @@ -162,6 +247,7 @@ source name // packages/backend/src/modules/graphqlLoaders.ts import { createBackendModule } from '@backstage/backend-plugin-api'; import { graphqlLoadersExtensionPoint } from '@frontside/backstage-plugin-graphql-backend-node'; +import { NodeQuery } from '@frontside/hydraphql'; export const graphqlModuleLoaders = createBackendModule({ pluginId: 'graphql', @@ -172,12 +258,12 @@ export const graphqlModuleLoaders = createBackendModule({ async init({ loaders }) { loaders.addLoaders({ ProjectAPI: async ( - keys: readonly string[], + queries: readonly NodeQuery[], context: GraphQLContext, ) => { /* Fetch */ }, - TaskAPI: async (keys: readonly string[], context: GraphQLContext) => { + TaskAPI: async (queries: readonly NodeQuery[], context: GraphQLContext) => { /* Fetch */ }, }); @@ -193,14 +279,14 @@ Then you can use `@resolve` directive in your GraphQL schemas interface Node @discriminates(with: "__source") @discriminationAlias(value: "Project", type: "ProjectAPI") - @discriminationAlias(value: "Tasks", type: "TaskAPI") + @discriminationAlias(value: "Task", type: "TaskAPI") type Project @implements(interface "Node") { - tasks: Tasks @resolve(at: "spec.projectId", from: "TaskAPI") + tasks: [Task] @resolve(at: "spec.projectId", from: "TaskAPI") } -type Tasks @implements(interface "Node") { - list: [Task!] @field(at: "tasks") +type Task @implements(interface "Node") { + # ... } ``` @@ -265,10 +351,64 @@ backend: - host: localhost:7007 ``` +## Questions + +### Why was my `union` type transformed to an interface in output schema? + +You might notice that if you have a `union` type which is used in +`@relation` directive with `Connection` type, like this: + +```graphql +union Owner = User | Group + +type Resource @implements(interface: "Entity") { + owners: Connection! @relation(name: "ownedBy", nodeType: "Owner") +} +``` + +In output schema you'll get: + +```graphql +interface Owner implements Node { + id: ID! +} + +type OwnerConnection implements Connection { + pageInfo: PageInfo! + edges: [OwnerEdge!]! + count: Int +} + +type OwnerEdge implements Edge { + cursor: String! + node: Owner! +} + +type User implements Entity & Node & Owner { + # ... +} + +type Group implements Entity & Node & Owner { + # ... +} +``` + +The reason why we do that, is because `Edge` interface has a `node` +field with `Node` type. So it forces that any object types that +implement `Edge` interface must have the `node` field with the type +that implements `Node` interface. And unions can't implement +interfaces yet +([graphql/graphql-spec#518](https://github.com/graphql/graphql-spec/issues/518)) +So you just simply can't use unions in such case. As a workaround we +change a union to an interface that implements `Node` and each type +that was used in the union, now implements the new interface. To an +end user there is no difference between a union and interface +approach, both variants work similar. + [graphql]: https://graphql.org [envelop]: https://the-guild.dev/graphql/envelop [graphql-modules]: https://the-guild.dev/graphql/modules -[graphql-catalog]: ../graphql-catalog/README.md -[graphql-common]: ../graphql-common/README.md +[graphql-backend-module-catalog]: ../graphql-backend-module-catalog/README.md +[HydraphQL]: https://github.com/thefrontside/HydraphQL/blob/main/README.md [backstage-catalog]: https://backstage.io/docs/features/software-catalog/software-catalog-overview [usemaskederrors]: https://the-guild.dev/graphql/envelop/plugins/use-masked-errors diff --git a/plugins/graphql-backend/docs/backend-plugins.md b/plugins/graphql-backend/docs/backend-plugins.md new file mode 100644 index 0000000000..2615c8afd1 --- /dev/null +++ b/plugins/graphql-backend/docs/backend-plugins.md @@ -0,0 +1,197 @@ +# GraphQL Backend + +- [Getting started](#getting-started) +- [GraphQL Modules](#graphql-modules) +- [Envelop Plugins](#envelop-plugins) +- [GraphQL Context](#graphql-context) +- [Custom Data loaders](#custom-data-loaders-advanced) + +## Getting Started + +If you are using the [Backstage plugin system](https://backstage.io/docs/plugins/backend-plugin), +then you can install the GraphQL Backend as a plugin + +1. Create a GraphQL plugin in your backend: + +```ts +// packages/backend/src/plugins/graphql.ts +import { createRouter } from '@frontside/backstage-plugin-graphql-backend'; +import { Router } from 'express'; +import { PluginEnvironment } from '../types'; + +export default async function createPlugin( + env: PluginEnvironment, +): Promise { + return await createRouter({ + logger: env.logger, + }); +} +``` + +2. Add a route for the GraphQL plugin: + +```ts +// packages/backend/src/index.ts +import graphql from './plugins/graphql'; + +// ... +async function main() { + // ... + const graphqlEnv = useHotMemoize(module, () => createEnv('graphql')); + apiRouter.use('/graphql', await graphql(graphqlEnv)); +} +``` + +3. Start the backend + +```bash +yarn workspace example-backend start +``` + +This will launch the full example backend. However, without any modules +installed, you won't be able to do much with it. + +## GraphQL Modules + +The way to add new types and new resolvers to your GraphQL backend is +with [GraphQL Modules][graphql-modules]. These are portable little +bundles of schema that you can drop into place and have them extend +your GraphQL server. The most important of these that is maintained by +the Backstage team is the [graphql-backend-module-catalog][] module that makes your +Catalog accessible via GraphQL. To add this module to your GraphQL server, +add it to the `modules` array in your backend config: + +```ts +import { createRouter } from '@frontside/backstage-plugin-graphql-backend'; +import { Catalog } from '@frontside/backstage-plugin-graphql-backend-module-catalog'; + +// packages/backend/src/plugins/graphql.ts +export default async function createPlugin( + env: PluginEnvironment, +): Promise { + return await createRouter({ + logger: env.logger, + modules: [Catalog], + }); +} +``` + +To learn more about adding your own modules, see the [hydraphql][] package. + +## Envelop Plugins + +Whereas [Graphql Modules][graphql-modules] are used to extend the +schema and resolvers of your GraphQL server, [Envelop][] plugins are +used to extend its GraphQL stack with tracing, error handling, context +extensions, and other middlewares. + +Plugins are be added via declaring GraphQL Yoga backend module. +For example, to prevent potentially sensitive error messages from +leaking to your client in production, add the [`useMaskedErrors`][usemaskederrors] +plugin. + +```ts +import { createRouter } from '@frontside/backstage-plugin-graphql-backend'; +import { useMaskedErrors } from '@envelop/core'; + +// packages/backend/src/plugins/graphql.ts +export default async function createPlugin( + env: PluginEnvironment, +): Promise { + return await createRouter({ + logger: env.logger, + plugins: [useMaskedErrors()], + }); +} +``` + +## GraphQL Context + +The GraphQL context is an object that is passed to every resolver +function. It is a convenient place to store data that is needed by +multiple resolvers, such as a database connection or a logger. + +You can add additional data to the context to GraphQL Yoga backend module: + +```ts +import { createRouter } from '@frontside/backstage-plugin-graphql-backend'; + +// packages/backend/src/plugins/graphql.ts +export default async function createPlugin( + env: PluginEnvironment, +): Promise { + return await createRouter({ + logger: env.logger, + context: { myContext: 'Hello World' }, + }); +} +``` + +## Custom Data Loaders (Advanced) + +By default, your graphql context will contain a `Dataloader` for retrieving +records from the Catalog by their GraphQL ID. Most of the time this is all you +will need. However, sometimes you will need to load data not just from the +Backstage catalog, but from a different data source entirely. To do this, you +will need to pass batch load functions for each data source. + +> ⚠️Caution! If you find yourself wanting to load data directly from a +> source other than the catalog, first consider the idea of instead +> just ingesting that data into the catalog, and then using the +> default data loader. After consideration, If you still want to load +> data directly from a source other than the Backstage catalog, then +> proceed with care. + +Load functions are to GraphQL Yoga backend module. Each load function +is stored under a unique key which is encoded inside node's id as a data +source name + +```ts +import { createRouter } from '@frontside/backstage-plugin-graphql-backend'; +import { NodeQuery } from '@frontside/hydraphql'; + +// packages/backend/src/plugins/graphql.ts +export default async function createPlugin( + env: PluginEnvironment, +): Promise { + return await createRouter({ + logger: env.logger, + loaders: { + ProjectAPI: async ( + queries: readonly NodeQuery[], + context: GraphQLContext, + ) => { + /* Fetch */ + }, + TaskAPI: async (queries: readonly NodeQuery[], context: GraphQLContext) => { + /* Fetch */ + }, + }, + }); +} +``` + +Then you can use `@resolve` directive in your GraphQL schemas + +```graphql +interface Node + @discriminates(with: "__source") + @discriminationAlias(value: "Project", type: "ProjectAPI") + @discriminationAlias(value: "Task", type: "TaskAPI") + +type Project @implements(interface "Node") { + tasks: [Task] @resolve(at: "spec.projectId", from: "TaskAPI") +} + +type Task @implements(interface "Node") { + # ... +} +``` + +[graphql]: https://graphql.org +[envelop]: https://the-guild.dev/graphql/envelop +[graphql-modules]: https://the-guild.dev/graphql/modules +[graphql-catalog]: ../graphql-backend-module-catalog/README.md +[hydraphql]: https://github.com/thefrontside/HydraphQL/blob/main/README.md +[backstage-catalog]: https://backstage.io/docs/features/software-catalog/software-catalog-overview +[usemaskederrors]: https://the-guild.dev/graphql/envelop/plugins/use-masked-errors