diff --git a/.changeset/selfish-kiwis-work.md b/.changeset/selfish-kiwis-work.md new file mode 100644 index 0000000000..c75af3a617 --- /dev/null +++ b/.changeset/selfish-kiwis-work.md @@ -0,0 +1,8 @@ +--- +'@frontside/backstage-plugin-graphql-backend-module-catalog': minor +'@frontside/backstage-plugin-graphql-backend-node': patch +'@frontside/backstage-plugin-graphql-backend': patch +'backend': patch +--- + +Add GraphQL `entities` query with generated filter input types diff --git a/packages/backend/src/plugins/graphql.ts b/packages/backend/src/plugins/graphql.ts index 4d333db13f..c60373ce3a 100644 --- a/packages/backend/src/plugins/graphql.ts +++ b/packages/backend/src/plugins/graphql.ts @@ -11,5 +11,6 @@ export default async function createPlugin( logger: env.logger, modules: [myModule, Catalog()], loaders: { ...createCatalogLoader(env.catalog) }, + context: (ctx) => ({ ...ctx, catalog: env.catalog }), }); } diff --git a/plugins/graphql-backend-module-catalog/package.json b/plugins/graphql-backend-module-catalog/package.json index 65e43b5c18..ebd105d1dd 100644 --- a/plugins/graphql-backend-module-catalog/package.json +++ b/plugins/graphql-backend-module-catalog/package.json @@ -36,7 +36,7 @@ "test": "backstage-cli package test", "prepack": "backstage-cli package prepack", "postpack": "backstage-cli package postpack", - "generate:types": "graphql-codegen -r ts-node/register", + "generate": "graphql-codegen -r ts-node/register", "clean": "backstage-cli package clean" }, "dependencies": { @@ -47,14 +47,15 @@ "@backstage/plugin-auth-node": "^0.4.1", "@backstage/plugin-catalog-node": "^1.5.0", "@frontside/backstage-plugin-graphql-backend": "^0.1.7", - "@frontside/hydraphql": "^0.1.2", + "@frontside/hydraphql": "^0.1.3", "@graphql-tools/load-files": "^7.0.0", "@graphql-tools/utils": "^10.0.0", "dataloader": "^2.1.0", "graphql": "^16.6.0", "graphql-modules": "^2.3.0", "graphql-relay": "^0.10.0", - "graphql-type-json": "^0.3.2" + "graphql-type-json": "^0.3.2", + "lodash": "^4.17.21" }, "devDependencies": { "@backstage/cli": "^0.25.2", 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 c3a69184cc..79ac4800d2 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 @@ -13,6 +13,8 @@ directive @relation(kind: String, name: String, nodeType: String) on FIELD_DEFIN directive @resolve(at: _DirectiveArgument_, from: String, nodeType: String) on FIELD_DEFINITION +directive @sourceType(name: String!) on FIELD_DEFINITION + interface API implements Entity & Node & Ownable { annotations: [KeyValuePair!] apiConsumedBy(after: String, before: String, first: Int, last: Int): ComponentConnection @@ -28,6 +30,7 @@ interface API implements Entity & Node & Ownable { name: String! namespace: String! owner: Owner! + relations: [Relation!] system: System tags: [String!] title: String @@ -60,6 +63,7 @@ type AsyncAPI implements API & Entity & Node & Ownable { name: String! namespace: String! owner: Owner! + relations: [Relation!] system: System tags: [String!] title: String @@ -83,6 +87,7 @@ interface Component implements Dependency & Entity & Node & Ownable { namespace: String! owner: Owner! providesApis(after: String, before: String, first: Int, last: Int): APIConnection + relations: [Relation!] subComponentOf: Component system: System tags: [String!] @@ -120,6 +125,7 @@ type Database implements Dependency & Entity & Node & Ownable & Resource { name: String! namespace: String! owner: Owner! + relations: [Relation!] system: System tags: [String!] title: String @@ -141,6 +147,7 @@ type Department implements Entity & Group & Node { ownerOf(after: String, before: String, first: Int, last: Int): OwnableConnection parent: Group profile: GroupProfile + relations: [Relation!] tags: [String!] title: String type: String! @@ -172,6 +179,7 @@ type Domain implements Entity & Node & Ownable { name: String! namespace: String! owner: Owner! + relations: [Relation!] systems(after: String, before: String, first: Int, last: Int): SystemConnection tags: [String!] title: String @@ -192,10 +200,115 @@ interface Entity implements Node { links: [EntityLink!] name: String! namespace: String! + relations: [Relation!] tags: [String!] title: String } +type EntityConnection implements Connection { + count: Int + edges: [EntityEdge!]! + pageInfo: PageInfo! +} + +type EntityEdge implements Edge { + cursor: String! + node: Entity! +} + +""" +{ + order: [ + { fieldA: ASC } + { fieldB: DESC } + { fieldC: [{ fieldD: ASC }] } + { fieldE: { order: ASC } } + { + fieldE: { + fields: [{ fieldF: DESC }, { fieldG: ASC }] + } + } + { annotations: [{ field: "backstage.io/source-location", order: ASC }] } + ] + search: { + term: "substring" + fields: { + fieldA: true + fieldB: true + fieldC: { fieldD: true } + fieldE: { + include: true + fields: { fieldF: true, fieldG: true } + } + } + } + match: [ + { fieldA: ["value1", "value2"], fieldB: ["value3"] } + { fieldC: { fieldD: ["value4"] } } + { + fieldE: { + values: ["value5", "value6"], + fields: { fieldF: ["value7"], fieldG: ["value8"] } + } + } + ] +} +""" +input EntityFilter { + match: [EntityFilterExpression!] + order: [EntityOrderField!] + search: EntityTextFilter +} + +input EntityFilterExpression { + annotations: [EntityRawFilterField!] + apiVersion: [JSON!] + definition: [JSON!] + description: [JSON!] + kind: [JSON!] + labels: [EntityRawFilterField!] + lifecycle: [JSON!] + links: EntityFilterExpression_Links + name: [JSON!] + namespace: [JSON!] + parameters: [EntityRawFilterField!] + presence: [JSON!] + profile: EntityFilterExpression_Profile + relations: EntityFilterExpression_Relations + steps: EntityFilterExpression_Steps + tags: [JSON!] + target: [JSON!] + targets: [JSON!] + title: [JSON!] + type: [JSON!] +} + +input EntityFilterExpression_Links { + icon: [JSON!] + title: [JSON!] + type: [JSON!] + url: [JSON!] +} + +input EntityFilterExpression_Profile { + displayName: [JSON!] + email: [JSON!] + picture: [JSON!] +} + +input EntityFilterExpression_Relations { + targetRef: [JSON!] + type: [JSON!] +} + +input EntityFilterExpression_Steps { + action: [JSON!] + id: [JSON!] + if: [JSON!] + input: [EntityRawFilterField!] + name: [JSON!] +} + type EntityLink { icon: String title: String @@ -203,6 +316,140 @@ type EntityLink { url: String! } +input EntityOrderField { + annotations: [EntityRawOrderField!] + apiVersion: OrderDirection + definition: OrderDirection + description: OrderDirection + kind: OrderDirection + labels: [EntityRawOrderField!] + lifecycle: OrderDirection + links: [EntityOrderField_Links!] + name: OrderDirection + namespace: OrderDirection + parameters: [EntityRawOrderField!] + presence: OrderDirection + profile: [EntityOrderField_Profile!] + relations: [EntityOrderField_Relations!] + steps: [EntityOrderField_Steps!] + tags: OrderDirection + target: OrderDirection + targets: OrderDirection + title: OrderDirection + type: OrderDirection +} + +input EntityOrderField_Links { + icon: OrderDirection + title: OrderDirection + type: OrderDirection + url: OrderDirection +} + +input EntityOrderField_Profile { + displayName: OrderDirection + email: OrderDirection + picture: OrderDirection +} + +input EntityOrderField_Relations { + targetRef: OrderDirection + type: OrderDirection +} + +input EntityOrderField_Steps { + action: OrderDirection + id: OrderDirection + if: OrderDirection + input: [EntityRawOrderField!] + name: OrderDirection +} + +input EntityRawFilter { + filter: [EntityRawFilterExpression!] + fullTextFilter: EntityRawTextFilter + orderFields: [EntityRawOrderField!] +} + +input EntityRawFilterExpression { + fields: [EntityRawFilterField!]! +} + +input EntityRawFilterField { + key: String! + values: [JSON!]! +} + +input EntityRawOrderField { + field: String! + order: OrderDirection! +} + +input EntityRawTextFilter { + fields: [String!] + term: String! +} + +type EntityRef { + kind: String! + name: String! + namespace: String! +} + +input EntityTextFilter { + fields: EntityTextFilterFields + term: String! +} + +input EntityTextFilterFields { + annotations: [String!] + apiVersion: Boolean + definition: Boolean + description: Boolean + kind: Boolean + labels: [String!] + lifecycle: Boolean + links: EntityTextFilterFields_Links + name: Boolean + namespace: Boolean + parameters: [String!] + presence: Boolean + profile: EntityTextFilterFields_Profile + relations: EntityTextFilterFields_Relations + steps: EntityTextFilterFields_Steps + tags: Boolean + target: Boolean + targets: Boolean + title: Boolean + type: Boolean +} + +input EntityTextFilterFields_Links { + icon: Boolean + title: Boolean + type: Boolean + url: Boolean +} + +input EntityTextFilterFields_Profile { + displayName: Boolean + email: Boolean + picture: Boolean +} + +input EntityTextFilterFields_Relations { + targetRef: Boolean + type: Boolean +} + +input EntityTextFilterFields_Steps { + action: Boolean + id: Boolean + if: Boolean + input: [String!] + name: Boolean +} + type FileLocation implements Entity & Location & Node { annotations: [KeyValuePair!] apiVersion: String! @@ -214,6 +461,7 @@ type FileLocation implements Entity & Location & Node { name: String! namespace: String! presence: String + relations: [Relation!] tags: [String!] target: String targets: [String!] @@ -236,6 +484,7 @@ type GRPC implements API & Entity & Node & Ownable { name: String! namespace: String! owner: Owner! + relations: [Relation!] system: System tags: [String!] title: String @@ -257,6 +506,7 @@ type GraphQL implements API & Entity & Node & Ownable { name: String! namespace: String! owner: Owner! + relations: [Relation!] system: System tags: [String!] title: String @@ -278,6 +528,7 @@ interface Group implements Entity & Node { ownerOf(after: String, before: String, first: Int, last: Int): OwnableConnection parent: Group profile: GroupProfile + relations: [Relation!] tags: [String!] title: String type: String! @@ -332,6 +583,7 @@ type Library implements Component & Dependency & Entity & Node & Ownable { namespace: String! owner: Owner! providesApis(after: String, before: String, first: Int, last: Int): APIConnection + relations: [Relation!] subComponentOf: Component system: System tags: [String!] @@ -350,6 +602,7 @@ interface Location implements Entity & Node { name: String! namespace: String! presence: String + relations: [Relation!] tags: [String!] target: String targets: [String!] @@ -376,6 +629,7 @@ type OpaqueAPI implements API & Entity & Node & Ownable { name: String! namespace: String! owner: Owner! + relations: [Relation!] system: System tags: [String!] title: String @@ -399,6 +653,7 @@ type OpaqueComponent implements Component & Dependency & Entity & Node & Ownable namespace: String! owner: Owner! providesApis(after: String, before: String, first: Int, last: Int): APIConnection + relations: [Relation!] subComponentOf: Component system: System tags: [String!] @@ -416,6 +671,7 @@ type OpaqueEntity implements Entity & Node { links: [EntityLink!] name: String! namespace: String! + relations: [Relation!] tags: [String!] title: String } @@ -435,6 +691,7 @@ type OpaqueGroup implements Entity & Group & Node { ownerOf(after: String, before: String, first: Int, last: Int): OwnableConnection parent: Group profile: GroupProfile + relations: [Relation!] tags: [String!] title: String type: String! @@ -451,6 +708,7 @@ type OpaqueLocation implements Entity & Location & Node { name: String! namespace: String! presence: String + relations: [Relation!] tags: [String!] target: String targets: [String!] @@ -471,6 +729,7 @@ type OpaqueResource implements Dependency & Entity & Node & Ownable & Resource { name: String! namespace: String! owner: Owner! + relations: [Relation!] system: System tags: [String!] title: String @@ -489,6 +748,7 @@ type OpaqueTemplate implements Entity & Node & Ownable & Template { namespace: String! owner: Owner parameters: JSONObject + relations: [Relation!] steps: [Step!]! tags: [String!] title: String @@ -510,12 +770,18 @@ type OpenAPI implements API & Entity & Node & Ownable { name: String! namespace: String! owner: Owner! + relations: [Relation!] system: System tags: [String!] title: String type: String! } +enum OrderDirection { + ASC + DESC +} + type Organization implements Entity & Group & Node { annotations: [KeyValuePair!] apiVersion: String! @@ -531,6 +797,7 @@ type Organization implements Entity & Group & Node { ownerOf(after: String, before: String, first: Int, last: Int): OwnableConnection parent: Group profile: GroupProfile + relations: [Relation!] tags: [String!] title: String type: String! @@ -561,11 +828,17 @@ type PageInfo { } type Query { + entities(after: String, before: String, filter: EntityFilter, first: Int, last: Int, rawFilter: EntityRawFilter): EntityConnection entity(kind: String!, name: String!, namespace: String): Entity node(id: ID!): Node nodes(ids: [ID!]!): [Node]! } +type Relation { + targetRef: EntityRef + type: String! +} + interface Resource implements Dependency & Entity & Node & Ownable { annotations: [KeyValuePair!] apiVersion: String! @@ -579,6 +852,7 @@ interface Resource implements Dependency & Entity & Node & Ownable { name: String! namespace: String! owner: Owner! + relations: [Relation!] system: System tags: [String!] title: String @@ -613,6 +887,7 @@ type Service implements Component & Dependency & Entity & Node & Ownable { namespace: String! owner: Owner! providesApis(after: String, before: String, first: Int, last: Int): APIConnection + relations: [Relation!] subComponentOf: Component system: System tags: [String!] @@ -620,6 +895,25 @@ type Service implements Component & Dependency & Entity & Node & Ownable { type: String! } +type ServiceTemplate implements Entity & Node & Ownable & Template { + annotations: [KeyValuePair!] + apiVersion: String! + description: String + id: ID! + kind: String! + labels: [KeyValuePair!] + links: [EntityLink!] + name: String! + namespace: String! + owner: Owner + parameters: JSONObject + relations: [Relation!] + steps: [Step!]! + tags: [String!] + title: String + type: String! +} + type Step { action: String! id: String @@ -643,6 +937,7 @@ type SubDepartment implements Entity & Group & Node { ownerOf(after: String, before: String, first: Int, last: Int): OwnableConnection parent: Group profile: GroupProfile + relations: [Relation!] tags: [String!] title: String type: String! @@ -662,6 +957,7 @@ type System implements Entity & Node & Ownable { name: String! namespace: String! owner: Owner! + relations: [Relation!] resources(after: String, before: String, first: Int, last: Int): ResourceConnection tags: [String!] title: String @@ -693,6 +989,7 @@ type Team implements Entity & Group & Node { ownerOf(after: String, before: String, first: Int, last: Int): OwnableConnection parent: Group profile: GroupProfile + relations: [Relation!] tags: [String!] title: String type: String! @@ -710,6 +1007,7 @@ interface Template implements Entity & Node & Ownable { namespace: String! owner: Owner parameters: JSONObject + relations: [Relation!] steps: [Step!]! tags: [String!] title: String @@ -727,6 +1025,7 @@ type URLLocation implements Entity & Location & Node { name: String! namespace: String! presence: String + relations: [Relation!] tags: [String!] target: String targets: [String!] @@ -747,6 +1046,7 @@ type User implements Entity & Node { namespace: String! ownerOf(after: String, before: String, first: Int, last: Int): OwnableConnection profile: UserProfile + relations: [Relation!] tags: [String!] title: String } @@ -785,6 +1085,7 @@ type Website implements Component & Dependency & Entity & Node & Ownable { namespace: String! owner: Owner! providesApis(after: String, before: String, first: Int, last: Int): APIConnection + relations: [Relation!] subComponentOf: Component system: System tags: [String!] @@ -837,6 +1138,7 @@ export type Api = { name: Scalars['String']['output']; namespace: Scalars['String']['output']; owner: Owner; + relations?: Maybe>; system?: Maybe; tags?: Maybe>; title?: Maybe; @@ -888,6 +1190,7 @@ export type AsyncApi = Api & Entity & Node & Ownable & { name: Scalars['String']['output']; namespace: Scalars['String']['output']; owner: Owner; + relations?: Maybe>; system?: Maybe; tags?: Maybe>; title?: Maybe; @@ -927,6 +1230,7 @@ export type Component = { namespace: Scalars['String']['output']; owner: Owner; providesApis?: Maybe; + relations?: Maybe>; subComponentOf?: Maybe; system?: Maybe; tags?: Maybe>; @@ -1007,6 +1311,7 @@ export type Database = Dependency & Entity & Node & Ownable & Resource & { name: Scalars['String']['output']; namespace: Scalars['String']['output']; owner: Owner; + relations?: Maybe>; system?: Maybe; tags?: Maybe>; title?: Maybe; @@ -1045,6 +1350,7 @@ export type Department = Entity & Group & Node & { ownerOf?: Maybe; parent?: Maybe; profile?: Maybe; + relations?: Maybe>; tags?: Maybe>; title?: Maybe; type: Scalars['String']['output']; @@ -1103,6 +1409,7 @@ export type Domain = Entity & Node & Ownable & { name: Scalars['String']['output']; namespace: Scalars['String']['output']; owner: Owner; + relations?: Maybe>; systems?: Maybe; tags?: Maybe>; title?: Maybe; @@ -1131,10 +1438,117 @@ export type Entity = { links?: Maybe>; name: Scalars['String']['output']; namespace: Scalars['String']['output']; + relations?: Maybe>; tags?: Maybe>; title?: Maybe; }; +export type EntityConnection = Connection & { + __typename?: 'EntityConnection'; + count?: Maybe; + edges: Array; + pageInfo: PageInfo; +}; + +export type EntityEdge = Edge & { + __typename?: 'EntityEdge'; + cursor: Scalars['String']['output']; + node: Entity; +}; + +/** + * { + * order: [ + * { fieldA: ASC } + * { fieldB: DESC } + * { fieldC: [{ fieldD: ASC }] } + * { fieldE: { order: ASC } } + * { + * fieldE: { + * fields: [{ fieldF: DESC }, { fieldG: ASC }] + * } + * } + * { annotations: [{ field: "backstage.io/source-location", order: ASC }] } + * ] + * search: { + * term: "substring" + * fields: { + * fieldA: true + * fieldB: true + * fieldC: { fieldD: true } + * fieldE: { + * include: true + * fields: { fieldF: true, fieldG: true } + * } + * } + * } + * match: [ + * { fieldA: ["value1", "value2"], fieldB: ["value3"] } + * { fieldC: { fieldD: ["value4"] } } + * { + * fieldE: { + * values: ["value5", "value6"], + * fields: { fieldF: ["value7"], fieldG: ["value8"] } + * } + * } + * ] + * } + */ +export type EntityFilter = { + match?: InputMaybe>; + order?: InputMaybe>; + search?: InputMaybe; +}; + +export type EntityFilterExpression = { + annotations?: InputMaybe>; + apiVersion?: InputMaybe>; + definition?: InputMaybe>; + description?: InputMaybe>; + kind?: InputMaybe>; + labels?: InputMaybe>; + lifecycle?: InputMaybe>; + links?: InputMaybe; + name?: InputMaybe>; + namespace?: InputMaybe>; + parameters?: InputMaybe>; + presence?: InputMaybe>; + profile?: InputMaybe; + relations?: InputMaybe; + steps?: InputMaybe; + tags?: InputMaybe>; + target?: InputMaybe>; + targets?: InputMaybe>; + title?: InputMaybe>; + type?: InputMaybe>; +}; + +export type EntityFilterExpression_Links = { + icon?: InputMaybe>; + title?: InputMaybe>; + type?: InputMaybe>; + url?: InputMaybe>; +}; + +export type EntityFilterExpression_Profile = { + displayName?: InputMaybe>; + email?: InputMaybe>; + picture?: InputMaybe>; +}; + +export type EntityFilterExpression_Relations = { + targetRef?: InputMaybe>; + type?: InputMaybe>; +}; + +export type EntityFilterExpression_Steps = { + action?: InputMaybe>; + id?: InputMaybe>; + if?: InputMaybe>; + input?: InputMaybe>; + name?: InputMaybe>; +}; + export type EntityLink = { __typename?: 'EntityLink'; icon?: Maybe; @@ -1143,6 +1557,141 @@ export type EntityLink = { url: Scalars['String']['output']; }; +export type EntityOrderField = { + annotations?: InputMaybe>; + apiVersion?: InputMaybe; + definition?: InputMaybe; + description?: InputMaybe; + kind?: InputMaybe; + labels?: InputMaybe>; + lifecycle?: InputMaybe; + links?: InputMaybe>; + name?: InputMaybe; + namespace?: InputMaybe; + parameters?: InputMaybe>; + presence?: InputMaybe; + profile?: InputMaybe>; + relations?: InputMaybe>; + steps?: InputMaybe>; + tags?: InputMaybe; + target?: InputMaybe; + targets?: InputMaybe; + title?: InputMaybe; + type?: InputMaybe; +}; + +export type EntityOrderField_Links = { + icon?: InputMaybe; + title?: InputMaybe; + type?: InputMaybe; + url?: InputMaybe; +}; + +export type EntityOrderField_Profile = { + displayName?: InputMaybe; + email?: InputMaybe; + picture?: InputMaybe; +}; + +export type EntityOrderField_Relations = { + targetRef?: InputMaybe; + type?: InputMaybe; +}; + +export type EntityOrderField_Steps = { + action?: InputMaybe; + id?: InputMaybe; + if?: InputMaybe; + input?: InputMaybe>; + name?: InputMaybe; +}; + +export type EntityRawFilter = { + filter?: InputMaybe>; + fullTextFilter?: InputMaybe; + orderFields?: InputMaybe>; +}; + +export type EntityRawFilterExpression = { + fields: Array; +}; + +export type EntityRawFilterField = { + key: Scalars['String']['input']; + values: Array; +}; + +export type EntityRawOrderField = { + field: Scalars['String']['input']; + order: OrderDirection; +}; + +export type EntityRawTextFilter = { + fields?: InputMaybe>; + term: Scalars['String']['input']; +}; + +export type EntityRef = { + __typename?: 'EntityRef'; + kind: Scalars['String']['output']; + name: Scalars['String']['output']; + namespace: Scalars['String']['output']; +}; + +export type EntityTextFilter = { + fields?: InputMaybe; + term: Scalars['String']['input']; +}; + +export type EntityTextFilterFields = { + annotations?: InputMaybe>; + apiVersion?: InputMaybe; + definition?: InputMaybe; + description?: InputMaybe; + kind?: InputMaybe; + labels?: InputMaybe>; + lifecycle?: InputMaybe; + links?: InputMaybe; + name?: InputMaybe; + namespace?: InputMaybe; + parameters?: InputMaybe>; + presence?: InputMaybe; + profile?: InputMaybe; + relations?: InputMaybe; + steps?: InputMaybe; + tags?: InputMaybe; + target?: InputMaybe; + targets?: InputMaybe; + title?: InputMaybe; + type?: InputMaybe; +}; + +export type EntityTextFilterFields_Links = { + icon?: InputMaybe; + title?: InputMaybe; + type?: InputMaybe; + url?: InputMaybe; +}; + +export type EntityTextFilterFields_Profile = { + displayName?: InputMaybe; + email?: InputMaybe; + picture?: InputMaybe; +}; + +export type EntityTextFilterFields_Relations = { + targetRef?: InputMaybe; + type?: InputMaybe; +}; + +export type EntityTextFilterFields_Steps = { + action?: InputMaybe; + id?: InputMaybe; + if?: InputMaybe; + input?: InputMaybe>; + name?: InputMaybe; +}; + export type FileLocation = Entity & Location & Node & { __typename?: 'FileLocation'; annotations?: Maybe>; @@ -1155,6 +1704,7 @@ export type FileLocation = Entity & Location & Node & { name: Scalars['String']['output']; namespace: Scalars['String']['output']; presence?: Maybe; + relations?: Maybe>; tags?: Maybe>; target?: Maybe; targets?: Maybe>; @@ -1178,6 +1728,7 @@ export type Grpc = Api & Entity & Node & Ownable & { name: Scalars['String']['output']; namespace: Scalars['String']['output']; owner: Owner; + relations?: Maybe>; system?: Maybe; tags?: Maybe>; title?: Maybe; @@ -1216,6 +1767,7 @@ export type GraphQl = Api & Entity & Node & Ownable & { name: Scalars['String']['output']; namespace: Scalars['String']['output']; owner: Owner; + relations?: Maybe>; system?: Maybe; tags?: Maybe>; title?: Maybe; @@ -1253,6 +1805,7 @@ export type Group = { ownerOf?: Maybe; parent?: Maybe; profile?: Maybe; + relations?: Maybe>; tags?: Maybe>; title?: Maybe; type: Scalars['String']['output']; @@ -1326,6 +1879,7 @@ export type Library = Component & Dependency & Entity & Node & Ownable & { namespace: Scalars['String']['output']; owner: Owner; providesApis?: Maybe; + relations?: Maybe>; subComponentOf?: Maybe; system?: Maybe; tags?: Maybe>; @@ -1384,6 +1938,7 @@ export type Location = { name: Scalars['String']['output']; namespace: Scalars['String']['output']; presence?: Maybe; + relations?: Maybe>; tags?: Maybe>; target?: Maybe; targets?: Maybe>; @@ -1411,6 +1966,7 @@ export type OpaqueApi = Api & Entity & Node & Ownable & { name: Scalars['String']['output']; namespace: Scalars['String']['output']; owner: Owner; + relations?: Maybe>; system?: Maybe; tags?: Maybe>; title?: Maybe; @@ -1451,6 +2007,7 @@ export type OpaqueComponent = Component & Dependency & Entity & Node & Ownable & namespace: Scalars['String']['output']; owner: Owner; providesApis?: Maybe; + relations?: Maybe>; subComponentOf?: Maybe; system?: Maybe; tags?: Maybe>; @@ -1509,6 +2066,7 @@ export type OpaqueEntity = Entity & Node & { links?: Maybe>; name: Scalars['String']['output']; namespace: Scalars['String']['output']; + relations?: Maybe>; tags?: Maybe>; title?: Maybe; }; @@ -1529,6 +2087,7 @@ export type OpaqueGroup = Entity & Group & Node & { ownerOf?: Maybe; parent?: Maybe; profile?: Maybe; + relations?: Maybe>; tags?: Maybe>; title?: Maybe; type: Scalars['String']['output']; @@ -1570,6 +2129,7 @@ export type OpaqueLocation = Entity & Location & Node & { name: Scalars['String']['output']; namespace: Scalars['String']['output']; presence?: Maybe; + relations?: Maybe>; tags?: Maybe>; target?: Maybe; targets?: Maybe>; @@ -1591,6 +2151,7 @@ export type OpaqueResource = Dependency & Entity & Node & Ownable & Resource & { name: Scalars['String']['output']; namespace: Scalars['String']['output']; owner: Owner; + relations?: Maybe>; system?: Maybe; tags?: Maybe>; title?: Maybe; @@ -1626,6 +2187,7 @@ export type OpaqueTemplate = Entity & Node & Ownable & Template & { namespace: Scalars['String']['output']; owner?: Maybe; parameters?: Maybe; + relations?: Maybe>; steps: Array; tags?: Maybe>; title?: Maybe; @@ -1648,6 +2210,7 @@ export type OpenApi = Api & Entity & Node & Ownable & { name: Scalars['String']['output']; namespace: Scalars['String']['output']; owner: Owner; + relations?: Maybe>; system?: Maybe; tags?: Maybe>; title?: Maybe; @@ -1670,6 +2233,10 @@ export type OpenApiApiProvidedByArgs = { last?: InputMaybe; }; +export type OrderDirection = + | 'ASC' + | 'DESC'; + export type Organization = Entity & Group & Node & { __typename?: 'Organization'; annotations?: Maybe>; @@ -1686,6 +2253,7 @@ export type Organization = Entity & Group & Node & { ownerOf?: Maybe; parent?: Maybe; profile?: Maybe; + relations?: Maybe>; tags?: Maybe>; title?: Maybe; type: Scalars['String']['output']; @@ -1744,12 +2312,23 @@ export type PageInfo = { export type Query = { __typename?: 'Query'; + entities?: Maybe; entity?: Maybe; node?: Maybe; nodes: Array>; }; +export type QueryEntitiesArgs = { + after?: InputMaybe; + before?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + rawFilter?: InputMaybe; +}; + + export type QueryEntityArgs = { kind: Scalars['String']['input']; name: Scalars['String']['input']; @@ -1766,6 +2345,12 @@ export type QueryNodesArgs = { ids: Array; }; +export type Relation = { + __typename?: 'Relation'; + targetRef?: Maybe; + type: Scalars['String']['output']; +}; + export type Resource = { annotations?: Maybe>; apiVersion: Scalars['String']['output']; @@ -1779,6 +2364,7 @@ export type Resource = { name: Scalars['String']['output']; namespace: Scalars['String']['output']; owner: Owner; + relations?: Maybe>; system?: Maybe; tags?: Maybe>; title?: Maybe; @@ -1832,6 +2418,7 @@ export type Service = Component & Dependency & Entity & Node & Ownable & { namespace: Scalars['String']['output']; owner: Owner; providesApis?: Maybe; + relations?: Maybe>; subComponentOf?: Maybe; system?: Maybe; tags?: Maybe>; @@ -1879,6 +2466,26 @@ export type ServiceProvidesApisArgs = { last?: InputMaybe; }; +export type ServiceTemplate = Entity & Node & Ownable & Template & { + __typename?: 'ServiceTemplate'; + annotations?: Maybe>; + apiVersion: Scalars['String']['output']; + description?: Maybe; + id: Scalars['ID']['output']; + kind: Scalars['String']['output']; + labels?: Maybe>; + links?: Maybe>; + name: Scalars['String']['output']; + namespace: Scalars['String']['output']; + owner?: Maybe; + parameters?: Maybe; + relations?: Maybe>; + steps: Array; + tags?: Maybe>; + title?: Maybe; + type: Scalars['String']['output']; +}; + export type Step = { __typename?: 'Step'; action: Scalars['String']['output']; @@ -1904,6 +2511,7 @@ export type SubDepartment = Entity & Group & Node & { ownerOf?: Maybe; parent?: Maybe; profile?: Maybe; + relations?: Maybe>; tags?: Maybe>; title?: Maybe; type: Scalars['String']['output']; @@ -1948,6 +2556,7 @@ export type System = Entity & Node & Ownable & { name: Scalars['String']['output']; namespace: Scalars['String']['output']; owner: Owner; + relations?: Maybe>; resources?: Maybe; tags?: Maybe>; title?: Maybe; @@ -2006,6 +2615,7 @@ export type Team = Entity & Group & Node & { ownerOf?: Maybe; parent?: Maybe; profile?: Maybe; + relations?: Maybe>; tags?: Maybe>; title?: Maybe; type: Scalars['String']['output']; @@ -2047,6 +2657,7 @@ export type Template = { namespace: Scalars['String']['output']; owner?: Maybe; parameters?: Maybe; + relations?: Maybe>; steps: Array; tags?: Maybe>; title?: Maybe; @@ -2065,6 +2676,7 @@ export type UrlLocation = Entity & Location & Node & { name: Scalars['String']['output']; namespace: Scalars['String']['output']; presence?: Maybe; + relations?: Maybe>; tags?: Maybe>; target?: Maybe; targets?: Maybe>; @@ -2086,6 +2698,7 @@ export type User = Entity & Node & { namespace: Scalars['String']['output']; ownerOf?: Maybe; profile?: Maybe; + relations?: Maybe>; tags?: Maybe>; title?: Maybe; }; @@ -2144,6 +2757,7 @@ export type Website = Component & Dependency & Entity & Node & Ownable & { namespace: Scalars['String']['output']; owner: Owner; providesApis?: Maybe; + relations?: Maybe>; subComponentOf?: Maybe; system?: Maybe; tags?: Maybe>; @@ -2267,16 +2881,16 @@ export type ResolversUnionTypes> = { export type ResolversInterfaceTypes> = { API: ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ); Component: ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ); - Connection: ( ApiConnection ) | ( ComponentConnection ) | ( DependencyConnection ) | ( GroupConnection ) | ( OwnableConnection ) | ( ResourceConnection ) | ( SystemConnection ) | ( UserConnection ); + Connection: ( ApiConnection ) | ( ComponentConnection ) | ( DependencyConnection ) | ( EntityConnection ) | ( GroupConnection ) | ( OwnableConnection ) | ( ResourceConnection ) | ( SystemConnection ) | ( UserConnection ); Dependency: ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ); - Edge: ( ApiEdge ) | ( ComponentEdge ) | ( DependencyEdge ) | ( GroupEdge ) | ( OwnableEdge ) | ( ResourceEdge ) | ( SystemEdge ) | ( UserEdge ); - Entity: ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Department ) | ( Omit & { owner: RefType['Owner'] } ) | ( FileLocation ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( OpaqueEntity ) | ( OpaqueGroup ) | ( OpaqueLocation ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner?: Maybe } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Organization ) | ( Omit & { owner: RefType['Owner'] } ) | ( SubDepartment ) | ( Omit & { owner: RefType['Owner'] } ) | ( Team ) | ( UrlLocation ) | ( User ) | ( Omit & { owner: RefType['Owner'] } ); + Edge: ( ApiEdge ) | ( ComponentEdge ) | ( DependencyEdge ) | ( EntityEdge ) | ( GroupEdge ) | ( OwnableEdge ) | ( ResourceEdge ) | ( SystemEdge ) | ( UserEdge ); + Entity: ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Department ) | ( Omit & { owner: RefType['Owner'] } ) | ( FileLocation ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( OpaqueEntity ) | ( OpaqueGroup ) | ( OpaqueLocation ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner?: Maybe } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Organization ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner?: Maybe } ) | ( SubDepartment ) | ( Omit & { owner: RefType['Owner'] } ) | ( Team ) | ( UrlLocation ) | ( User ) | ( Omit & { owner: RefType['Owner'] } ); Group: ( Department ) | ( OpaqueGroup ) | ( Organization ) | ( SubDepartment ) | ( Team ); Location: ( FileLocation ) | ( OpaqueLocation ) | ( UrlLocation ); - Node: ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Department ) | ( Omit & { owner: RefType['Owner'] } ) | ( FileLocation ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( OpaqueEntity ) | ( OpaqueGroup ) | ( OpaqueLocation ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner?: Maybe } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Organization ) | ( Omit & { owner: RefType['Owner'] } ) | ( SubDepartment ) | ( Omit & { owner: RefType['Owner'] } ) | ( Team ) | ( UrlLocation ) | ( User ) | ( Omit & { owner: RefType['Owner'] } ); - Ownable: ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner?: Maybe } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ); + Node: ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Department ) | ( Omit & { owner: RefType['Owner'] } ) | ( FileLocation ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( OpaqueEntity ) | ( OpaqueGroup ) | ( OpaqueLocation ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner?: Maybe } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Organization ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner?: Maybe } ) | ( SubDepartment ) | ( Omit & { owner: RefType['Owner'] } ) | ( Team ) | ( UrlLocation ) | ( User ) | ( Omit & { owner: RefType['Owner'] } ); + Ownable: ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner?: Maybe } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner?: Maybe } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ); Resource: ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ); - Template: ( Omit & { owner?: Maybe } ); + Template: ( Omit & { owner?: Maybe } ) | ( Omit & { owner?: Maybe } ); }; /** Mapping between all available schema types and the resolvers types */ @@ -2298,7 +2912,32 @@ export type ResolversTypes = { Domain: ResolverTypeWrapper & { owner: ResolversTypes['Owner'] }>; Edge: ResolverTypeWrapper['Edge']>; Entity: ResolverTypeWrapper['Entity']>; + EntityConnection: ResolverTypeWrapper; + EntityEdge: ResolverTypeWrapper; + EntityFilter: EntityFilter; + EntityFilterExpression: EntityFilterExpression; + EntityFilterExpression_Links: EntityFilterExpression_Links; + EntityFilterExpression_Profile: EntityFilterExpression_Profile; + EntityFilterExpression_Relations: EntityFilterExpression_Relations; + EntityFilterExpression_Steps: EntityFilterExpression_Steps; EntityLink: ResolverTypeWrapper; + EntityOrderField: EntityOrderField; + EntityOrderField_Links: EntityOrderField_Links; + EntityOrderField_Profile: EntityOrderField_Profile; + EntityOrderField_Relations: EntityOrderField_Relations; + EntityOrderField_Steps: EntityOrderField_Steps; + EntityRawFilter: EntityRawFilter; + EntityRawFilterExpression: EntityRawFilterExpression; + EntityRawFilterField: EntityRawFilterField; + EntityRawOrderField: EntityRawOrderField; + EntityRawTextFilter: EntityRawTextFilter; + EntityRef: ResolverTypeWrapper; + EntityTextFilter: EntityTextFilter; + EntityTextFilterFields: EntityTextFilterFields; + EntityTextFilterFields_Links: EntityTextFilterFields_Links; + EntityTextFilterFields_Profile: EntityTextFilterFields_Profile; + EntityTextFilterFields_Relations: EntityTextFilterFields_Relations; + EntityTextFilterFields_Steps: EntityTextFilterFields_Steps; FileLocation: ResolverTypeWrapper; GRPC: ResolverTypeWrapper & { owner: ResolversTypes['Owner'] }>; GraphQL: ResolverTypeWrapper & { owner: ResolversTypes['Owner'] }>; @@ -2322,6 +2961,7 @@ export type ResolversTypes = { OpaqueResource: ResolverTypeWrapper & { owner: ResolversTypes['Owner'] }>; OpaqueTemplate: ResolverTypeWrapper & { owner?: Maybe }>; OpenAPI: ResolverTypeWrapper & { owner: ResolversTypes['Owner'] }>; + OrderDirection: OrderDirection; Organization: ResolverTypeWrapper; Ownable: ResolverTypeWrapper['Ownable']>; OwnableConnection: ResolverTypeWrapper; @@ -2329,10 +2969,12 @@ export type ResolversTypes = { Owner: ResolverTypeWrapper['Owner']>; PageInfo: ResolverTypeWrapper; Query: ResolverTypeWrapper<{}>; + Relation: ResolverTypeWrapper; Resource: ResolverTypeWrapper['Resource']>; ResourceConnection: ResolverTypeWrapper; ResourceEdge: ResolverTypeWrapper; Service: ResolverTypeWrapper & { owner: ResolversTypes['Owner'] }>; + ServiceTemplate: ResolverTypeWrapper & { owner?: Maybe }>; Step: ResolverTypeWrapper; String: ResolverTypeWrapper; SubDepartment: ResolverTypeWrapper; @@ -2369,7 +3011,32 @@ export type ResolversParentTypes = { Domain: Omit & { owner: ResolversParentTypes['Owner'] }; Edge: ResolversInterfaceTypes['Edge']; Entity: ResolversInterfaceTypes['Entity']; + EntityConnection: EntityConnection; + EntityEdge: EntityEdge; + EntityFilter: EntityFilter; + EntityFilterExpression: EntityFilterExpression; + EntityFilterExpression_Links: EntityFilterExpression_Links; + EntityFilterExpression_Profile: EntityFilterExpression_Profile; + EntityFilterExpression_Relations: EntityFilterExpression_Relations; + EntityFilterExpression_Steps: EntityFilterExpression_Steps; EntityLink: EntityLink; + EntityOrderField: EntityOrderField; + EntityOrderField_Links: EntityOrderField_Links; + EntityOrderField_Profile: EntityOrderField_Profile; + EntityOrderField_Relations: EntityOrderField_Relations; + EntityOrderField_Steps: EntityOrderField_Steps; + EntityRawFilter: EntityRawFilter; + EntityRawFilterExpression: EntityRawFilterExpression; + EntityRawFilterField: EntityRawFilterField; + EntityRawOrderField: EntityRawOrderField; + EntityRawTextFilter: EntityRawTextFilter; + EntityRef: EntityRef; + EntityTextFilter: EntityTextFilter; + EntityTextFilterFields: EntityTextFilterFields; + EntityTextFilterFields_Links: EntityTextFilterFields_Links; + EntityTextFilterFields_Profile: EntityTextFilterFields_Profile; + EntityTextFilterFields_Relations: EntityTextFilterFields_Relations; + EntityTextFilterFields_Steps: EntityTextFilterFields_Steps; FileLocation: FileLocation; GRPC: Omit & { owner: ResolversParentTypes['Owner'] }; GraphQL: Omit & { owner: ResolversParentTypes['Owner'] }; @@ -2400,10 +3067,12 @@ export type ResolversParentTypes = { Owner: ResolversUnionTypes['Owner']; PageInfo: PageInfo; Query: {}; + Relation: Relation; Resource: ResolversInterfaceTypes['Resource']; ResourceConnection: ResourceConnection; ResourceEdge: ResourceEdge; Service: Omit & { owner: ResolversParentTypes['Owner'] }; + ServiceTemplate: Omit & { owner?: Maybe }; Step: Step; String: Scalars['String']['output']; SubDepartment: SubDepartment; @@ -2464,6 +3133,12 @@ export type ResolveDirectiveArgs = { export type ResolveDirectiveResolver = DirectiveResolverFn; +export type SourceTypeDirectiveArgs = { + name: Scalars['String']['input']; +}; + +export type SourceTypeDirectiveResolver = DirectiveResolverFn; + export type ApiResolvers = { __resolveType: TypeResolveFn<'AsyncAPI' | 'GRPC' | 'GraphQL' | 'OpaqueAPI' | 'OpenAPI', ParentType, ContextType>; annotations?: Resolver>, ParentType, ContextType>; @@ -2480,6 +3155,7 @@ export type ApiResolvers; namespace?: Resolver; owner?: Resolver; + relations?: Resolver>, ParentType, ContextType>; system?: Resolver, ParentType, ContextType>; tags?: Resolver>, ParentType, ContextType>; title?: Resolver, ParentType, ContextType>; @@ -2514,6 +3190,7 @@ export type AsyncApiResolvers; namespace?: Resolver; owner?: Resolver; + relations?: Resolver>, ParentType, ContextType>; system?: Resolver, ParentType, ContextType>; tags?: Resolver>, ParentType, ContextType>; title?: Resolver, ParentType, ContextType>; @@ -2539,6 +3216,7 @@ export type ComponentResolvers; owner?: Resolver; providesApis?: Resolver, ParentType, ContextType, Partial>; + relations?: Resolver>, ParentType, ContextType>; subComponentOf?: Resolver, ParentType, ContextType>; system?: Resolver, ParentType, ContextType>; tags?: Resolver>, ParentType, ContextType>; @@ -2560,7 +3238,7 @@ export type ComponentEdgeResolvers = { - __resolveType: TypeResolveFn<'APIConnection' | 'ComponentConnection' | 'DependencyConnection' | 'GroupConnection' | 'OwnableConnection' | 'ResourceConnection' | 'SystemConnection' | 'UserConnection', ParentType, ContextType>; + __resolveType: TypeResolveFn<'APIConnection' | 'ComponentConnection' | 'DependencyConnection' | 'EntityConnection' | 'GroupConnection' | 'OwnableConnection' | 'ResourceConnection' | 'SystemConnection' | 'UserConnection', ParentType, ContextType>; count?: Resolver, ParentType, ContextType>; edges?: Resolver, ParentType, ContextType>; pageInfo?: Resolver; @@ -2579,6 +3257,7 @@ export type DatabaseResolvers; namespace?: Resolver; owner?: Resolver; + relations?: Resolver>, ParentType, ContextType>; system?: Resolver, ParentType, ContextType>; tags?: Resolver>, ParentType, ContextType>; title?: Resolver, ParentType, ContextType>; @@ -2601,6 +3280,7 @@ export type DepartmentResolvers, ParentType, ContextType, Partial>; parent?: Resolver, ParentType, ContextType>; profile?: Resolver, ParentType, ContextType>; + relations?: Resolver>, ParentType, ContextType>; tags?: Resolver>, ParentType, ContextType>; title?: Resolver, ParentType, ContextType>; type?: Resolver; @@ -2636,6 +3316,7 @@ export type DomainResolvers; namespace?: Resolver; owner?: Resolver; + relations?: Resolver>, ParentType, ContextType>; systems?: Resolver, ParentType, ContextType, Partial>; tags?: Resolver>, ParentType, ContextType>; title?: Resolver, ParentType, ContextType>; @@ -2643,13 +3324,13 @@ export type DomainResolvers = { - __resolveType: TypeResolveFn<'APIEdge' | 'ComponentEdge' | 'DependencyEdge' | 'GroupEdge' | 'OwnableEdge' | 'ResourceEdge' | 'SystemEdge' | 'UserEdge', ParentType, ContextType>; + __resolveType: TypeResolveFn<'APIEdge' | 'ComponentEdge' | 'DependencyEdge' | 'EntityEdge' | 'GroupEdge' | 'OwnableEdge' | 'ResourceEdge' | 'SystemEdge' | 'UserEdge', ParentType, ContextType>; cursor?: Resolver; node?: Resolver; }; export type EntityResolvers = { - __resolveType: TypeResolveFn<'AsyncAPI' | 'Database' | 'Department' | 'Domain' | 'FileLocation' | 'GRPC' | 'GraphQL' | 'Library' | 'OpaqueAPI' | 'OpaqueComponent' | 'OpaqueEntity' | 'OpaqueGroup' | 'OpaqueLocation' | 'OpaqueResource' | 'OpaqueTemplate' | 'OpenAPI' | 'Organization' | 'Service' | 'SubDepartment' | 'System' | 'Team' | 'URLLocation' | 'User' | 'Website', ParentType, ContextType>; + __resolveType: TypeResolveFn<'AsyncAPI' | 'Database' | 'Department' | 'Domain' | 'FileLocation' | 'GRPC' | 'GraphQL' | 'Library' | 'OpaqueAPI' | 'OpaqueComponent' | 'OpaqueEntity' | 'OpaqueGroup' | 'OpaqueLocation' | 'OpaqueResource' | 'OpaqueTemplate' | 'OpenAPI' | 'Organization' | 'Service' | 'ServiceTemplate' | 'SubDepartment' | 'System' | 'Team' | 'URLLocation' | 'User' | 'Website', ParentType, ContextType>; annotations?: Resolver>, ParentType, ContextType>; apiVersion?: Resolver; description?: Resolver, ParentType, ContextType>; @@ -2659,10 +3340,24 @@ export type EntityResolvers>, ParentType, ContextType>; name?: Resolver; namespace?: Resolver; + relations?: Resolver>, ParentType, ContextType>; tags?: Resolver>, ParentType, ContextType>; title?: Resolver, ParentType, ContextType>; }; +export type EntityConnectionResolvers = { + count?: Resolver, ParentType, ContextType>; + edges?: Resolver, ParentType, ContextType>; + pageInfo?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type EntityEdgeResolvers = { + cursor?: Resolver; + node?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type EntityLinkResolvers = { icon?: Resolver, ParentType, ContextType>; title?: Resolver, ParentType, ContextType>; @@ -2671,6 +3366,13 @@ export type EntityLinkResolvers; }; +export type EntityRefResolvers = { + kind?: Resolver; + name?: Resolver; + namespace?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type FileLocationResolvers = { annotations?: Resolver>, ParentType, ContextType>; apiVersion?: Resolver; @@ -2682,6 +3384,7 @@ export type FileLocationResolvers; namespace?: Resolver; presence?: Resolver, ParentType, ContextType>; + relations?: Resolver>, ParentType, ContextType>; tags?: Resolver>, ParentType, ContextType>; target?: Resolver, ParentType, ContextType>; targets?: Resolver>, ParentType, ContextType>; @@ -2705,6 +3408,7 @@ export type GrpcResolvers; namespace?: Resolver; owner?: Resolver; + relations?: Resolver>, ParentType, ContextType>; system?: Resolver, ParentType, ContextType>; tags?: Resolver>, ParentType, ContextType>; title?: Resolver, ParentType, ContextType>; @@ -2727,6 +3431,7 @@ export type GraphQlResolvers; namespace?: Resolver; owner?: Resolver; + relations?: Resolver>, ParentType, ContextType>; system?: Resolver, ParentType, ContextType>; tags?: Resolver>, ParentType, ContextType>; title?: Resolver, ParentType, ContextType>; @@ -2750,6 +3455,7 @@ export type GroupResolvers, ParentType, ContextType, Partial>; parent?: Resolver, ParentType, ContextType>; profile?: Resolver, ParentType, ContextType>; + relations?: Resolver>, ParentType, ContextType>; tags?: Resolver>, ParentType, ContextType>; title?: Resolver, ParentType, ContextType>; type?: Resolver; @@ -2806,6 +3512,7 @@ export type LibraryResolvers; owner?: Resolver; providesApis?: Resolver, ParentType, ContextType, Partial>; + relations?: Resolver>, ParentType, ContextType>; subComponentOf?: Resolver, ParentType, ContextType>; system?: Resolver, ParentType, ContextType>; tags?: Resolver>, ParentType, ContextType>; @@ -2826,6 +3533,7 @@ export type LocationResolvers; namespace?: Resolver; presence?: Resolver, ParentType, ContextType>; + relations?: Resolver>, ParentType, ContextType>; tags?: Resolver>, ParentType, ContextType>; target?: Resolver, ParentType, ContextType>; targets?: Resolver>, ParentType, ContextType>; @@ -2834,7 +3542,7 @@ export type LocationResolvers = { - __resolveType: TypeResolveFn<'AsyncAPI' | 'Database' | 'Department' | 'Domain' | 'FileLocation' | 'GRPC' | 'GraphQL' | 'Library' | 'OpaqueAPI' | 'OpaqueComponent' | 'OpaqueEntity' | 'OpaqueGroup' | 'OpaqueLocation' | 'OpaqueResource' | 'OpaqueTemplate' | 'OpenAPI' | 'Organization' | 'Service' | 'SubDepartment' | 'System' | 'Team' | 'URLLocation' | 'User' | 'Website', ParentType, ContextType>; + __resolveType: TypeResolveFn<'AsyncAPI' | 'Database' | 'Department' | 'Domain' | 'FileLocation' | 'GRPC' | 'GraphQL' | 'Library' | 'OpaqueAPI' | 'OpaqueComponent' | 'OpaqueEntity' | 'OpaqueGroup' | 'OpaqueLocation' | 'OpaqueResource' | 'OpaqueTemplate' | 'OpenAPI' | 'Organization' | 'Service' | 'ServiceTemplate' | 'SubDepartment' | 'System' | 'Team' | 'URLLocation' | 'User' | 'Website', ParentType, ContextType>; id?: Resolver; }; @@ -2853,6 +3561,7 @@ export type OpaqueApiResolvers; namespace?: Resolver; owner?: Resolver; + relations?: Resolver>, ParentType, ContextType>; system?: Resolver, ParentType, ContextType>; tags?: Resolver>, ParentType, ContextType>; title?: Resolver, ParentType, ContextType>; @@ -2877,6 +3586,7 @@ export type OpaqueComponentResolvers; owner?: Resolver; providesApis?: Resolver, ParentType, ContextType, Partial>; + relations?: Resolver>, ParentType, ContextType>; subComponentOf?: Resolver, ParentType, ContextType>; system?: Resolver, ParentType, ContextType>; tags?: Resolver>, ParentType, ContextType>; @@ -2895,6 +3605,7 @@ export type OpaqueEntityResolvers>, ParentType, ContextType>; name?: Resolver; namespace?: Resolver; + relations?: Resolver>, ParentType, ContextType>; tags?: Resolver>, ParentType, ContextType>; title?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -2915,6 +3626,7 @@ export type OpaqueGroupResolvers, ParentType, ContextType, Partial>; parent?: Resolver, ParentType, ContextType>; profile?: Resolver, ParentType, ContextType>; + relations?: Resolver>, ParentType, ContextType>; tags?: Resolver>, ParentType, ContextType>; title?: Resolver, ParentType, ContextType>; type?: Resolver; @@ -2932,6 +3644,7 @@ export type OpaqueLocationResolvers; namespace?: Resolver; presence?: Resolver, ParentType, ContextType>; + relations?: Resolver>, ParentType, ContextType>; tags?: Resolver>, ParentType, ContextType>; target?: Resolver, ParentType, ContextType>; targets?: Resolver>, ParentType, ContextType>; @@ -2953,6 +3666,7 @@ export type OpaqueResourceResolvers; namespace?: Resolver; owner?: Resolver; + relations?: Resolver>, ParentType, ContextType>; system?: Resolver, ParentType, ContextType>; tags?: Resolver>, ParentType, ContextType>; title?: Resolver, ParentType, ContextType>; @@ -2972,6 +3686,7 @@ export type OpaqueTemplateResolvers; owner?: Resolver, ParentType, ContextType>; parameters?: Resolver, ParentType, ContextType>; + relations?: Resolver>, ParentType, ContextType>; steps?: Resolver, ParentType, ContextType>; tags?: Resolver>, ParentType, ContextType>; title?: Resolver, ParentType, ContextType>; @@ -2994,6 +3709,7 @@ export type OpenApiResolvers; namespace?: Resolver; owner?: Resolver; + relations?: Resolver>, ParentType, ContextType>; system?: Resolver, ParentType, ContextType>; tags?: Resolver>, ParentType, ContextType>; title?: Resolver, ParentType, ContextType>; @@ -3016,6 +3732,7 @@ export type OrganizationResolvers, ParentType, ContextType, Partial>; parent?: Resolver, ParentType, ContextType>; profile?: Resolver, ParentType, ContextType>; + relations?: Resolver>, ParentType, ContextType>; tags?: Resolver>, ParentType, ContextType>; title?: Resolver, ParentType, ContextType>; type?: Resolver; @@ -3023,7 +3740,7 @@ export type OrganizationResolvers = { - __resolveType: TypeResolveFn<'AsyncAPI' | 'Database' | 'Domain' | 'GRPC' | 'GraphQL' | 'Library' | 'OpaqueAPI' | 'OpaqueComponent' | 'OpaqueResource' | 'OpaqueTemplate' | 'OpenAPI' | 'Service' | 'System' | 'Website', ParentType, ContextType>; + __resolveType: TypeResolveFn<'AsyncAPI' | 'Database' | 'Domain' | 'GRPC' | 'GraphQL' | 'Library' | 'OpaqueAPI' | 'OpaqueComponent' | 'OpaqueResource' | 'OpaqueTemplate' | 'OpenAPI' | 'Service' | 'ServiceTemplate' | 'System' | 'Website', ParentType, ContextType>; id?: Resolver; }; @@ -3053,11 +3770,18 @@ export type PageInfoResolvers = { + entities?: Resolver, ParentType, ContextType, Partial>; entity?: Resolver, ParentType, ContextType, RequireFields>; node?: Resolver, ParentType, ContextType, RequireFields>; nodes?: Resolver>, ParentType, ContextType, RequireFields>; }; +export type RelationResolvers = { + targetRef?: Resolver, ParentType, ContextType>; + type?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type ResourceResolvers = { __resolveType: TypeResolveFn<'Database' | 'OpaqueResource', ParentType, ContextType>; annotations?: Resolver>, ParentType, ContextType>; @@ -3072,6 +3796,7 @@ export type ResourceResolvers; namespace?: Resolver; owner?: Resolver; + relations?: Resolver>, ParentType, ContextType>; system?: Resolver, ParentType, ContextType>; tags?: Resolver>, ParentType, ContextType>; title?: Resolver, ParentType, ContextType>; @@ -3108,6 +3833,7 @@ export type ServiceResolvers; owner?: Resolver; providesApis?: Resolver, ParentType, ContextType, Partial>; + relations?: Resolver>, ParentType, ContextType>; subComponentOf?: Resolver, ParentType, ContextType>; system?: Resolver, ParentType, ContextType>; tags?: Resolver>, ParentType, ContextType>; @@ -3116,6 +3842,26 @@ export type ServiceResolvers; }; +export type ServiceTemplateResolvers = { + annotations?: Resolver>, ParentType, ContextType>; + apiVersion?: Resolver; + description?: Resolver, ParentType, ContextType>; + id?: Resolver; + kind?: Resolver; + labels?: Resolver>, ParentType, ContextType>; + links?: Resolver>, ParentType, ContextType>; + name?: Resolver; + namespace?: Resolver; + owner?: Resolver, ParentType, ContextType>; + parameters?: Resolver, ParentType, ContextType>; + relations?: Resolver>, ParentType, ContextType>; + steps?: Resolver, ParentType, ContextType>; + tags?: Resolver>, ParentType, ContextType>; + title?: Resolver, ParentType, ContextType>; + type?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type StepResolvers = { action?: Resolver; id?: Resolver, ParentType, ContextType>; @@ -3140,6 +3886,7 @@ export type SubDepartmentResolvers, ParentType, ContextType, Partial>; parent?: Resolver, ParentType, ContextType>; profile?: Resolver, ParentType, ContextType>; + relations?: Resolver>, ParentType, ContextType>; tags?: Resolver>, ParentType, ContextType>; title?: Resolver, ParentType, ContextType>; type?: Resolver; @@ -3160,6 +3907,7 @@ export type SystemResolvers; namespace?: Resolver; owner?: Resolver; + relations?: Resolver>, ParentType, ContextType>; resources?: Resolver, ParentType, ContextType, Partial>; tags?: Resolver>, ParentType, ContextType>; title?: Resolver, ParentType, ContextType>; @@ -3194,6 +3942,7 @@ export type TeamResolvers, ParentType, ContextType, Partial>; parent?: Resolver, ParentType, ContextType>; profile?: Resolver, ParentType, ContextType>; + relations?: Resolver>, ParentType, ContextType>; tags?: Resolver>, ParentType, ContextType>; title?: Resolver, ParentType, ContextType>; type?: Resolver; @@ -3201,7 +3950,7 @@ export type TeamResolvers = { - __resolveType: TypeResolveFn<'OpaqueTemplate', ParentType, ContextType>; + __resolveType: TypeResolveFn<'OpaqueTemplate' | 'ServiceTemplate', ParentType, ContextType>; annotations?: Resolver>, ParentType, ContextType>; apiVersion?: Resolver; description?: Resolver, ParentType, ContextType>; @@ -3213,6 +3962,7 @@ export type TemplateResolvers; owner?: Resolver, ParentType, ContextType>; parameters?: Resolver, ParentType, ContextType>; + relations?: Resolver>, ParentType, ContextType>; steps?: Resolver, ParentType, ContextType>; tags?: Resolver>, ParentType, ContextType>; title?: Resolver, ParentType, ContextType>; @@ -3230,6 +3980,7 @@ export type UrlLocationResolvers; namespace?: Resolver; presence?: Resolver, ParentType, ContextType>; + relations?: Resolver>, ParentType, ContextType>; tags?: Resolver>, ParentType, ContextType>; target?: Resolver, ParentType, ContextType>; targets?: Resolver>, ParentType, ContextType>; @@ -3251,6 +4002,7 @@ export type UserResolvers; ownerOf?: Resolver, ParentType, ContextType, Partial>; profile?: Resolver, ParentType, ContextType>; + relations?: Resolver>, ParentType, ContextType>; tags?: Resolver>, ParentType, ContextType>; title?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -3293,6 +4045,7 @@ export type WebsiteResolvers; owner?: Resolver; providesApis?: Resolver, ParentType, ContextType, Partial>; + relations?: Resolver>, ParentType, ContextType>; subComponentOf?: Resolver, ParentType, ContextType>; system?: Resolver, ParentType, ContextType>; tags?: Resolver>, ParentType, ContextType>; @@ -3322,7 +4075,10 @@ export type Resolvers = { Domain?: DomainResolvers; Edge?: EdgeResolvers; Entity?: EntityResolvers; + EntityConnection?: EntityConnectionResolvers; + EntityEdge?: EntityEdgeResolvers; EntityLink?: EntityLinkResolvers; + EntityRef?: EntityRefResolvers; FileLocation?: FileLocationResolvers; GRPC?: GrpcResolvers; GraphQL?: GraphQlResolvers; @@ -3351,10 +4107,12 @@ export type Resolvers = { Owner?: OwnerResolvers; PageInfo?: PageInfoResolvers; Query?: QueryResolvers; + Relation?: RelationResolvers; Resource?: ResourceResolvers; ResourceConnection?: ResourceConnectionResolvers; ResourceEdge?: ResourceEdgeResolvers; Service?: ServiceResolvers; + ServiceTemplate?: ServiceTemplateResolvers; Step?: StepResolvers; SubDepartment?: SubDepartmentResolvers; System?: SystemResolvers; @@ -3378,6 +4136,7 @@ export type DirectiveResolvers = { implements?: ImplementsDirectiveResolver; relation?: RelationDirectiveResolver; resolve?: ResolveDirectiveResolver; + sourceType?: SourceTypeDirectiveResolver; }; export type Json = Scalars["JSON"]; diff --git a/plugins/graphql-backend-module-catalog/src/catalog/catalog.graphql b/plugins/graphql-backend-module-catalog/src/catalog/catalog.graphql index 772ced7e79..f59e9fdc3d 100644 --- a/plugins/graphql-backend-module-catalog/src/catalog/catalog.graphql +++ b/plugins/graphql-backend-module-catalog/src/catalog/catalog.graphql @@ -1,12 +1,3 @@ -directive @relation( - name: String - nodeType: String - kind: String -) on FIELD_DEFINITION - -scalar JSON -scalar JSONObject - type KeyValuePair { key: String! value: String! @@ -16,8 +7,7 @@ union Ownable = API | Component | Domain | Resource | System | Template union Dependency = Component | Resource union Owner = User | Group -interface Entity - @implements(interface: "Node") +extend interface Entity @discriminates(with: "kind", opaqueType: "OpaqueEntity") { name: String! @field(at: "metadata.name") kind: String! @field(at: "kind") @@ -25,10 +15,11 @@ interface Entity apiVersion: String! @field(at: "apiVersion") title: String @field(at: "metadata.title") description: String @field(at: "metadata.description") - labels: [KeyValuePair!] @field(at: "metadata.labels") - annotations: [KeyValuePair!] @field(at: "metadata.annotations") + labels: [KeyValuePair!] @field(at: "metadata.labels") @sourceType(name: "JSONObject") + annotations: [KeyValuePair!] @field(at: "metadata.annotations") @sourceType(name: "JSONObject") tags: [String!] @field(at: "metadata.tags") links: [EntityLink!] @field(at: "metadata.links") + relations: [Relation!] @field(at: "relations") } type EntityLink { @@ -38,6 +29,19 @@ type EntityLink { type: String } +type Relation { + type: String! + # FIXME: @resolve/@field directives work only on top level of node object + # target: Entity @resolve(at: "targetRef") + targetRef: EntityRef @sourceType(name: "String") +} + +type EntityRef { + kind: String! + namespace: String! + name: String! +} + interface Location @implements(interface: "Entity") @discriminates(with: "spec.type", opaqueType: "OpaqueLocation") @@ -123,7 +127,8 @@ type Step { interface Template @implements(interface: "Entity") - @discriminates(with: "spec.type", opaqueType: "OpaqueTemplate") { + @discriminates(with: "spec.type", opaqueType: "OpaqueTemplate") + @discriminationAlias(value: "service", type: "ServiceTemplate") { type: String! @field(at: "spec.type") parameters: JSONObject @field(at: "spec.parameters") steps: [Step!]! @field(at: "spec.steps") @@ -162,7 +167,3 @@ type User @implements(interface: "Entity") { memberOf: Connection @relation(nodeType: "Group") ownerOf: Connection @relation(nodeType: "Ownable") } - -extend type Query { - entity(kind: String!, name: String!, namespace: String): Entity -} diff --git a/plugins/graphql-backend-module-catalog/src/catalog/catalog.ts b/plugins/graphql-backend-module-catalog/src/catalog/catalog.ts index b96dc854bf..bffca45b38 100644 --- a/plugins/graphql-backend-module-catalog/src/catalog/catalog.ts +++ b/plugins/graphql-backend-module-catalog/src/catalog/catalog.ts @@ -1,14 +1,9 @@ import { createModule } from 'graphql-modules'; -import GraphQLJSON, { GraphQLJSONObject } from 'graphql-type-json'; -import { relationDirectiveMapper } from '../relationDirectiveMapper'; -import { - GraphQLModule, - encodeId, -} from '@frontside/hydraphql'; -import { stringifyEntityRef } from '@backstage/catalog-model'; import { loadFilesSync } from '@graphql-tools/load-files'; import { resolvePackagePath } from '@backstage/backend-common'; -import { CATALOG_SOURCE } from '../constants'; +import { Relation } from '../relation'; +import { GraphQLModule } from '@frontside/hydraphql'; +import { EntityRelation, parseEntityRef } from '@backstage/catalog-model'; const catalogSchemaPath = resolvePackagePath( '@frontside/backstage-plugin-graphql-backend-module-catalog', @@ -17,13 +12,16 @@ const catalogSchemaPath = resolvePackagePath( /** @public */ export const Catalog = (): GraphQLModule => ({ - mappers: { relation: relationDirectiveMapper }, + mappers: { ...Relation().mappers }, + postTransform: Relation().postTransform, module: createModule({ - id: 'catalog', - typeDefs: loadFilesSync(catalogSchemaPath), + id: 'catalog-entities', + typeDefs: [ + ...Relation().module.typeDefs, + ...loadFilesSync(catalogSchemaPath), + ], resolvers: { - JSON: GraphQLJSON, - JSONObject: GraphQLJSONObject, + ...Relation().module.config.resolvers, Entity: { labels: (labels: Record) => labels @@ -37,22 +35,9 @@ export const Catalog = (): GraphQLModule => ({ })) : null, }, - Query: { - entity: ( - _: any, - { - name, - kind, - namespace = 'default', - }: { name: string; kind: string; namespace: string }, - ): { id: string } => ({ - id: encodeId({ - source: CATALOG_SOURCE, - typename: 'Entity', - query: { ref: stringifyEntityRef({ name, kind, namespace }) }, - }), - }), + Relation: { + targetRef: (relation: EntityRelation) => parseEntityRef(relation.targetRef), }, }, - }) + }), }); diff --git a/plugins/graphql-backend-module-catalog/src/common/common.graphql b/plugins/graphql-backend-module-catalog/src/common/common.graphql new file mode 100644 index 0000000000..3b7cf7ebe6 --- /dev/null +++ b/plugins/graphql-backend-module-catalog/src/common/common.graphql @@ -0,0 +1,125 @@ +directive @relation( + name: String + nodeType: String + kind: String +) on FIELD_DEFINITION +directive @sourceType( + name: String! +) on FIELD_DEFINITION + +scalar JSON +scalar JSONObject + +enum OrderDirection { + ASC + DESC +} + +type EntityConnection implements Connection { + pageInfo: PageInfo! + edges: [EntityEdge!]! + count: Int +} + +type EntityEdge implements Edge { + cursor: String! + node: Entity! +} + +extend type Query { + entity(kind: String!, name: String!, namespace: String): Entity + entities( + first: Int, + after: String, + last: Int, + before: String, + filter: EntityFilter, + rawFilter: EntityRawFilter, + ): EntityConnection +} + +input EntityOrderField { + _dummy: OrderDirection +} + +input EntityTextFilterFields { + _dummy: Boolean +} + +input EntityTextFilter { + term: String! + fields: EntityTextFilterFields +} + +input EntityFilterExpression { + _dummy: [JSON!] +} + +""" +{ + order: [ + { fieldA: ASC } + { fieldB: DESC } + { fieldC: [{ fieldD: ASC }] } + { fieldE: { order: ASC } } + { + fieldE: { + fields: [{ fieldF: DESC }, { fieldG: ASC }] + } + } + { annotations: [{ field: "backstage.io/source-location", order: ASC }] } + ] + search: { + term: "substring" + fields: { + fieldA: true + fieldB: true + fieldC: { fieldD: true } + fieldE: { + include: true + fields: { fieldF: true, fieldG: true } + } + } + } + match: [ + { fieldA: ["value1", "value2"], fieldB: ["value3"] } + { fieldC: { fieldD: ["value4"] } } + { + fieldE: { + values: ["value5", "value6"], + fields: { fieldF: ["value7"], fieldG: ["value8"] } + } + } + ] +} +""" +input EntityFilter { + order: [EntityOrderField!] + search: EntityTextFilter + match: [EntityFilterExpression!] +} + +input EntityRawFilterField { + key: String! + values: [JSON!]! +} + +input EntityRawFilterExpression { + fields: [EntityRawFilterField!]! +} + +input EntityRawOrderField { + field: String! + order: OrderDirection! +} + +input EntityRawTextFilter { + term: String! + fields: [String!] +} + +input EntityRawFilter { + filter: [EntityRawFilterExpression!] + orderFields: [EntityRawOrderField!] + fullTextFilter: EntityRawTextFilter +} diff --git a/plugins/graphql-backend-module-catalog/src/common/common.ts b/plugins/graphql-backend-module-catalog/src/common/common.ts new file mode 100644 index 0000000000..1e388d6c32 --- /dev/null +++ b/plugins/graphql-backend-module-catalog/src/common/common.ts @@ -0,0 +1,28 @@ +import { createModule } from 'graphql-modules'; +import { relationDirectiveMapper } from '../relationDirectiveMapper'; +import { GraphQLModule } from '@frontside/hydraphql'; +import { loadFilesSync } from '@graphql-tools/load-files'; +import { resolvePackagePath } from '@backstage/backend-common'; +import { queryResolvers } from '../resolvers'; +import GraphQLJSON, { GraphQLJSONObject } from 'graphql-type-json'; +import { generateEntitiesQueryInputTypes } from '../generateInputTypes'; + +const commonSchemaPath = resolvePackagePath( + '@frontside/backstage-plugin-graphql-backend-module-catalog', + 'src/common/common.graphql', +); + +/** @public */ +export const Common = (): GraphQLModule => ({ + mappers: { relation: relationDirectiveMapper }, + postTransform: generateEntitiesQueryInputTypes, + module: createModule({ + id: 'catalog-common', + typeDefs: loadFilesSync(commonSchemaPath), + resolvers: { + JSON: GraphQLJSON, + JSONObject: GraphQLJSONObject, + Query: queryResolvers() + } + }) +}); diff --git a/plugins/graphql-backend-module-catalog/src/common/index.ts b/plugins/graphql-backend-module-catalog/src/common/index.ts new file mode 100644 index 0000000000..d0b9323665 --- /dev/null +++ b/plugins/graphql-backend-module-catalog/src/common/index.ts @@ -0,0 +1 @@ +export * from './common'; diff --git a/plugins/graphql-backend-module-catalog/src/entitiesLoadFn.ts b/plugins/graphql-backend-module-catalog/src/entitiesLoadFn.ts index 7f0a24d3c9..d4a07b8254 100644 --- a/plugins/graphql-backend-module-catalog/src/entitiesLoadFn.ts +++ b/plugins/graphql-backend-module-catalog/src/entitiesLoadFn.ts @@ -11,7 +11,7 @@ export const createCatalogLoader = (catalog: CatalogApi) => ({ queries: readonly (NodeQuery | undefined)[], context: GraphQLContext & { request?: Request } ): Promise> => { - // TODO: Support fields + // TODO: Support fields, to allow use loader in `@resolve` directive const request = context.request; const token = getBearerTokenFromAuthorizationHeader(request?.headers.get('authorization')); const entityRefs = queries.reduce( diff --git a/plugins/graphql-backend-module-catalog/src/generateInputTypes.test.ts b/plugins/graphql-backend-module-catalog/src/generateInputTypes.test.ts new file mode 100644 index 0000000000..9e7024abeb --- /dev/null +++ b/plugins/graphql-backend-module-catalog/src/generateInputTypes.test.ts @@ -0,0 +1,283 @@ +import { transformSchema } from '@frontside/hydraphql'; +import { DocumentNode, GraphQLNamedType, printType } from 'graphql'; +import { Module, createModule, gql } from 'graphql-modules'; +import { Relation } from './relation/relation'; + +describe('generateEntitiesQueryInputTypes', () => { + const transform = (source: DocumentNode, anotherModule?: Module) => + transformSchema([ + Relation(), + createModule({ + id: 'generateEntitiesQueryInputTypes', + typeDefs: source, + }), + ...(anotherModule ? [anotherModule] : []), + ]); + + it('should generate dummy input types', () => { + const schema = transform(gql` + type Foo { + id: ID! + } + `); + + expect( + printType(schema.getType('EntityOrderField') as GraphQLNamedType).split( + '\n', + ), + ).toEqual(['input EntityOrderField {', ' _dummy: OrderDirection', '}']); + expect( + printType( + schema.getType('EntityTextFilterFields') as GraphQLNamedType, + ).split('\n'), + ).toEqual(['input EntityTextFilterFields {', ' _dummy: Boolean', '}']); + expect( + printType( + schema.getType('EntityFilterExpression') as GraphQLNamedType, + ).split('\n'), + ).toEqual(['input EntityFilterExpression {', ' _dummy: [JSON!]', '}']); + }); + + it('should generate plain input types for primitive fields', () => { + const schema = transform(gql` + extend interface Entity { + name: String! @field(at: "metadata.name") + kind: String! @field(at: "kind") + namespace: String! @field(at: "metadata.namespace", default: "default") + apiVersion: String! @field(at: "apiVersion") + title: String @field(at: "metadata.title") + description: String @field(at: "metadata.description") + } + `); + + expect( + printType(schema.getType('EntityOrderField') as GraphQLNamedType).split( + '\n', + ), + ).toEqual([ + 'input EntityOrderField {', + ' name: OrderDirection', + ' kind: OrderDirection', + ' namespace: OrderDirection', + ' apiVersion: OrderDirection', + ' title: OrderDirection', + ' description: OrderDirection', + '}', + ]); + expect( + printType( + schema.getType('EntityTextFilterFields') as GraphQLNamedType, + ).split('\n'), + ).toEqual([ + 'input EntityTextFilterFields {', + ' name: Boolean', + ' kind: Boolean', + ' namespace: Boolean', + ' apiVersion: Boolean', + ' title: Boolean', + ' description: Boolean', + '}', + ]); + expect( + printType( + schema.getType('EntityFilterExpression') as GraphQLNamedType, + ).split('\n'), + ).toEqual([ + 'input EntityFilterExpression {', + ' name: [JSON!]', + ' kind: [JSON!]', + ' namespace: [JSON!]', + ' apiVersion: [JSON!]', + ' title: [JSON!]', + ' description: [JSON!]', + '}', + ]); + }); + + it('should generate input types for composite fields', () => { + const schema = transform(gql` + extend interface Entity { + metadata: Metadata! @field(at: "metadata") + } + + type Metadata { + name: String! @field + namespace: String! @field + } + `); + + expect( + printType(schema.getType('EntityOrderField') as GraphQLNamedType).split( + '\n', + ), + ).toEqual([ + 'input EntityOrderField {', + ' metadata: [EntityOrderField_Metadata!]', + '}', + ]); + expect( + printType( + schema.getType('EntityOrderField_Metadata') as GraphQLNamedType, + ).split('\n'), + ).toEqual([ + 'input EntityOrderField_Metadata {', + ' name: OrderDirection', + ' namespace: OrderDirection', + '}', + ]); + + expect( + printType( + schema.getType('EntityTextFilterFields') as GraphQLNamedType, + ).split('\n'), + ).toEqual([ + 'input EntityTextFilterFields {', + ' metadata: EntityTextFilterFields_Metadata', + '}', + ]); + expect( + printType( + schema.getType('EntityTextFilterFields_Metadata') as GraphQLNamedType, + ).split('\n'), + ).toEqual([ + 'input EntityTextFilterFields_Metadata {', + ' name: Boolean', + ' namespace: Boolean', + '}', + ]); + + expect( + printType( + schema.getType('EntityFilterExpression') as GraphQLNamedType, + ).split('\n'), + ).toEqual([ + 'input EntityFilterExpression {', + ' metadata: EntityFilterExpression_Metadata', + '}', + ]); + expect( + printType( + schema.getType('EntityFilterExpression_Metadata') as GraphQLNamedType, + ).split('\n'), + ).toEqual([ + 'input EntityFilterExpression_Metadata {', + ' name: [JSON!]', + ' namespace: [JSON!]', + '}', + ]); + }); + + it('should generate input types for mixed fields (plain and composite)', () => { + const schema = transform(gql` + extend interface Entity @discriminates(with: "kind") { + kind: String! @field(at: "kind") + } + + type Component @implements(interface: "Entity") { + target: String! @field(at: "spec.target") + } + + type Location @implements(interface: "Entity") { + target: Target! @field(at: "spec.target") + } + + type Target { + host: String! @field + port: Int! @field + } + `); + + expect( + printType(schema.getType('EntityOrderField') as GraphQLNamedType).split( + '\n', + ), + ).toEqual([ + 'input EntityOrderField {', + ' kind: OrderDirection', + ' target: EntityOrderField_Target', + '}', + ]); + expect( + printType( + schema.getType('EntityOrderField_Target') as GraphQLNamedType, + ).split('\n'), + ).toEqual([ + 'input EntityOrderField_Target {', + ' order: OrderDirection', + ' fields: [EntityOrderField__Target!]', + '}', + ]); + expect( + printType( + schema.getType('EntityOrderField__Target') as GraphQLNamedType, + ).split('\n'), + ).toEqual([ + 'input EntityOrderField__Target {', + ' host: OrderDirection', + ' port: OrderDirection', + '}', + ]); + + expect( + printType( + schema.getType('EntityTextFilterFields') as GraphQLNamedType, + ).split('\n'), + ).toEqual([ + 'input EntityTextFilterFields {', + ' kind: Boolean', + ' target: EntityTextFilterFields_Target', + '}', + ]); + expect( + printType( + schema.getType('EntityTextFilterFields_Target') as GraphQLNamedType, + ).split('\n'), + ).toEqual([ + 'input EntityTextFilterFields_Target {', + ' include: Boolean', + ' fields: EntityTextFilterFields__Target', + '}', + ]); + expect( + printType( + schema.getType('EntityTextFilterFields__Target') as GraphQLNamedType, + ).split('\n'), + ).toEqual([ + 'input EntityTextFilterFields__Target {', + ' host: Boolean', + ' port: Boolean', + '}', + ]); + + expect( + printType( + schema.getType('EntityFilterExpression') as GraphQLNamedType, + ).split('\n'), + ).toEqual([ + 'input EntityFilterExpression {', + ' kind: [JSON!]', + ' target: EntityFilterExpression_Target', + '}', + ]); + expect( + printType( + schema.getType('EntityFilterExpression_Target') as GraphQLNamedType, + ).split('\n'), + ).toEqual([ + 'input EntityFilterExpression_Target {', + ' values: [JSON!]', + ' fields: EntityFilterExpression__Target', + '}', + ]); + expect( + printType( + schema.getType('EntityFilterExpression__Target') as GraphQLNamedType, + ).split('\n'), + ).toEqual([ + 'input EntityFilterExpression__Target {', + ' host: [JSON!]', + ' port: [JSON!]', + '}', + ]); + }); +}); diff --git a/plugins/graphql-backend-module-catalog/src/generateInputTypes.ts b/plugins/graphql-backend-module-catalog/src/generateInputTypes.ts new file mode 100644 index 0000000000..9a97a47a08 --- /dev/null +++ b/plugins/graphql-backend-module-catalog/src/generateInputTypes.ts @@ -0,0 +1,751 @@ +import { + GraphQLSchema, + isInterfaceType, + isInputObjectType, + GraphQLInputObjectType, + isEnumType, + GraphQLBoolean, + GraphQLEnumType, + isUnionType, + GraphQLCompositeType, + getNamedType, + GraphQLList, + GraphQLNonNull, + GraphQLInputType, + isLeafType, + isCompositeType, + GraphQLInputFieldConfig, + GraphQLString, + GraphQLNamedType, +} from 'graphql'; +import { addTypes, getDirective } from '@graphql-tools/utils'; +import GraphQLJSON from 'graphql-type-json'; + +interface TypeParams { + isLeaf?: boolean; + isComposite?: boolean; + isJsonObject?: boolean; +} + +function isJsonObject(type: GraphQLNamedType) { + return type.name === 'JSONObject'; +} + +function createCompositeOrderFieldsType( + fieldName: string, + orderFieldTypeName: string, + compositeOrderFieldType: GraphQLInputType, +) { + return { + type: new GraphQLList( + new GraphQLNonNull( + new GraphQLInputObjectType({ + ...( + getNamedType(compositeOrderFieldType) as GraphQLInputObjectType + ).toConfig(), + name: `${orderFieldTypeName}__${fieldName[0].toUpperCase()}${fieldName.slice( + 1, + )}`, + }), + ), + ), + }; +} + +function createCompositeTextFilterFieldsType( + fieldName: string, + textFilterFieldsTypeName: string, + compositeTextFilterFieldsType: GraphQLInputType, +) { + return { + type: new GraphQLInputObjectType({ + ...( + getNamedType(compositeTextFilterFieldsType) as GraphQLInputObjectType + ).toConfig(), + name: `${textFilterFieldsTypeName}__${fieldName[0].toUpperCase()}${fieldName.slice( + 1, + )}`, + }), + }; +} + +function createCompositeFilterExpressionType( + fieldName: string, + filterExpressionTypeName: string, + compositeFilterExpressionType: GraphQLInputType, +) { + return { + type: new GraphQLInputObjectType({ + ...( + getNamedType(compositeFilterExpressionType) as GraphQLInputObjectType + ).toConfig(), + name: `${filterExpressionTypeName}__${fieldName[0].toUpperCase()}${fieldName.slice( + 1, + )}`, + }), + }; +} + +function mergeLeafAndCompositeOrderFieldTypes( + fieldName: string, + orderFieldTypeName: string, + fields: Record, +) { + // NOTE: Should we check if the type is already in the schema? + return { + type: new GraphQLInputObjectType({ + name: `${orderFieldTypeName}_${fieldName[0].toUpperCase()}${fieldName.slice( + 1, + )}`, + fields, + }), + }; +} + +function mergeLeafAndCompositeTextFilterFieldsTypes( + fieldName: string, + textFilterFieldsTypeName: string, + fields: Record, +) { + return { + type: new GraphQLInputObjectType({ + name: `${textFilterFieldsTypeName}_${fieldName[0].toUpperCase()}${fieldName.slice( + 1, + )}`, + fields, + }), + }; +} + +function mergeLeafAndCompositeFilterExpressionTypes( + fieldName: string, + filterExpressionTypeName: string, + fields: Record, +) { + return { + type: new GraphQLInputObjectType({ + name: `${filterExpressionTypeName}_${fieldName[0].toUpperCase()}${fieldName.slice( + 1, + )}`, + fields, + }), + }; +} + +function getTypeConfigs( + fieldName: string, + fieldTypes: Map, + orderFieldTypeConfig: ReturnType, + textFilterFieldsTypeConfig: ReturnType, + filterExpressionTypeConfig: ReturnType, +) { + if (fieldTypes.get(fieldName)?.isComposite) { + if (fieldTypes.get(fieldName)?.isLeaf) { + const mixedOrderFieldType = orderFieldTypeConfig.fields[fieldName] + .type as GraphQLInputObjectType; + const mixedTextFilterFieldsType = textFilterFieldsTypeConfig.fields[ + fieldName + ].type as GraphQLInputObjectType; + const mixedFilterExpressionType = filterExpressionTypeConfig.fields[ + fieldName + ].type as GraphQLInputObjectType; + + return { + // { + // order: OrderDirection + // fields: [EntityOrderField${FieldName}!] + // } + compositeOrderFieldTypeConfig: ( + getNamedType( + mixedOrderFieldType.toConfig().fields.fields.type, + ) as GraphQLInputObjectType + ).toConfig(), + // { + // include: Boolean + // fields: EntityTextFilterFields${FieldName} + // } + compositeTextFilterFieldsTypeConfig: ( + getNamedType( + mixedTextFilterFieldsType.toConfig().fields.fields.type, + ) as GraphQLInputObjectType + ).toConfig(), + // { + // values: JSON + // fields: [EntityFilterExpression${FieldName}!] + // } + compositeFilterExpressionTypeConfig: ( + getNamedType( + mixedFilterExpressionType.toConfig().fields.fields.type, + ) as GraphQLInputObjectType + ).toConfig(), + }; + } + return { + compositeOrderFieldTypeConfig: ( + getNamedType( + orderFieldTypeConfig.fields[fieldName].type, + ) as GraphQLInputObjectType + ).toConfig(), + compositeTextFilterFieldsTypeConfig: ( + getNamedType( + textFilterFieldsTypeConfig.fields[fieldName].type, + ) as GraphQLInputObjectType + ).toConfig(), + compositeFilterExpressionTypeConfig: ( + getNamedType( + filterExpressionTypeConfig.fields[fieldName].type, + ) as GraphQLInputObjectType + ).toConfig(), + }; + } + return { + compositeOrderFieldTypeConfig: { + name: `${ + orderFieldTypeConfig.name + }_${fieldName[0].toUpperCase()}${fieldName.slice(1)}`, + fields: {}, + } as ReturnType, + compositeTextFilterFieldsTypeConfig: { + name: `${ + textFilterFieldsTypeConfig.name + }_${fieldName[0].toUpperCase()}${fieldName.slice(1)}`, + fields: {}, + } as ReturnType, + compositeFilterExpressionTypeConfig: { + name: `${ + filterExpressionTypeConfig.name + }_${fieldName[0].toUpperCase()}${fieldName.slice(1)}`, + fields: {}, + } as ReturnType, + }; +} + +function processTypes( + schema: GraphQLSchema, + types: readonly GraphQLCompositeType[], + { + isNested = false, + orderDirectionType, + orderFieldTypeConfig, + textFilterFieldsTypeConfig, + filterExpressionTypeConfig, + entityRawOrderFieldType, + entityRawFilterFieldType, + }: { + isNested?: boolean; + orderDirectionType: GraphQLEnumType; + orderFieldTypeConfig: ReturnType; + textFilterFieldsTypeConfig: ReturnType; + filterExpressionTypeConfig: ReturnType; + entityRawOrderFieldType: GraphQLInputObjectType; + entityRawFilterFieldType: GraphQLInputObjectType; + }, +) { + const fieldTypes = new Map(); + + types.forEach(type => { + if (isUnionType(type)) { + processTypes(schema, type.getTypes(), { + isNested, + orderDirectionType, + orderFieldTypeConfig, + textFilterFieldsTypeConfig, + filterExpressionTypeConfig, + entityRawOrderFieldType, + entityRawFilterFieldType, + }); + return; + } + if (isInterfaceType(type)) { + processTypes( + schema, + [ + ...schema.getImplementations(type).interfaces, + ...schema.getImplementations(type).objects, + ], + { + isNested, + orderDirectionType, + orderFieldTypeConfig, + textFilterFieldsTypeConfig, + filterExpressionTypeConfig, + entityRawOrderFieldType, + entityRawFilterFieldType, + }, + ); + } + Object.entries(type.getFields()).forEach(([fieldName, field]) => { + const [fieldDirective] = getDirective(schema, field, 'field') ?? []; + const [resolveDirective] = getDirective(schema, field, 'resolve') ?? [] + const [sourceTypeDirective] = + getDirective(schema, field, 'sourceType') ?? []; + if (!fieldDirective && !isNested || resolveDirective) return; + + const fieldType = sourceTypeDirective + ? schema.getType(sourceTypeDirective.name) + : field.type; + + if (!fieldType) + throw new Error( + `Can't find "${sourceTypeDirective.name}" type described in @sourceType(name: "${sourceTypeDirective.name}") directive for "${field.name}" field of "${type.name}" type/interface`, + ); + const fieldNamedType = getNamedType(fieldType); + const order = { type: orderDirectionType }; + const include = { type: GraphQLBoolean }; + // NOTE: Should we handle enums differently? + const values = { type: new GraphQLList(new GraphQLNonNull(GraphQLJSON)) }; + const orderRawFields = { + type: new GraphQLList(new GraphQLNonNull(entityRawOrderFieldType)), + }; + const includeRawFields = { + type: new GraphQLList(new GraphQLNonNull(GraphQLString)), + }; + const valuesRawFields = { + type: new GraphQLList(new GraphQLNonNull(entityRawFilterFieldType)), + }; + + if ( + isJsonObject(fieldNamedType) && + !fieldTypes.get(fieldName)?.isJsonObject + ) { + if (fieldTypes.get(fieldName)?.isComposite) { + const { + compositeOrderFieldTypeConfig, + compositeTextFilterFieldsTypeConfig, + compositeFilterExpressionTypeConfig, + } = getTypeConfigs( + fieldName, + fieldTypes, + orderFieldTypeConfig, + textFilterFieldsTypeConfig, + filterExpressionTypeConfig, + ); + + orderFieldTypeConfig.fields[fieldName] = + mergeLeafAndCompositeOrderFieldTypes( + fieldName, + orderFieldTypeConfig.name, + { + ...(fieldTypes.get(fieldName)?.isLeaf ? { order } : {}), + rawFields: orderRawFields, + fields: createCompositeOrderFieldsType( + fieldName, + orderFieldTypeConfig.name, + new GraphQLInputObjectType(compositeOrderFieldTypeConfig), + ), + }, + ); + + textFilterFieldsTypeConfig.fields[fieldName] = + mergeLeafAndCompositeTextFilterFieldsTypes( + fieldName, + textFilterFieldsTypeConfig.name, + { + ...(fieldTypes.get(fieldName)?.isLeaf ? { include } : {}), + rawFields: includeRawFields, + fields: createCompositeTextFilterFieldsType( + fieldName, + textFilterFieldsTypeConfig.name, + new GraphQLInputObjectType( + compositeTextFilterFieldsTypeConfig, + ), + ), + }, + ); + + filterExpressionTypeConfig.fields[fieldName] = + mergeLeafAndCompositeFilterExpressionTypes( + fieldName, + filterExpressionTypeConfig.name, + { + ...(fieldTypes.get(fieldName)?.isLeaf ? { values } : {}), + rawFields: valuesRawFields, + fields: createCompositeFilterExpressionType( + fieldName, + filterExpressionTypeConfig.name, + new GraphQLInputObjectType( + compositeFilterExpressionTypeConfig, + ), + ), + }, + ); + } else { + if (fieldTypes.get(fieldName)?.isLeaf) { + orderFieldTypeConfig.fields[fieldName] = + mergeLeafAndCompositeOrderFieldTypes( + fieldName, + orderFieldTypeConfig.name, + { + order, + rawFields: orderRawFields, + }, + ); + textFilterFieldsTypeConfig.fields[fieldName] = + mergeLeafAndCompositeTextFilterFieldsTypes( + fieldName, + textFilterFieldsTypeConfig.name, + { + include, + rawFields: includeRawFields, + }, + ); + filterExpressionTypeConfig.fields[fieldName] = + mergeLeafAndCompositeFilterExpressionTypes( + fieldName, + filterExpressionTypeConfig.name, + { + values, + rawFields: valuesRawFields, + }, + ); + } else { + orderFieldTypeConfig.fields[fieldName] = orderRawFields; + textFilterFieldsTypeConfig.fields[fieldName] = includeRawFields; + filterExpressionTypeConfig.fields[fieldName] = valuesRawFields; + } + } + fieldTypes.set(fieldName, { + ...fieldTypes.get(fieldName), + isJsonObject: true, + }); + } + + if ( + !isJsonObject(fieldNamedType) && + isLeafType(fieldNamedType) && + !fieldTypes.get(fieldName)?.isLeaf + ) { + if (fieldTypes.get(fieldName)?.isComposite) { + // NOTE: Should we check if the type is already in the schema? + orderFieldTypeConfig.fields[fieldName] = + mergeLeafAndCompositeOrderFieldTypes( + fieldName, + orderFieldTypeConfig.name, + { + order, + ...(fieldTypes.get(fieldName)?.isJsonObject + ? { rawFields: orderRawFields } + : {}), + fields: createCompositeOrderFieldsType( + fieldName, + orderFieldTypeConfig.name, + orderFieldTypeConfig.fields[fieldName].type, + ), + }, + ); + + textFilterFieldsTypeConfig.fields[fieldName] = + mergeLeafAndCompositeTextFilterFieldsTypes( + fieldName, + textFilterFieldsTypeConfig.name, + { + include, + ...(fieldTypes.get(fieldName)?.isJsonObject + ? { rawFields: includeRawFields } + : {}), + fields: createCompositeTextFilterFieldsType( + fieldName, + textFilterFieldsTypeConfig.name, + textFilterFieldsTypeConfig.fields[fieldName].type, + ), + }, + ); + + filterExpressionTypeConfig.fields[fieldName] = + mergeLeafAndCompositeFilterExpressionTypes( + fieldName, + filterExpressionTypeConfig.name, + { + values, + ...(fieldTypes.get(fieldName)?.isJsonObject + ? { rawFields: valuesRawFields } + : {}), + fields: createCompositeFilterExpressionType( + fieldName, + filterExpressionTypeConfig.name, + filterExpressionTypeConfig.fields[fieldName].type, + ), + }, + ); + } else { + if (fieldTypes.get(fieldName)?.isJsonObject) { + orderFieldTypeConfig.fields[fieldName] = + mergeLeafAndCompositeOrderFieldTypes( + fieldName, + orderFieldTypeConfig.name, + { + order, + rawFields: orderRawFields, + }, + ); + textFilterFieldsTypeConfig.fields[fieldName] = + mergeLeafAndCompositeTextFilterFieldsTypes( + fieldName, + textFilterFieldsTypeConfig.name, + { + include, + rawFields: includeRawFields, + }, + ); + filterExpressionTypeConfig.fields[fieldName] = + mergeLeafAndCompositeFilterExpressionTypes( + fieldName, + filterExpressionTypeConfig.name, + { + values, + rawFields: valuesRawFields, + }, + ); + } else { + orderFieldTypeConfig.fields[fieldName] = order; + textFilterFieldsTypeConfig.fields[fieldName] = include; + filterExpressionTypeConfig.fields[fieldName] = values; + } + } + + fieldTypes.set(fieldName, { + ...fieldTypes.get(fieldName), + isLeaf: true, + }); + } + + if (isCompositeType(fieldNamedType)) { + const { + compositeOrderFieldTypeConfig, + compositeTextFilterFieldsTypeConfig, + compositeFilterExpressionTypeConfig, + } = getTypeConfigs( + fieldName, + fieldTypes, + orderFieldTypeConfig, + textFilterFieldsTypeConfig, + filterExpressionTypeConfig, + ); + + processTypes(schema, [fieldNamedType], { + isNested: true, + orderDirectionType, + orderFieldTypeConfig: compositeOrderFieldTypeConfig, + textFilterFieldsTypeConfig: compositeTextFilterFieldsTypeConfig, + filterExpressionTypeConfig: compositeFilterExpressionTypeConfig, + entityRawOrderFieldType, + entityRawFilterFieldType, + }); + + if (fieldTypes.get(fieldName)?.isLeaf) { + orderFieldTypeConfig.fields[fieldName] = + mergeLeafAndCompositeOrderFieldTypes( + fieldName, + orderFieldTypeConfig.name, + { + order, + ...(fieldTypes.get(fieldName)?.isJsonObject + ? { rawFields: orderRawFields } + : {}), + fields: createCompositeOrderFieldsType( + fieldName, + orderFieldTypeConfig.name, + new GraphQLInputObjectType(compositeOrderFieldTypeConfig), + ), + }, + ); + + textFilterFieldsTypeConfig.fields[fieldName] = + mergeLeafAndCompositeTextFilterFieldsTypes( + fieldName, + textFilterFieldsTypeConfig.name, + { + include, + ...(fieldTypes.get(fieldName)?.isJsonObject + ? { rawFields: includeRawFields } + : {}), + fields: createCompositeTextFilterFieldsType( + fieldName, + textFilterFieldsTypeConfig.name, + new GraphQLInputObjectType( + compositeTextFilterFieldsTypeConfig, + ), + ), + }, + ); + + filterExpressionTypeConfig.fields[fieldName] = + mergeLeafAndCompositeFilterExpressionTypes( + fieldName, + filterExpressionTypeConfig.name, + { + values, + ...(fieldTypes.get(fieldName)?.isJsonObject + ? { rawFields: valuesRawFields } + : {}), + fields: createCompositeFilterExpressionType( + fieldName, + filterExpressionTypeConfig.name, + new GraphQLInputObjectType( + compositeFilterExpressionTypeConfig, + ), + ), + }, + ); + } else { + if (fieldTypes.get(fieldName)?.isJsonObject) { + orderFieldTypeConfig.fields[fieldName] = + mergeLeafAndCompositeOrderFieldTypes( + fieldName, + orderFieldTypeConfig.name, + { + rawFields: orderRawFields, + fields: createCompositeOrderFieldsType( + fieldName, + orderFieldTypeConfig.name, + new GraphQLInputObjectType(compositeOrderFieldTypeConfig), + ), + }, + ); + textFilterFieldsTypeConfig.fields[fieldName] = + mergeLeafAndCompositeTextFilterFieldsTypes( + fieldName, + textFilterFieldsTypeConfig.name, + { + rawFields: includeRawFields, + fields: createCompositeTextFilterFieldsType( + fieldName, + textFilterFieldsTypeConfig.name, + new GraphQLInputObjectType( + compositeTextFilterFieldsTypeConfig, + ), + ), + }, + ); + filterExpressionTypeConfig.fields[fieldName] = + mergeLeafAndCompositeFilterExpressionTypes( + fieldName, + filterExpressionTypeConfig.name, + { + rawFields: valuesRawFields, + fields: createCompositeFilterExpressionType( + fieldName, + filterExpressionTypeConfig.name, + new GraphQLInputObjectType( + compositeFilterExpressionTypeConfig, + ), + ), + }, + ); + } else { + orderFieldTypeConfig.fields[fieldName] = { + type: new GraphQLList( + new GraphQLNonNull( + new GraphQLInputObjectType(compositeOrderFieldTypeConfig), + ), + ), + }; + textFilterFieldsTypeConfig.fields[fieldName] = { + type: new GraphQLInputObjectType( + compositeTextFilterFieldsTypeConfig, + ), + }; + filterExpressionTypeConfig.fields[fieldName] = { + type: new GraphQLInputObjectType( + compositeFilterExpressionTypeConfig, + ), + }; + } + } + + fieldTypes.set(fieldName, { + ...fieldTypes.get(fieldName), + isComposite: true, + }); + } + }); + }); +} + +export function generateEntitiesQueryInputTypes( + schema: GraphQLSchema, +): GraphQLSchema { + const entityType = schema.getType('Entity'); + if (!entityType || !isInterfaceType(entityType)) return schema; + + const orderFieldType = schema.getType('EntityOrderField'); + + if (!orderFieldType || !isInputObjectType(orderFieldType)) { + throw new Error('"EntityOrderField" type not found or isn\'t input type'); + } + const orderFieldTypeConfig = orderFieldType.toConfig(); + orderFieldTypeConfig.fields = {}; + + const orderDirectionType = schema.getType('OrderDirection'); + if (!orderDirectionType || !isEnumType(orderDirectionType)) { + throw new Error('"OrderDirection" type not found or isn\'t enum type'); + } + + const textFilterFieldsType = schema.getType('EntityTextFilterFields'); + if (!textFilterFieldsType || !isInputObjectType(textFilterFieldsType)) { + throw new Error( + '"EntityTextFilterFields" type not found or isn\'t input type', + ); + } + const textFilterFieldsTypeConfig = textFilterFieldsType.toConfig(); + textFilterFieldsTypeConfig.fields = {}; + + const filterExpressionType = schema.getType('EntityFilterExpression'); + if (!filterExpressionType || !isInputObjectType(filterExpressionType)) { + throw new Error( + '"EntityFilterExpression" type not found or isn\'t input type', + ); + } + const filterExpressionTypeConfig = filterExpressionType.toConfig(); + filterExpressionTypeConfig.fields = {}; + + const entityRawOrderFieldType = schema.getType('EntityRawOrderField'); + if (!entityRawOrderFieldType || !isInputObjectType(entityRawOrderFieldType)) { + throw new Error( + '"EntityRawOrderField" type not found or isn\'t input type', + ); + } + + const entityRawFilterFieldType = schema.getType('EntityRawFilterField'); + if ( + !entityRawFilterFieldType || + !isInputObjectType(entityRawFilterFieldType) + ) { + throw new Error( + '"EntityRawFilterField" type not found or isn\'t input type', + ); + } + + processTypes(schema, [entityType], { + orderDirectionType, + orderFieldTypeConfig, + textFilterFieldsTypeConfig, + filterExpressionTypeConfig, + entityRawOrderFieldType, + entityRawFilterFieldType, + }); + + if (!Object.keys(orderFieldTypeConfig.fields).length) { + orderFieldTypeConfig.fields = { + _dummy: { type: orderDirectionType }, + }; + } + + if (!Object.keys(textFilterFieldsTypeConfig.fields).length) { + textFilterFieldsTypeConfig.fields = { + _dummy: { type: GraphQLBoolean }, + }; + } + + if (!Object.keys(filterExpressionTypeConfig.fields).length) { + filterExpressionTypeConfig.fields = { + _dummy: { type: new GraphQLList(new GraphQLNonNull(GraphQLJSON)) }, + }; + } + + return addTypes(schema, [ + new GraphQLInputObjectType(orderFieldTypeConfig), + new GraphQLInputObjectType(textFilterFieldsTypeConfig), + new GraphQLInputObjectType(filterExpressionTypeConfig), + ]); +} diff --git a/plugins/graphql-backend-module-catalog/src/index.ts b/plugins/graphql-backend-module-catalog/src/index.ts index 8b8b7477ce..1fe8cd2939 100644 --- a/plugins/graphql-backend-module-catalog/src/index.ts +++ b/plugins/graphql-backend-module-catalog/src/index.ts @@ -1,4 +1,5 @@ export * from './helpers'; +export * from './common'; export * from './catalog'; export * from './relation'; export * from './catalogModule'; diff --git a/plugins/graphql-backend-module-catalog/src/relation/relation.graphql b/plugins/graphql-backend-module-catalog/src/relation/relation.graphql index fc7b65b65e..51ed871b51 100644 --- a/plugins/graphql-backend-module-catalog/src/relation/relation.graphql +++ b/plugins/graphql-backend-module-catalog/src/relation/relation.graphql @@ -1,5 +1 @@ -directive @relation( - name: String - nodeType: String - kind: String -) on FIELD_DEFINITION +interface Entity @implements(interface: "Node") diff --git a/plugins/graphql-backend-module-catalog/src/relation/relation.ts b/plugins/graphql-backend-module-catalog/src/relation/relation.ts index 98a9378ae9..5ad140df5d 100644 --- a/plugins/graphql-backend-module-catalog/src/relation/relation.ts +++ b/plugins/graphql-backend-module-catalog/src/relation/relation.ts @@ -1,8 +1,8 @@ import { createModule } from 'graphql-modules'; -import { relationDirectiveMapper } from '../relationDirectiveMapper'; -import { GraphQLModule } from '@frontside/hydraphql'; import { loadFilesSync } from '@graphql-tools/load-files'; import { resolvePackagePath } from '@backstage/backend-common'; +import { GraphQLModule } from '@frontside/hydraphql'; +import { Common } from '../common'; const relationSchemaPath = resolvePackagePath( '@frontside/backstage-plugin-graphql-backend-module-catalog', @@ -11,9 +11,13 @@ const relationSchemaPath = resolvePackagePath( /** @public */ export const Relation = (): GraphQLModule => ({ - mappers: { relation: relationDirectiveMapper }, + mappers: { ...Common().mappers }, + postTransform: Common().postTransform, module: createModule({ - id: 'relation', - typeDefs: loadFilesSync(relationSchemaPath), + id: 'catalog-relation', + typeDefs: [...Common().module.typeDefs, ...loadFilesSync(relationSchemaPath)], + resolvers: { + ...Common().module.config.resolvers, + } }) }); diff --git a/plugins/graphql-backend-module-catalog/src/relationDirectiveMapper.test.ts b/plugins/graphql-backend-module-catalog/src/relationDirectiveMapper.test.ts index 8c38bcfc38..b10f430803 100644 --- a/plugins/graphql-backend-module-catalog/src/relationDirectiveMapper.test.ts +++ b/plugins/graphql-backend-module-catalog/src/relationDirectiveMapper.test.ts @@ -5,27 +5,29 @@ import { } from '@frontside/hydraphql'; import DataLoader from 'dataloader'; import { DocumentNode, GraphQLNamedType, printType } from 'graphql'; -import { createModule, gql } from 'graphql-modules'; +import { Module, createModule, gql } from 'graphql-modules'; import { createGraphQLAPI } from './__testUtils__'; import { Relation } from './relation/relation'; +import { Common } from './common'; describe('mapRelationDirective', () => { - const transform = (source: DocumentNode) => + const transform = (source: DocumentNode, anotherModule?: Module) => transformSchema([ + Common(), Relation(), createModule({ id: 'mapRelationDirective', typeDefs: source, }), + ...(anotherModule ? [anotherModule] : []), ]); it('should add subtypes to a union type', () => { const schema = transform(gql` union Ownable = Entity - interface Entity - @discriminates(with: "kind") - @implements(interface: "Node") { + extend interface Entity + @discriminates(with: "kind") { name: String! } interface Resource @@ -74,9 +76,8 @@ describe('mapRelationDirective', () => { const schema = transform(gql` union Ownable = Entity - interface Entity - @discriminates(with: "kind") - @implements(interface: "Node") { + extend interface Entity + @discriminates(with: "kind") { name: String! } type Resource @implements(interface: "Entity") { @@ -131,7 +132,7 @@ describe('mapRelationDirective', () => { it("should fail if @relation interface doesn't exist", () => { expect(() => transform(gql` - interface Entity { + extend interface Entity { owners: Connection @relation(name: "ownedBy", nodeType: "Owner") } `), @@ -143,7 +144,7 @@ describe('mapRelationDirective', () => { it('should fail if @relation interface is input type', () => { expect(() => transform(gql` - interface Entity { + extend interface Entity { owners: Connection @relation(name: "ownedBy", nodeType: "OwnerInput") } input OwnerInput { @@ -158,7 +159,7 @@ describe('mapRelationDirective', () => { it('should fail if Connection type is in a list', () => { expect(() => transform(gql` - interface Entity { + extend interface Entity { owners: [Connection] @relation(name: "ownedBy", nodeType: "Owner") } interface Owner { @@ -173,7 +174,7 @@ describe('mapRelationDirective', () => { it('should fail if Connection has arguments are not valid types', () => { expect(() => transform(gql` - interface Entity { + extend interface Entity { owners(first: String!, after: Int!): Connection @relation(name: "ownedBy", nodeType: "Owner") } @@ -189,7 +190,7 @@ describe('mapRelationDirective', () => { it('should fail if @relation and @field are used on the same field', () => { expect(() => transform(gql` - interface Entity { + extend interface Entity { owners: Connection @relation(name: "ownedBy", nodeType: "Owner") @field(at: "name") diff --git a/plugins/graphql-backend-module-catalog/src/resolvers.ts b/plugins/graphql-backend-module-catalog/src/resolvers.ts new file mode 100644 index 0000000000..a2475fcdea --- /dev/null +++ b/plugins/graphql-backend-module-catalog/src/resolvers.ts @@ -0,0 +1,528 @@ +import _ from 'lodash'; +import { encodeId } from '@frontside/hydraphql'; +import { Resolvers } from 'graphql-modules'; +import { CATALOG_SOURCE } from './constants'; +import { stringifyEntityRef } from '@backstage/catalog-model'; +import { CatalogApi } from '@backstage/catalog-client'; +import { Connection } from 'graphql-relay'; +import { encodeEntityId } from './helpers'; +import { getDirective } from '@graphql-tools/utils'; +import { + GraphQLCompositeType, + GraphQLSchema, + getNamedType, + isCompositeType, + isInterfaceType, + isUnionType, +} from 'graphql'; + +type OrderDirection = 'ASC' | 'DESC'; + +interface CatalogCursor { + firstSortFieldValues?: [string, string]; + orderFieldValues?: [string, string] | never[]; + totalItems?: number; + isPrevious: boolean; + orderFields?: Array<{ field: string; order: 'asc' | 'desc' }>; + fullTextSearch?: { term: string; fields?: string[] }; + filter?: { anyOf: Array<{ allOf: { key: string; values: string[] }[] }> }; +} + +function parseEntityFilter(filter: { fields: unknown }[]) { + return { anyOf: filter.map(({ fields }) => ({ allOf: fields })) }; +} + +function traverseFieldDirectives( + type: GraphQLCompositeType, + schema: GraphQLSchema, + { isNested = false }: { isNested?: boolean } = {}, +) { + const fieldMap = new Map }>(); + if (isUnionType(type)) { + type.getTypes().forEach(subType => { + traverseFieldDirectives(subType, schema, { isNested }).forEach( + (mappedFieldNames, childFieldName) => { + const mappedFields = [ + ...(fieldMap.get(childFieldName)?.fields ?? []), + ...mappedFieldNames.fields, + ]; + fieldMap.set(childFieldName, { + isLeaf: fieldMap.get(childFieldName)?.isLeaf ?? false, + fields: new Set(mappedFields), + }); + }, + ); + }); + } else { + if (isInterfaceType(type)) { + const { interfaces, objects } = schema.getImplementations(type); + [...interfaces, ...objects].forEach(subType => { + traverseFieldDirectives(subType, schema, { isNested }).forEach( + (mappedFieldNames, childFieldName) => { + const mappedFields = [ + ...(fieldMap.get(childFieldName)?.fields ?? []), + ...mappedFieldNames.fields, + ]; + fieldMap.set(childFieldName, { + isLeaf: fieldMap.get(childFieldName)?.isLeaf ?? false, + fields: new Set(mappedFields), + }); + }, + ); + }); + } + Object.entries(type.getFields()).forEach(([fieldName, field]) => { + const [fieldDirective] = getDirective(schema, field, 'field') ?? []; + const [resolveDirective] = getDirective(schema, field, 'resolve') ?? []; + const [sourceTypeDirective] = + getDirective(schema, field, 'sourceType') ?? []; + if (!fieldDirective && !isNested || resolveDirective) return; + + const fieldType = sourceTypeDirective + ? schema.getType(sourceTypeDirective.name) + : field.type; + const unwrappedType = getNamedType(fieldType); + + const mappedFieldName = fieldDirective?.at ?? fieldName; + const fields = (fieldMap.get(fieldName)?.fields ?? new Set()).add( + Array.isArray(mappedFieldName) + ? mappedFieldName.join('.') + : mappedFieldName, + ); + + if (isCompositeType(unwrappedType)) { + fieldMap.set(fieldName, { isLeaf: false, fields }); + traverseFieldDirectives(unwrappedType, schema, { + isNested: true, + }).forEach((mappedFieldNames, childFieldName) => { + const childFieldPath = `${fieldName}.${childFieldName}`; + const mappedFields = [ + ...(fieldMap.get(childFieldPath)?.fields ?? []), + ...[...mappedFieldNames.fields].flatMap(childMappedField => + [...(fieldMap.get(fieldName)?.fields ?? [])].map( + parentMappedFieldName => + `${parentMappedFieldName}.${childMappedField}`, + ), + ), + ]; + fieldMap.set(childFieldPath, { + isLeaf: fieldMap.get(childFieldName)?.isLeaf ?? false, + fields: new Set(mappedFields), + }); + }); + } else { + fieldMap.set(fieldName, { isLeaf: true, fields }); + } + }); + } + + return fieldMap; +} + +function mapMatchFilterToQueryFilter( + match: Record>, + fieldMap: Map }>, + parentKey?: string, +): ( + | { key: string; values: unknown[] } + | { anyOf: { key: string; values: unknown[] }[] } +)[] { + if (parentKey && fieldMap.get(parentKey)?.isLeaf) { + const { values, rawFields, fields } = match; + const sourceFields = [...(fieldMap.get(parentKey)?.fields ?? [])]; + return [ + ...(Array.isArray(values) + ? [ + { + anyOf: [ + ...sourceFields.map(fieldKey => ({ key: fieldKey, values })), + ], + }, + ] + : []), + ...(rawFields + ? (rawFields as { key: string; values: unknown[] }[]).map(rawField => ({ + anyOf: [ + ...sourceFields.map(fieldKey => ({ + key: `${fieldKey}.${rawField.key}`, + values: rawField.values, + })), + ], + })) + : []), + ...(fields + ? mapMatchFilterToQueryFilter( + fields as Record, + fieldMap, + parentKey, + ) + : []), + ]; + } + return Object.entries(match).reduce((filters, [fieldName, fieldValues]) => { + const matchKey = parentKey ? `${parentKey}.${fieldName}` : fieldName; + const sourceFields = [...(fieldMap.get(matchKey)?.fields ?? [])] + if (Array.isArray(fieldValues)) { + const [firstValue] = fieldValues + if (firstValue && typeof firstValue === 'object' && 'key' in firstValue && typeof firstValue?.key === 'string') { + return [ + ...filters, + ...(fieldValues as { key: string; values: unknown[] }[]).map((rawField) => ({ + anyOf: sourceFields.map(fieldKey => ({ + key: `${fieldKey}.${rawField.key}`, + values: rawField.values + })) + })) + ] + } + return [ + ...filters, + { + anyOf: sourceFields.map(fieldKey => ({ + key: fieldKey, + values: fieldValues as unknown[], + })), + }, + ]; + } + + return [ + ...filters, + ...mapMatchFilterToQueryFilter(fieldValues, fieldMap, matchKey), + ]; + }, [] as ({ key: string; values: unknown[] } | { anyOf: { key: string; values: unknown[] }[] })[]); +} + +function mapOrderFieldsToQueryOrder( + orders: Record< + string, + | OrderDirection + | { field: string; order: OrderDirection }[] + | { + order?: OrderDirection; + rawFields?: { field: string; order: OrderDirection }[]; + fields?: Record[]; + } + | Record[] + >[], + fieldMap: Map }>, + parentKey?: string, +): { field: string; order: string }[] { + return orders.flatMap(fieldOrder => { + if (Object.keys(fieldOrder).length > 1) { + throw new Error('Cannot have more than one field in order field object'); + } + const [[fieldName, directionOrChild]] = Object.entries(fieldOrder); + const orderKey = parentKey ? `${parentKey}.${fieldName}` : fieldName; + const sourceFields = [...(fieldMap.get(orderKey)?.fields ?? [])]; + if (typeof directionOrChild === 'string') { + return sourceFields.map(fieldKey => ({ + field: fieldKey, + order: directionOrChild.toLowerCase(), + })); + } + if (Array.isArray(directionOrChild)) { + const [firstChild] = directionOrChild; + if ( + // NOTE: isRawFields + typeof firstChild.field === 'string' && + ['ASC', 'DESC'].includes(firstChild.order) + ) { + return [ + ...directionOrChild.flatMap(({ field, order }) => + sourceFields.map(fieldKey => ({ + field: `${fieldKey}.${field}`, + order: order.toLowerCase(), + })), + ), + ]; + } + return [ + ...mapOrderFieldsToQueryOrder( + directionOrChild as Record[], + fieldMap, + orderKey, + ), + ]; + } + const { fields, rawFields, order } = directionOrChild; + if (Object.keys(directionOrChild).length > 1) { + throw new Error( + 'Only one of "fields", "rawFields" and "order" is allowed in order field object', + ); + } + if (rawFields) { + return [ + ...rawFields.flatMap(rawField => + sourceFields.map(fieldKey => ({ + field: `${fieldKey}.${rawField.field}`, + order: rawField.order.toLowerCase(), + })), + ), + ]; + } + if (fields) { + return mapOrderFieldsToQueryOrder(fields, fieldMap, orderKey); + } + if (order) { + return [...(fieldMap.get(orderKey)?.fields ?? [])].map(fieldKey => ({ + field: fieldKey, + order: order.toLowerCase(), + })); + } + return []; + }); +} + +function mapSearchFilterToTextSearch( + search: Record< + string, + | boolean + | string[] + | Record< + string, + { + include?: boolean; + rawFields?: string[]; + fields?: Record; + } + > + | Record + >, + fieldMap: Map }>, + parentKey?: string, +): string[] { + if (parentKey && fieldMap.get(parentKey)?.isLeaf) { + const { include, rawFields, fields } = search; + const sourceFields = [...(fieldMap.get(parentKey)?.fields ?? [])]; + return [ + ...(include ? sourceFields : []), + ...((rawFields ?? []) as string[]).flatMap(rawField => + sourceFields.map(fieldKey => `${fieldKey}.${rawField}`), + ), + ...(fields + ? mapSearchFilterToTextSearch( + fields as Record, + fieldMap, + parentKey, + ) + : []), + ]; + } + return Object.entries(search).flatMap(([fieldName, value]) => { + const searchKey = parentKey ? `${parentKey}.${fieldName}` : fieldName; + const sourceFields = [...(fieldMap.get(fieldName)?.fields ?? [])]; + if (value === true) { + return sourceFields; + } + if (Array.isArray(value)) { + return value.flatMap(rawField => + sourceFields.map(fieldKey => `${fieldKey}.${rawField}`), + ); + } + if (typeof value === 'object') { + return [ + ...mapSearchFilterToTextSearch( + value as Record, + fieldMap, + searchKey, + ), + ]; + } + return []; + }); +} + +export const queryResolvers: () => Resolvers = () => { + let fieldMap: Map }> | null = + null; + + return { + entity: ( + _root: any, + { + name, + kind, + namespace = 'default', + }: { name: string; kind: string; namespace: string }, + ): { id: string } => ({ + id: encodeId({ + source: CATALOG_SOURCE, + typename: 'Entity', + query: { ref: stringifyEntityRef({ name, kind, namespace }) }, + }), + }), + entities: async ( + _root: any, + { + first, + after, + last, + before, + filter, + rawFilter, + }: { + first?: number; + after?: string; + last?: number; + before?: string; + filter?: { + match?: Record[]; + order?: Record[]; + search?: { term: string; fields: Record }; + }; + rawFilter?: { + filter?: { fields: unknown[] }[]; + orderFields?: { field: string; order: OrderDirection }[]; + fullTextFilter?: { term: string; fields?: string[] }; + }; + } = {}, + { catalog }: { catalog: CatalogApi }, + { schema }: { schema: GraphQLSchema }, + ): Promise> => { + if (filter && rawFilter) { + throw new Error( + 'Both "filter" and "rawFilter" arguments cannot be used together', + ); + } + + if (!fieldMap) { + fieldMap = traverseFieldDirectives( + schema.getType('Entity') as GraphQLCompositeType, + schema, + ); + } + + const decodedCursor = (c => + c ? JSON.parse(Buffer.from(c, 'base64').toString('utf8')) : undefined)( + after ?? before, + ); + + const cursorObject: Partial = { + orderFieldValues: [], + }; + if (decodedCursor) { + Object.assign(cursorObject, decodedCursor); + } else { + const orderFields = (() => { + if (rawFilter?.orderFields) { + return rawFilter.orderFields.map( + ({ field, order }: { field: string; order: OrderDirection }) => ({ + field, + order: order.toLowerCase(), + }), + ); + } + if (filter?.order) { + return mapOrderFieldsToQueryOrder( + filter.order as Record[], + fieldMap, + ); + } + return [{ field: 'metadata.uid', order: 'asc' }]; + })(); + + const fullTextSearch = (() => { + if (rawFilter?.fullTextFilter) { + return { + term: rawFilter.fullTextFilter.term, + fields: rawFilter.fullTextFilter.fields ?? undefined, + }; + } + if (filter?.search) { + return { + term: filter.search.term, + fields: filter.search.fields + ? mapSearchFilterToTextSearch( + filter.search.fields as Record, + fieldMap, + ) + : undefined, + }; + } + return { term: '' }; + })(); + + const queryFilter = (() => { + if (rawFilter?.filter) { + return parseEntityFilter(rawFilter.filter); + } + if (filter?.match) { + return { + anyOf: filter.match.map((match: unknown) => ({ + allOf: mapMatchFilterToQueryFilter( + match as Record, + fieldMap!, + ), + })), + }; + } + return undefined; + })(); + + Object.assign(cursorObject, { + orderFields, + fullTextSearch, + filter: queryFilter, + }); + } + const cursor = Buffer.from( + JSON.stringify({ + ...cursorObject, + isPrevious: + (first === undefined && last !== undefined) || + (after === undefined && before !== undefined), + }), + 'utf8', + ).toString('base64'); + + let limit: number | undefined = first ?? last; + if (after) limit = first; + if (before) limit = last; + + const orderField = cursorObject.orderFields?.[0]?.field; + const { items, pageInfo, totalItems } = await catalog.queryEntities({ + fields: [ + 'metadata.uid', + 'metadata.name', + 'metadata.namespace', + 'kind', + ...(orderField ? [orderField] : []), + ], + cursor, + limit, + }); + + // TODO Reuse field's resolvers https://github.com/thefrontside/HydraphQL/pull/22 + return { + edges: items.map(item => ({ + cursor: Buffer.from( + JSON.stringify({ + totalItems, + firstSortFieldValues: [ + orderField + ? _.get(items[0], orderField) + : items[0].metadata.uid, + items[0].metadata.uid, + ], + ...cursorObject, + orderFieldValues: [ + orderField ? _.get(item, orderField) : item.metadata.uid, + item.metadata.uid, + ], + }), + 'utf8', + ).toString('base64'), + node: { id: encodeEntityId(item) }, + })), + pageInfo: { + startCursor: pageInfo.prevCursor ?? undefined, + endCursor: pageInfo.nextCursor ?? undefined, + hasPreviousPage: Boolean(pageInfo.prevCursor), + hasNextPage: Boolean(pageInfo.nextCursor), + }, + count: totalItems, + } as Connection<{ id: string }>; + }, + }; +}; diff --git a/plugins/graphql-backend-node/package.json b/plugins/graphql-backend-node/package.json index 09e3502555..714e08868d 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.7", - "@frontside/hydraphql": "^0.1.2", + "@frontside/hydraphql": "^0.1.3", "dataloader": "^2.1.0", "graphql-modules": "^2.3.0", "graphql-yoga": "^4.0.3" diff --git a/plugins/graphql-backend/package.json b/plugins/graphql-backend/package.json index 1419ddd25e..836e748774 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.5", - "@frontside/hydraphql": "^0.1.2", + "@frontside/hydraphql": "^0.1.3", "dataloader": "^2.1.0", "express": "^4.17.1", "express-promise-router": "^4.1.0", diff --git a/yarn.lock b/yarn.lock index ef6df6a44e..5dcd0a62c4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5091,10 +5091,10 @@ graphql "16.5.0" hash.js "1.1.7" -"@frontside/hydraphql@^0.1.2": - version "0.1.2" - resolved "https://registry.yarnpkg.com/@frontside/hydraphql/-/hydraphql-0.1.2.tgz#15b68f1b507d5edecc2f69161d5b2e8ab52f0d84" - integrity sha512-DD7+YG1zO0aHQp6K8bFUSKrj5kzlj0f8sOou6oQxSHxzjsggRiFis41ejeUxP/c6QwXns3tgxJfYFW1U3gdRUw== +"@frontside/hydraphql@^0.1.3": + version "0.1.3" + resolved "https://registry.yarnpkg.com/@frontside/hydraphql/-/hydraphql-0.1.3.tgz#11107a050f78d096d2b69327b7753c586d1b7e17" + integrity sha512-frXzM8452J3sXwV182XtRNyBdgnCpJe2voUFZk3NQq2thz5D+IDoFK4yXa7MsO9JqmzOND23SPQJmFpIdBvM6w== dependencies: "@graphql-tools/code-file-loader" "^8.0.0" "@graphql-tools/graphql-file-loader" "^8.0.0"