diff --git a/www/docs/backstage.mdx b/www/docs/backstage.mdx deleted file mode 100644 index 6df5a81..0000000 --- a/www/docs/backstage.mdx +++ /dev/null @@ -1,70 +0,0 @@ -The [Backstage GraphQL plugin][graphql-backend] is designed for schema-first -development of the GraphQL API. It reduces work necessary to expand the schema -using schema directives. [Schema directives](https://the-guild.dev/graphql/tools/docs/schema-directives) -are extensions to GraphQL schema used to automate implementation of the GraphQL API. -In Backstage GraphQL Plugin, schema directives are used to automatically create -resolvers. [Resolvers](https://graphql.org/learn/execution/) tell a GraphQL API -how to provide data for a specific schema type or field. The Backstage GraphQL -Plugin uses what it knows about the Backstage catalog to reduce the need for -writing resolvers that call the catalog. - -Currently, Backstage implemented two backend systems: - -- [EXPERIMENTAL Backstage backend system](https://backstage.io/docs/backend-system/) -- [Backstage backend plugins](https://backstage.io/docs/plugins/backend-plugin) - -### Usage with backend system - -The full cover of using GraphQL with EXPERIMENTAL Backstage backend system you can -find in [readme](https://github.com/thefrontside/playhouse/blob/main/plugins/graphql-backend/README.md) - -```typescript -// packages/backend/src/index.ts -import { graphqlPlugin } from '@frontside/backstage-plugin-graphql-backend'; -import { graphqlModuleCatalog } from '@frontside/backstage-plugin-graphql-backend-module-catalog'; - -// Initializing Backstage backend -const backend = createBackend(); - -// Adding GraphQL plugin -backend.use(graphqlPlugin()); -// Adding Catalog GraphQL module -backend.use(graphqlModuleCatalog()); -``` - -### Usage with backend plugins - -Using the old Backstage backend plugins system is also fully covered in -[readme](https://github.com/thefrontside/playhouse/blob/main/plugins/graphql-backend/docs/backend-plugins.md) - -```typescript -// packages/backend/src/plugins/graphql.ts -import { createRouter } from '@frontside/backstage-plugin-graphql-backend'; -import { Catalog, createCatalogLoader } from '@frontside/backstage-plugin-graphql-backend-module-catalog'; - -export default async function createPlugin( - env: PluginEnvironment, -): Promise { - return await createRouter({ - logger: env.logger, - modules: [Catalog], - loaders: { ...createCatalogLoader(env.catalogClient) }, - }); -} -``` - -### Catalog module - -The single GraphQL plugin isn't useful by itself. To able query Backstage Catalog -using GraphQL queries you need to install the [Catalog module][catalog-module]. - -The Catalog module provides [`@relation`](./relation) schema directive and data -loader for Catalog API. It also has well written [Catalog GraphQL module][catalog-schema] -with most basic Backstage types. We recommend to use it as a starting point. But -if you'd like to implement your own type structure you can use [Relation GraphQL module][relation-schema]. -Relation module contains only `@relation` schema directive and Catalog data loader. - -[graphql-backend]: https://github.com/thefrontside/playhouse/blob/main/plugins/graphql-backend -[catalog-module]: https://github.com/thefrontside/playhouse/blob/main/plugins/graphql-backend-module-catalog -[catalog-schema]: https://github.com/thefrontside/playhouse/blob/main/plugins/graphql-backend-module-catalog/src/catalog -[relation-schema]: https://github.com/thefrontside/playhouse/blob/main/plugins/graphql-backend-module-catalog/src/relation diff --git a/www/docs/hydraphql.mdx b/www/docs/hydraphql.mdx new file mode 100644 index 0000000..5da89cf --- /dev/null +++ b/www/docs/hydraphql.mdx @@ -0,0 +1,352 @@ +### GraphQL basics + +GraphQL is a powerful query language and runtime for APIs. It provides a flexible +and efficient approach to requesting and manipulating data from servers. With GraphQL, +clients can specify the exact data they need, and the server responds with +a JSON payload that matches the requested structure. It allows clients to retrieve +multiple resources in a single request, reducing over-fetching and under-fetching of data. + +The GraphQL schema defines a collection of types and the relationships between them. +The syntax of the schema language is very intuitive and readable. It makes it easy +to understand the capabilities of the API. + +```graphql +type User { + id: ID! + name: String! + email: String +} + +type Query { + user(id: ID!): User +} +``` + +On the other hand simplicity causes some downsides: + +- The schema doesn't describe how the data should be processed to fit the schema. + It only describes the data structure. Which means that the developer has to write + imperative code to resolve types and fields from the data. +- The schema language requires a lot of boilerplate code to be written. + +```graphql +interface Character { + id: ID! + name: String! + friends: [Character] + appearsIn: [Episode]! +} + +type Human implements Character { + id: ID! + name: String! + friends: [Character] + appearsIn: [Episode]! + starships: [Starship] + totalCredits: Int +} + +type Droid implements Character { + id: ID! + name: String! + friends: [Character] + appearsIn: [Episode]! + primaryFunction: String +} +``` + +To solve these problems, HydraphQL was created. + +### What is HydraphQL? + +HydraphQL is a set of GraphQL directives that allow you to enhance your schema, +declaratively define how the data should be processed and reduce the amount of +boilerplate code. It's the core piece of [Backstage GraphQL Plugin](./backstage) + +```graphql +interface Character @discriminates(with: "type") { + id: ID! + name: String! @field + friends: [Character] @resolve + appearsIn: [Episode]! @resolve(at: "appears_in") +} + +type Human @implements(interface: "Character") { + starships: [Starship] @resolve + totalCredits: Int @field(at: "total_credits") +} + +type Droid @implements(interface: "Character") { + primaryFunction: String @field(at: "primary_function") +} +``` + +### Quick start + +HydraphQL transforms directives to raw GraphQL schema and adds a set of resolvers. + +To start using it you will need to install some packages: + +```bash +yarn add dataloader graphql graphql-modules +``` + +And then you can install HydraphQL itself: + +```bash +yarn add @frontside/hydraphql +``` + +### Usage + +So after you've installed packages you can create your first GraphQL application. +HydraphQL uses [graphql-modules] under the hood and returns an instance of +[`Application`](https://the-guild.dev/graphql/modules/docs/api#application). You also +can checkout how to use it with your GraphQL server in the [server]. + +```typescript +import { createGraphQLApp } from '@frontside/hydraphql'; + +export async function main() { + const application = await createGraphQLApp(); + + const schema = application.schema; + const execute = application.createExecution(); + const subscribe = application.createSubscription(); +} +``` + +An empty application isn't very useful, so let's add a simple module to it. You can +create directory `src/modules` and add two files (a GraphQL schema and a module itself): + +```graphql +# src/modules/my-module.graphql +type Asset { + id: ID! + symbol: String! + name: String! + supply: String! + marketCapUsd: String! + volumeUsd24Hr: String! + priceUsd: String! + changePercent24Hr: String! +} + +extend type Query { + asset(id: String!): Asset! +} +``` + +```typescript +// src/modules/my-module.ts +import { createModule } from 'graphql-modules'; +import { loadFilesSync } from '@graphql-tools/load-files'; + +export const MyModule = createModule({ + id: 'my-module', + dirname: __dirname, + typeDefs: loadFilesSync(require.resolve('./my-module.graphql')), + resolvers: { + Query: { + asset: async (_, { id }) => { + const response = await fetch(`https://api.coincap.io/v2/assets/${id}`); + const { data } = await response.json(); + return data; + }, + }, + }, +}); +``` + +After we created a module we need to add it to our application: + +```typescript +// src/application.ts +import http from 'http'; +import { createHandler } from 'graphql-http'; +import { createGraphQLApp } from "@frontside/hydraphql"; +import { MyModule } from "./modules/my-module"; + +export async function main() { + const application = await createGraphQLApp({ modules: [MyModule] }); + + const server = http.createServer( + createHandler({ + schema: application.schema, + execute: application.createExecution(), + }) + ); + + server.listen(4000, () => console.log(`πŸš€ Server ready at http://localhost:4000/`)); +} +``` + +And you are done! Now you can connect to your server from a [playground] +and run a query: + +```graphql +query { + asset(id: "polkadot") { + id + symbol + name + supply + marketCapUsd + volumeUsd24Hr + priceUsd + changePercent24Hr + } +} +``` + +### Adding a data loader + +Our application is working, but functionality is pretty limited. We can only fetch +one asset at a time, but what if we want to fetch multiple assets? Or fetch more +information about assets? We can add a data loader to our application to solve this. +It's highly recommended to use `createLoader` that creates a [data loader][loader] +for you even if you have only one data source. Let's add a data loader to our application: + +```typescript +// src/loader.ts +import type { NodeQuery } from "@frontside/hydraphql"; + +export async function Assets(queries: readonly NodeQuery[]) { + const ids = queries.map(query => query.ref); + const response = await fetch( + `https://api.coincap.io/v2/assets?ids=${ids.join(',')}&limit=${queries.length}` + ); + const { data } = await response.json(); + + return data; +} + +export async function Markets(queries: readonly NodeQuery[]) { + const responses = await Promise.all(queries + .map(query => query.ref) + .map(id => fetch(`https://api.coincap.io/v2/assets/${id}/markets`))); + const result = await Promise.all(responses.map(response => response.json())); + + return result.map(({ data }) => data); +} + +export async function Exchanges(queries: readonly NodeQuery[]) { + const responses = await Promise.all(queries + .map(query => query.ref) + .map(id => fetch(`https://api.coincap.io/v2/exchanges/${id}`))); + const result = await Promise.all(responses.map(response => response.json())); + + return result.map(({ data }) => data); +} +``` + +```typescript +// src/application.ts +import { createServer } from 'node:http'; +import { createHandler } from 'graphql-http'; +import { createGraphQLApp, createLoader } from "@frontside/hydraphql"; +import { MyModule } from "./modules/my-module/my-module"; +import { Assets, Markets, Exchanges } from "./loader"; + +export async function main() { + const application = await createGraphQLApp({ modules: [MyModule] }); + // NOTE: We are creating data loader with a few sources + const loader = createLoader({ Assets, Markets, Exchanges }); + + const server = createServer( + createHandler({ + context: { loader }, + schema: application.schema, + execute: application.createExecution(), + }) + ); + + server.listen(4000, () => console.log(`πŸš€ Server ready at http://localhost:4000/`)); +} +``` + +In the last step we need to update our schema + +```graphql +extend interface Node + @discriminates(with: "__source") + @discriminationAlias(value: "Assets", type: "Asset") + @discriminationAlias(value: "Markets", type: "Market") + @discriminationAlias(value: "Exchanges", type: "Exchange") {} + +type Asset @implements(interface: "Node") { + symbol: String! @field + name: String! @field + supply: String! @field + marketCapUsd: String! @field + volumeUsd24Hr: String! @field + priceUsd: String! @field + changePercent24Hr: String! @field + marketsList: [Market!]! @resolve(at: "id", from: "Markets") + markets: Connection @resolve(at: "id", nodeType: "Market", from: "Markets") +} + +type Market @implements(interface: "Node") { + exchange: Exchange! @resolve(at: "exchangeId", from: "Exchanges") + asset: Asset! @resolve(at: "baseId", from: "Assets") + quoteId: String! @field + assetSymbol: String! @field(at: "baseSymbol") + quoteSymbol: String! @field + priceUsd: String! @field + volumeUsd24Hr: String! @field + volumePercent: String! @field +} + +type Exchange @implements(interface: "Node") { + name: String! @field + rank: String! @field + percentTotalVolume: String! @field + volumeUsd: String! @field + tradingPairs: String! @field + socket: Boolean! @field + exchangeUrl: String! @field + updated: Int! @field +} +``` + +Now we can fetch multiple assets at once and fetch more information about them: + +```graphql +query { + nodes(ids: ["polkadot", "bitcoin", "ethereum"]) { + id + symbol + name + supply + marketCapUsd + volumeUsd24Hr + priceUsd + changePercent24Hr + markets(first: 5) { + id + exchange { + id + name + rank + percentTotalVolume + volumeUsd + tradingPairs + socket + exchangeUrl + updated + } + quoteId + assetSymbol + quoteSymbol + priceUsd + volumeUsd24Hr + volumePercent + } + } +} +``` + +[graphql-modules]: https://the-guild.dev/graphql/modules/docs +[playground]: https://studio.apollographql.com/sandbox/explorer +[loader]: https://github.com/graphql/dataloader +[server]: ./server diff --git a/www/docs/introduction.mdx b/www/docs/introduction.mdx index 4ce64c8..43b1397 100644 --- a/www/docs/introduction.mdx +++ b/www/docs/introduction.mdx @@ -1,84 +1,213 @@ -### GraphQL basics +### Graph as the medium -GraphQL is a powerfull query language and runtime for APIs. It provides a flexible -and efficient approach to requesting and manipulating data from servers. With GraphQL, -clients can specify the exact data they need, and the server responds with -a JSON payload that matches the requested structure. It allows clients to retrieve -multiple resources in a single request, reducing over-fetching and under-fetching of data. +The Backstage catalog model is deliberately designed to be open to extension +so that it can house whatever set of attributes a company might need to represent +its entities. This means that the `spec` property of an Entity can contain +anything (which is good), but at the cost of being awkward to work with because +it is just a blob of un-typed JSON data over which each client must layer its +own structure at the point of consumption. For example: -The GraphQL schema defines a collection of types and the relationships between them. -The syntax of the schema language is very intuitive and readable. It makes it easy -to understand the capabilities of the API. +```typescript +let employee = await client.getEntityByRef("employee:cowboyd"); + +// let's say that "address" was changed to "location" +employee.spec.address.country; // TypeError: property 'country' does not exist on undefined +``` + +If the shape of the data changes, the client side types will just be straight +up wrong, and as a result must be carefully kept in sync with the backend. + +The story is similar when it comes to working with relationships. The flexibility +of the catalog model in this regard is powerful in that any entity can be related +to any other entity, and the type of relation that can join two entities together +in this manner is unbounded. Once again however, [working] with [these relationships][these] +can [be awkward][awkward] using the `CatalogApi` by itself. + +Not only is it cumbersome, it can also be inefficient. This is because without a +[bespoke batching mechanism][batching] at the point of consumption, the lowest +activation energy solution is going to suffer from the N+1 problem by manually +dereferencing each relationship: + +```typescript +const [ownedByRelation] = getEntityRelations(entity, RELATION_OWNED_BY); +const owner = ownedByRelation + ? await client.getEntityByRef(ownedByRelation.targetRef) + : null; +``` + +### GraphQL API + +Compare how GraphQL works where if we attempt to query a field that does not exist, +it immediately triggers an error. This is because we cannot express the API +without also expressing its shape. There is more info about [querying] + +```typescript +let employee = await client.query( + `node(id: "employee:cowboyd") { address { country } }` +); // TypeError: field "address" does not exist on type "Employee" +``` + +Even better, we can use [GraphQL Codegen](https://the-guild.dev/graphql/codegen) +to surface this category of error at *compile time* which can yield enormous +times savings for each dev cycle. Check out how to setup [codegen]. + +In terms of relationships a GraphQL API has the possibility of both querying +and traversing relationships as the natural expression of its schema, we can +just access it directly: + +```typescript +const owner = entity.ownedBy; +``` + +And, because resolution happens on the server, it can be responsible for properly +batching entity loads according to the depth of the query, thereby removing +[the burden][burden] from the shoulders of the developer. + +### GraphQL Schema + +The entity format is great for representing the graph internally, but it imposes +unnecessary ceremony when working with it from the outside. As an API consumer, +nobody cares whether a description of a user's role is in `metadata.description`, +or if their name to display on a web page is inside `spec.profile.displayName`, +they just want the data. By the same token, in order to work with related entities, +nobody wants to have to go through the rigmarole of reading relations and +assembling them by hand. Instead, they just want to access the related records +directly. + +To make this happen, we define a set of [GraphQL directives][directives] that +allow us to directly map values from an entity to a GraphQL type and thereby +generate the resolvers for it automatically. To map the fields like the ones +above, we introduce a [`@field`][field] directive to pull data out of the envelope and +give it directly to the developer. ```graphql -type User { - id: ID! - name: String! - email: String +interface Entity { + name: String! @field(at: "metadata.name") + kind: String! @field(at: "kind") + namespace: String! @field(at: "metadata.namespace", default: "default") + title: String! @field(at: "metadata.title", default: "") + description: String! @field(at: "metadata.description", default: "") + tags: [String] @field(at: "metadata.tags") + links: [EntityLink] @field(at: "metadata.links") } +``` + +> πŸ’‘By default the kind of an entity is determined by the GraphQL type's name, +> so in the preceding example, the entities to which this applies will be +> kind: "User" more on this mechanism later -type Query { - user(id: ID!): User +GraphQL provides the opportunity to navigate relationships in the same way as +you would a simple data structure, and because the Backstage catalog has a +normalized way of expressing relations between nodes, we can leverage it to +automatically write the resolvers for related records. To do this, we introduce +a [`@relation`][relation] directive. Take this example snippet from the proposed +catalog schema: + +```graphql +union Owner = User | Group + +type System { + owner: Owner! @relation(name: "ownedBy") + domain: Domain @relation(name: "partOf") + apis: Connection @relation(name: "hasPart", nodeType: "API", kind: "api") + components: Connection + @relation(name: "hasPart", nodeType: "Component", kind: "component") + resources: Connection + @relation(name: "hasPart", nodeType: "Resource", kind: "resource") } ``` -On the other hand simplicity causes some downsides: +This will tell the catalog loader to look up the relation of type β€œownedBy” and +use its target as the value of the `owner` property. -- The schema doesn't describe how the data should be processed to fit the schema. - It only describes the data structure. Which means that the developer has to write - imperative code to resolve types and fields from the data. -- The schema language requires a lot of boilerplate code to be written. +```typescript +system.owner.name //=> "team-a" +``` + +With all advantages that GraphQL provides there are some aspects that might add +difficulties of maintaining GraphQL schema. One of that aspects is GraphQL +doesn't have type inheritance, instead it uses composition: ```graphql -interface Character { +interface Node { id: ID! - name: String! - friends: [Character] - appearsIn: [Episode]! } -type Human implements Character { +interface Entity implements Node { id: ID! - name: String! - friends: [Character] - appearsIn: [Episode]! - starships: [Starship] - totalCredits: Int + #... Entity fields ... } -type Droid implements Character { +type System implements Node & Entity { id: ID! - name: String! - friends: [Character] - appearsIn: [Episode]! - primaryFunction: String + #... Entity fields ... + #... System fields ... } ``` -To solve these problems, HydraphQL was created. - -### What is HydraphQL? - -HydraphQL is a set of GraphQL directives that allow you to enchance your schema, -declaratively define how the data should be processed and reduce the amount of -boilerplate code. +Which means if type `System` implements `Node` and `Entity` interfaces developer +must declare all fields of implementing interfaces. As a result GraphQL schema +contains a lot of repeating code. To simplify work with types we introduce a +[`@implements`][implements] directive so code from above will be: ```graphql -interface Character @discriminates(with: "type") { +interface Node { id: ID! - name: String! @field - friends: [Character] @resolve - appearsIn: [Episode]! @resolve(at: "appears_in") } -type Human @implements(interface: "Character") { - starships: [Starship] @resolve - totalCredits: Int @field(at: "total_credits") +interface Entity @implements(interface: "Node") { + #... Entity fields ... +} + +type System @implements(interface: "Entity") { + #... System fields ... +} +``` + +GraphQL Plugin has a schema mapper that process [`@implements`][implements] +directives and unwind them to the final schema + +Another aspect of working with GraphQL is an input data must be resolved to a +specific object type and developer have to implement type resolvers for each +interface they have. That leads to bunch of imperative code alongside with +declarative schema and as any other project's code it requires to be covered by +unit tests. In the most cases these code will be straight forward by matching +certain fields' values with object type names, so why not to make life easier +through a declarative approach. To make it real we introduce +[`@discriminates` and `@discriminationAlias`][discriminates] directives: + +```graphql +interface Entity @discriminates(with: "kind") { + #... Entity fields ... } -type Droid @implements(interface: "Character") { - primaryFunction: String @field(at: "primary_function") +interface Component + @implements(interface: "Entity") + @discriminates(with: "spec.type") + @discriminationAlias(value: "service", type: "Service") + @discriminationAlias(value: "website", type: "Website") + @discriminationAlias(value: "library", type: "Library") { + #... Component fields ... } ``` -HydraphQL transforms directives to raw GraphQL schema and adds a set of resolvers. +Let's dive into this. By `with` argument we are telling GraphQL application to +take a value from a field `kind` of input data and use it as a type name to +discriminate input data as a specific type. For example `kind == 'Component'` +and `Component` is an interface, but we need to resolve our data to object type, +so we have to go further. For `Component` interface we tell GraphQL application +to look at `spec.type` field and here we declared a few aliases to what type +data should be resolve for specific value. + +[working]: https://github.com/thefrontside/backstage/blob/master/plugins/catalog-react/src/utils/getEntityRelations.ts#L29-L48 +[these]: https://github.com/thefrontside/backstage/blob/cf28822/plugins/catalog/src/components/AboutCard/AboutContent.tsx#L84-L97 +[awkward]: https://github.com/thefrontside/backstage/blob/cf28822/plugins/catalog/src/components/SystemDiagramCard/SystemDiagramCard.tsx#L172-L265 +[batching]: https://github.com/thefrontside/backstage/blob/master/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityStore.ts +[burden]: https://github.com/thefrontside/backstage/blob/master/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityStore.ts +[directives]: https://graphql.org/learn/queries/#directives +[field]: ./field +[relation]: ./relation +[implements]: ./implements +[discriminates]: ./discriminates +[querying]: ./querying +[codegen]: ./codegen diff --git a/www/docs/quickstart.mdx b/www/docs/quickstart.mdx index c331665..343bade 100644 --- a/www/docs/quickstart.mdx +++ b/www/docs/quickstart.mdx @@ -1,264 +1,75 @@ -To start using it you will need to install some packages: +The [Backstage GraphQL plugin][graphql-backend] is designed for schema-first +development of the GraphQL API. It reduces work necessary to expand the schema +using schema directives. [Schema directives](https://the-guild.dev/graphql/tools/docs/schema-directives) +are extensions to GraphQL schema used to automate implementation of the GraphQL API. +In Backstage GraphQL Plugin, schema directives are used to automatically create +resolvers. [Resolvers](https://graphql.org/learn/execution/) tell a GraphQL API +how to provide data for a specific schema type or field. The Backstage GraphQL +Plugin uses what it knows about the Backstage catalog to reduce the need for +writing resolvers that call the catalog. -```bash -yarn add dataloader graphql graphql-modules -``` - -And then you can install HydraphQL itself: +Currently, Backstage implemented two backend systems: -```bash -yarn add @frontside/hydraphql -``` +- [EXPERIMENTAL Backstage backend system](https://backstage.io/docs/backend-system/) +- [Backstage backend plugins](https://backstage.io/docs/plugins/backend-plugin) -### Usage +### Usage with backend system -So after you've installed packages you can create your first GraphQL application. -HydraphQL uses [graphql-modules] under the hood and returns an instance of -[`Application`](https://the-guild.dev/graphql/modules/docs/api#application). You also -can checkout how to use it with your GraphQL server in the [server]. +The full cover of using GraphQL with EXPERIMENTAL Backstage backend system you can +find in [readme](https://github.com/thefrontside/playhouse/blob/main/plugins/graphql-backend/README.md) ```typescript -import { createGraphQLApp } from '@frontside/hydraphql'; - -export async function main() { - const application = await createGraphQLApp(); - - const schema = application.schema; - const execute = application.createExecution(); - const subscribe = application.createSubscription(); -} -``` +// packages/backend/src/index.ts +import { graphqlPlugin } from '@frontside/backstage-plugin-graphql-backend'; +import { graphqlModuleCatalog } from '@frontside/backstage-plugin-graphql-backend-module-catalog'; -An empty application isn't very useful, so let's add a simple module to it. -You can create directory `src/modules` and add two files (a GraphQL schema -and a module itself): - -```graphql -# src/modules/my-module.graphql -type Asset { - id: ID! - symbol: String! - name: String! - supply: String! - marketCapUsd: String! - volumeUsd24Hr: String! - priceUsd: String! - changePercent24Hr: String! -} - -extend type Query { - asset(id: String!): Asset! -} -``` - -```typescript -// src/modules/my-module.ts -import { createModule } from 'graphql-modules'; -import { loadFilesSync } from '@graphql-tools/load-files'; +// Initializing Backstage backend +const backend = createBackend(); -export const MyModule = createModule({ - id: 'my-module', - dirname: __dirname, - typeDefs: loadFilesSync(require.resolve('./my-module.graphql')), - resolvers: { - Query: { - asset: async (_, { id }) => { - const response = await fetch(`https://api.coincap.io/v2/assets/${id}`); - const { data } = await response.json(); - return data; - }, - }, - }, -}); +// Adding GraphQL plugin +backend.use(graphqlPlugin()); +// Adding Catalog GraphQL module +backend.use(graphqlModuleCatalog()); ``` -After we created a module we need to add it to our application: - -```typescript -// src/application.ts -import http from 'http'; -import { createHandler } from 'graphql-http'; -import { createGraphQLApp } from "@frontside/hydraphql"; -import { MyModule } from "./modules/my-module"; - -export async function main() { - const application = await createGraphQLApp({ modules: [MyModule] }); - - const server = http.createServer( - createHandler({ - schema: application.schema, - execute: application.createExecution(), - }) - ); - - server.listen(4000, () => console.log(`πŸš€ Server ready at http://localhost:4000/`)); -} -``` - -And you are done! Now you can connect to your server from a [playground](https://studio.apollographql.com/sandbox/explorer) -and run a query: - -```graphql -query { - asset(id: "polkadot") { - id - symbol - name - supply - marketCapUsd - volumeUsd24Hr - priceUsd - changePercent24Hr - } -} -``` - -### Adding a data loader - -Our application is working, but functionality is pretty limited. We can only fetch -one asset at a time, but what if we want to fetch multiple assets? Or fetch more -information about assets? We can add a data loader to our application to solve this. -It's highly recommended to use `createLoader` that creates a [data loader](https://github.com/graphql/dataloader) -for you even if you have only one data source. Let's add a data loader to our application: +### Usage with backend plugins -```typescript -// src/loader.ts -import type { NodeQuery } from "@frontside/hydraphql"; - -export async function Assets(queries: readonly NodeQuery[]) { - const ids = queries.map(query => query.ref); - const response = await fetch( - `https://api.coincap.io/v2/assets?ids=${ids.join(',')}&limit=${queries.length}` - ); - const { data } = await response.json(); - - return data; -} - -export async function Markets(queries: readonly NodeQuery[]) { - const responses = await Promise.all(queries - .map(query => query.ref) - .map(id => fetch(`https://api.coincap.io/v2/assets/${id}/markets`))); - const result = await Promise.all(responses.map(response => response.json())); - - return result.map(({ data }) => data); -} - -export async function Exchanges(queries: readonly NodeQuery[]) { - const responses = await Promise.all(queries - .map(query => query.ref) - .map(id => fetch(`https://api.coincap.io/v2/exchanges/${id}`))); - const result = await Promise.all(responses.map(response => response.json())); - - return result.map(({ data }) => data); -} -``` +Using the old Backstage backend plugins system is also fully covered in +[readme](https://github.com/thefrontside/playhouse/blob/main/plugins/graphql-backend/docs/backend-plugins.md) ```typescript -// src/application.ts -import { createServer } from 'node:http'; -import { createHandler } from 'graphql-http'; -import { createGraphQLApp, createLoader } from "@frontside/hydraphql"; -import { MyModule } from "./modules/my-module/my-module"; -import { Assets, Markets, Exchanges } from "./loader"; - -export async function main() { - const application = await createGraphQLApp({ modules: [MyModule] }); - // NOTE: We are creating data loader with a few sources - const loader = createLoader({ Assets, Markets, Exchanges }); - - const server = createServer( - createHandler({ - context: { loader }, - schema: application.schema, - execute: application.createExecution(), - }) - ); - - server.listen(4000, () => console.log(`πŸš€ Server ready at http://localhost:4000/`)); -} -``` - -In the last step we need to update our schema - -```graphql -extend interface Node - @discriminates(with: "__source") - @discriminationAlias(value: "Assets", type: "Asset") - @discriminationAlias(value: "Markets", type: "Market") - @discriminationAlias(value: "Exchanges", type: "Exchange") {} - -type Asset @implements(interface: "Node") { - symbol: String! @field - name: String! @field - supply: String! @field - marketCapUsd: String! @field - volumeUsd24Hr: String! @field - priceUsd: String! @field - changePercent24Hr: String! @field - marketsList: [Market!]! @resolve(at: "id", from: "Markets") - markets: Connection @resolve(at: "id", nodeType: "Market", from: "Markets") -} - -type Market @implements(interface: "Node") { - exchange: Exchange! @resolve(at: "exchangeId", from: "Exchanges") - asset: Asset! @resolve(at: "baseId", from: "Assets") - quoteId: String! @field - assetSymbol: String! @field(at: "baseSymbol") - quoteSymbol: String! @field - priceUsd: String! @field - volumeUsd24Hr: String! @field - volumePercent: String! @field -} - -type Exchange @implements(interface: "Node") { - name: String! @field - rank: String! @field - percentTotalVolume: String! @field - volumeUsd: String! @field - tradingPairs: String! @field - socket: Boolean! @field - exchangeUrl: String! @field - updated: Int! @field -} -``` - -Now we can fetch multiple assets at once and fetch more information about them: - -```graphql -query { - nodes(ids: ["polkadot", "bitcoin", "ethereum"]) { - id - symbol - name - supply - marketCapUsd - volumeUsd24Hr - priceUsd - changePercent24Hr - markets(first: 5) { - id - exchange { - id - name - rank - percentTotalVolume - volumeUsd - tradingPairs - socket - exchangeUrl - updated - } - quoteId - assetSymbol - quoteSymbol - priceUsd - volumeUsd24Hr - volumePercent - } - } +// packages/backend/src/plugins/graphql.ts +import { createRouter } from '@frontside/backstage-plugin-graphql-backend'; +import { Catalog, createCatalogLoader } from '@frontside/backstage-plugin-graphql-backend-module-catalog'; + +export default async function createPlugin( + env: PluginEnvironment, +): Promise { + return await createRouter({ + logger: env.logger, + modules: [Catalog], + loaders: { ...createCatalogLoader(env.catalogClient) }, + }); } ``` -[graphql-modules]: https://the-guild.dev/graphql/modules/docs -[server]: ./server +### Catalog module + +The single GraphQL plugin isn't useful by itself. To able query Backstage Catalog +using GraphQL queries you need to install the [Catalog module][catalog-module]. + +The Catalog module provides [`@relation`](./relation) schema directive and data +loader for Catalog API. It also has well written [Catalog GraphQL module][catalog-schema] +with most basic Backstage types. We recommend to use it as a starting point for +further [extending] GraphQL schema. But if you'd like to implement your own type +structure you can use [Relation GraphQL module][relation-schema]. Relation module +contains only [`@relation`](./relation) schema directive and Catalog data loader. +Then you can setup [codegen] to generate GraphQL schema for clients and TypeScript +types for the schema. + +[graphql-backend]: https://github.com/thefrontside/playhouse/blob/main/plugins/graphql-backend +[catalog-module]: https://github.com/thefrontside/playhouse/blob/main/plugins/graphql-backend-module-catalog +[catalog-schema]: https://github.com/thefrontside/playhouse/blob/main/plugins/graphql-backend-module-catalog/src/catalog +[relation-schema]: https://github.com/thefrontside/playhouse/blob/main/plugins/graphql-backend-module-catalog/src/relation +[extending]: ./extending +[codegen]: ./codegen diff --git a/www/docs/resolving.mdx b/www/docs/resolving.mdx index b75fd66..9d0278a 100644 --- a/www/docs/resolving.mdx +++ b/www/docs/resolving.mdx @@ -2,3 +2,50 @@ TODO: Describe how type resolving works in details TODO: Describe how field resolving works P+1 and late resolving TODO: How to combine with custom resolvers TODO: Resolving Connection type + +Let's start from that each node in HydraphQL has a unique `id` which contains +encoded information about the type of the node, which data source use to fetch +runtime data and also a reference to the data with optional query arguments. +Here is an example of a node id: + +``` +'Group@Catalog@{"ref":"group:default/team-a"}' +``` + +So `Group` is typename which was taken from a schema for a field that is being resolved. + +```graphql +interface Component { + owner: Group @relation(name: "ownedBy") +} +``` + +`Catalog` is a name of data source from where data should be fetched for this node. +And `{"ref":"group:default/team-a"}` is a `NodeQuery` which passed to data source function. +It can also might contain `args` field with query arguments. The way how `NodeQuery` is +handled you can check out in [`@resolve`](./resolve#creating-a-data-loader) section. + +### Resolving fields + +For each field, used with `@field/@resolve/@relation` directives, HydraphQL generates +lazy resolver function, why it's better instead of resolving all fields at once you +can check out [GraphQL Resolvers: Best Practices](https://medium.com/paypal-tech/graphql-resolvers-best-practices-cd36fdbcef55) +article. + +So for a field with `@field` directive HydraphQL generates a resolver function like: + +```typescript +const customResolver = field.resolve; + +field.resolve = async ( + source: { id: string }, + args: any, + context: { loader: DataLoader }, + info: GraphQLResolveInfo, +): Promise => { + const { loader } = context; + const node = await loader.load(source.id); + const rawData = _.get(node, directive.at) ?? directive.default; + return customResolver(rawData, args, context, info); +} +``` diff --git a/www/docs/structure.json b/www/docs/structure.json index f95aa03..913ca82 100644 --- a/www/docs/structure.json +++ b/www/docs/structure.json @@ -14,12 +14,10 @@ ["resolve.mdx", "@resolve"], ["relation.mdx", "@relation"] ], - "Integrations": [ - ["server.mdx", "GraphQL Server"], - ["backstage.mdx", "Backstage plugins"] - ], "Advanced": [ - ["resolving.mdx", "Type resolving"], - ["collections.mdx", "List and Connection types"] + ["resolving.mdx", "Type/Field resolving"], + ["collections.mdx", "List and Connection types"], + ["hydraphql.mdx", "HydraphQL"], + ["server.mdx", "GraphQL Server"] ] } diff --git a/www/routes/app.html.tsx b/www/routes/app.html.tsx index a0515ec..8a00930 100644 --- a/www/routes/app.html.tsx +++ b/www/routes/app.html.tsx @@ -29,11 +29,11 @@ export function* useAppHtml({ @@ -68,7 +68,7 @@ export function* useAppHtml({ > HydraphQL Logo @@ -81,16 +81,18 @@ export function* useAppHtml({ Guides
  • - + NPM
  • - Github + + Github +
  • @@ -114,7 +116,7 @@ export function* useAppHtml({

    About

    - + Maintained by Frontside @@ -137,7 +139,7 @@ export function* useAppHtml({ Discord GitHub @@ -168,7 +170,7 @@ const IconExtern = () => ( ); -const IconGithHub = () => ( +const IconGitHub = () => ( diff --git a/www/routes/index-route.tsx b/www/routes/index-route.tsx index f021a5c..9dc6351 100644 --- a/www/routes/index-route.tsx +++ b/www/routes/index-route.tsx @@ -4,7 +4,8 @@ import { useAppHtml } from "./app.html.tsx"; export function indexRoute(): JSXMiddleware { return function* () { - let AppHtml = yield* useAppHtml({ title: `HydraphQL` }); + let title = "Backstage GraphQL Plugin"; + let AppHtml = yield* useAppHtml({ title }); return ( @@ -13,14 +14,14 @@ export function indexRoute(): JSXMiddleware {
    HydraphQL Logo -

    HydraphQL

    +

    {title}

    - Declarative resolvers for GraphQL schemas + Backend plugin provides a GraphQL API for Backstage services.

    - HydraphQL supports GraphQL Modules, allowing you to compose your - schema from smaller, easy to maintain pieces. + GraphQL Plugin supports GraphQL Modules, allowing you to compose + your schema from smaller, easy to maintain pieces. In raw GraphQL schemas you have to declare all fields from - implementing interfaces. With HydraphQL you are able declare field - once and all interface implementations will include it. + implementing interfaces. With GraphQL Plugin you are able declare + field once and all interface implementations will include it. - HydraphQL provides a way to define declarative type resolvers + GraphQL Plugin provides a way to define declarative type resolvers directly in your schema, which means you will have less JavaScript code to write and test. @@ -49,7 +50,7 @@ export function indexRoute(): JSXMiddleware { into one single endpoint. - HydraphQL is compatible with many popular GraphQL servers, + GraphQL Plugin is compatible with many popular GraphQL servers, including Apollo Server, GraphQL Yoga, Express GraphQL and more.