From 7e67c8d522afbc9d3eb44e8ee6cd509bb77d9704 Mon Sep 17 00:00:00 2001 From: Joe Caulfield Date: Sat, 4 May 2024 10:42:17 -0500 Subject: [PATCH] refactor(experimental): graphql: enable schema extending --- .../src/__tests__/customization-test.ts | 127 ++++++++++++++++++ packages/rpc-graphql/src/index.ts | 54 ++++++-- packages/rpc-graphql/src/resolvers/index.ts | 16 ++- packages/rpc-graphql/src/resolvers/root.ts | 10 +- packages/rpc-graphql/src/schema/index.ts | 19 ++- pnpm-lock.yaml | 2 +- 6 files changed, 209 insertions(+), 19 deletions(-) create mode 100644 packages/rpc-graphql/src/__tests__/customization-test.ts diff --git a/packages/rpc-graphql/src/__tests__/customization-test.ts b/packages/rpc-graphql/src/__tests__/customization-test.ts new file mode 100644 index 00000000000..9ee5448d6a5 --- /dev/null +++ b/packages/rpc-graphql/src/__tests__/customization-test.ts @@ -0,0 +1,127 @@ +import { + GetAccountInfoApi, + GetBlockApi, + GetMultipleAccountsApi, + GetProgramAccountsApi, + GetTransactionApi, + Rpc, +} from '@solana/rpc'; + +import { createRpcGraphQL, resolveAccount } from '../index'; +import { createLocalhostSolanaRpc } from './__setup__'; + +type GraphQLCompliantRpc = Rpc< + GetAccountInfoApi & GetBlockApi & GetMultipleAccountsApi & GetProgramAccountsApi & GetTransactionApi +>; + +describe('schema customization', () => { + let rpc: GraphQLCompliantRpc; + beforeEach(() => { + rpc = createLocalhostSolanaRpc(); + }); + + it('query with types', async () => { + expect.assertions(1); + + const masterEditionAddress = 'B2Srva38aD8bWpjghkU7jKFUqT1Y4KB2ejAnsJbP2ibA'; + + // Define custom type definitions for the GraphQL schema. + const customTypeDefs = /* GraphQL */ ` + # A Solana Master Edition NFT. + type NftMasterEdition { + address: Address + metadata: Account + mint: Account + } + + # Query to retrieve a Solana Master Edition NFT. + type Query { + masterEdition(address: Address!): NftMasterEdition + } + `; + + // Define custom resolvers for the GraphQL schema. + const customResolvers = { + // Resolver for the custom `NftMasterEdition` type. + NftMasterEdition: { + metadata: resolveAccount('metadata'), + mint: resolveAccount('mint'), + }, + }; + + // Define custom queries for the GraphQL schema. + const customQueries = { + // Query to retrieve a Solana Master Edition NFT. + masterEdition: () => { + return { + // Arbitrary address. + address: masterEditionAddress, + // See scripts/fixtures/gpa1.json. + metadata: 'CcYNb7WqpjaMrNr7B1mapaNfWctZRH7LyAjWRLBGt1Fk', + // See scripts/fixtures/spl-token-mint-account.json. + mint: 'Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr', + }; + }, + }; + + // Create the RPC-GraphQL client with the custom type definitions and + // resolvers. + const rpcGraphQL = createRpcGraphQL(rpc, { + queries: customQueries, + resolvers: customResolvers, + typeDefs: customTypeDefs, + }); + + // Create a test query for the custom `masterEdition` query. + const source = /* GraphQL */ ` + query ($masterEditionAddress: Address!) { + masterEdition(address: $masterEditionAddress) { + address + metadata { + address + lamports + } + mint { + address + lamports + ownerProgram { + address + } + ... on MintAccount { + decimals + supply + } + } + } + } + `; + + // Execute the test query. + const result = await rpcGraphQL.query(source, { + masterEditionAddress, + }); + + // Assert the custom type definitions and resolvers were accepted and + // the query was successful. + expect(result).toMatchObject({ + data: { + masterEdition: { + address: masterEditionAddress, + metadata: { + address: 'CcYNb7WqpjaMrNr7B1mapaNfWctZRH7LyAjWRLBGt1Fk', + lamports: expect.any(BigInt), + }, + mint: { + address: 'Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr', + decimals: expect.any(Number), + lamports: expect.any(BigInt), + ownerProgram: { + address: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + }, + supply: expect.any(String), + }, + }, + }, + }); + }); +}); diff --git a/packages/rpc-graphql/src/index.ts b/packages/rpc-graphql/src/index.ts index 7dd3b697295..87405cc7bd2 100644 --- a/packages/rpc-graphql/src/index.ts +++ b/packages/rpc-graphql/src/index.ts @@ -12,17 +12,50 @@ export interface RpcGraphQL { ): ReturnType; } -export function createRpcGraphQL( - rpc: Parameters[0], - config?: Partial[1]>, -): RpcGraphQL { +type Config = { + /** + * Maximum number of acceptable bytes to waste before splitting two + * `dataSlice` requests into two requests. + */ + maxDataSliceByteRange?: number; + /** + * Maximum number of accounts to fetch in a single batch. + * See https://docs.solana.com/api/http#getmultipleaccounts. + */ + maxMultipleAccountsBatchSize?: number; + /** + * Custom queries to extend the default queries. + */ + queries?: Parameters[0]['resolvers']; + /** + * Custom resolvers to extend the default resolvers. + * + * These resolvers should correspond to any custom type definitions + * provided in `typeDefs`. + */ + resolvers?: Parameters[0]['resolvers']; + /** + * Custom type definitions to extend the default type definitions in + * the GraphQL schema. + * + * Note: custom type definitions may not override existing type + * definitions, but may implement existing interfaces. + * + * These type definitions should correspond to any custom resolvers + * provided in `resolvers`. + */ + typeDefs?: Parameters[0]['typeDefs']; +}; + +export function createRpcGraphQL(rpc: Parameters[0], config?: Config): RpcGraphQL { + const { maxDataSliceByteRange, maxMultipleAccountsBatchSize, queries, resolvers, typeDefs } = config ?? {}; const rpcGraphQLConfig = { - maxDataSliceByteRange: config?.maxDataSliceByteRange ?? 200, - maxMultipleAccountsBatchSize: config?.maxMultipleAccountsBatchSize ?? 100, + maxDataSliceByteRange: maxDataSliceByteRange ?? 200, + maxMultipleAccountsBatchSize: maxMultipleAccountsBatchSize ?? 100, }; const schema = makeExecutableSchema({ - resolvers: createSolanaGraphQLResolvers(), - typeDefs: createSolanaGraphQLTypeDefs(), + resolvers: createSolanaGraphQLResolvers({ queries, resolvers }), + typeDefs: createSolanaGraphQLTypeDefs({ typeDefs }), }); return { async query(source, variableValues?) { @@ -36,3 +69,8 @@ export function createRpcGraphQL( }, }; } + +// Export resolvers to be used to build custom resolvers. +export { resolveAccount } from './resolvers/account'; +export { resolveBlock } from './resolvers/block'; +export { resolveTransaction } from './resolvers/transaction'; diff --git a/packages/rpc-graphql/src/resolvers/index.ts b/packages/rpc-graphql/src/resolvers/index.ts index a96ca227557..302692a13b0 100644 --- a/packages/rpc-graphql/src/resolvers/index.ts +++ b/packages/rpc-graphql/src/resolvers/index.ts @@ -7,12 +7,24 @@ import { rootResolvers } from './root'; import { transactionResolvers } from './transaction'; import { typeTypeResolvers } from './types'; -export function createSolanaGraphQLResolvers(): Parameters[0]['resolvers'] { +type Resolvers = Parameters[0]['resolvers']; + +export function createSolanaGraphQLResolvers({ + queries, + resolvers, +}: { + queries?: Resolvers; + resolvers?: Resolvers; +}): Resolvers { return { + Query: { + ...queries, + ...rootResolvers, + }, ...accountResolvers, ...blockResolvers, ...instructionResolvers, - ...rootResolvers, + ...resolvers, ...transactionResolvers, ...typeTypeResolvers, }; diff --git a/packages/rpc-graphql/src/resolvers/root.ts b/packages/rpc-graphql/src/resolvers/root.ts index ce443532be8..399d033a046 100644 --- a/packages/rpc-graphql/src/resolvers/root.ts +++ b/packages/rpc-graphql/src/resolvers/root.ts @@ -6,10 +6,8 @@ import { resolveProgramAccounts } from './program-accounts'; import { resolveTransaction } from './transaction'; export const rootResolvers: Parameters[0]['resolvers'] = { - Query: { - account: resolveAccount(), - block: resolveBlock(), - programAccounts: resolveProgramAccounts(), - transaction: resolveTransaction(), - }, + account: resolveAccount(), + block: resolveBlock(), + programAccounts: resolveProgramAccounts(), + transaction: resolveTransaction(), }; diff --git a/packages/rpc-graphql/src/schema/index.ts b/packages/rpc-graphql/src/schema/index.ts index d26c1341ef8..8f6a00cef45 100644 --- a/packages/rpc-graphql/src/schema/index.ts +++ b/packages/rpc-graphql/src/schema/index.ts @@ -1,3 +1,5 @@ +import type { makeExecutableSchema } from '@graphql-tools/schema'; + import { accountTypeDefs } from './account'; import { blockTypeDefs } from './block'; import { instructionTypeDefs } from './instruction'; @@ -5,6 +7,19 @@ import { rootTypeDefs } from './root'; import { transactionTypeDefs } from './transaction'; import { typeTypeDefs } from './types'; -export function createSolanaGraphQLTypeDefs() { - return [accountTypeDefs, blockTypeDefs, instructionTypeDefs, rootTypeDefs, typeTypeDefs, transactionTypeDefs]; +type TypeDefs = Parameters[0]['typeDefs']; + +export function createSolanaGraphQLTypeDefs({ typeDefs }: { typeDefs?: TypeDefs }): TypeDefs { + const schemaTypeDefs = [ + accountTypeDefs, + blockTypeDefs, + instructionTypeDefs, + rootTypeDefs, + typeTypeDefs, + transactionTypeDefs, + ] as TypeDefs[]; + if (typeDefs) { + schemaTypeDefs.push(typeDefs); + } + return schemaTypeDefs; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 062bcad9a49..820af782982 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13590,4 +13590,4 @@ packages: optionalDependencies: '@types/fs-extra': 11.0.4 '@types/node': 20.12.7 - dev: true + dev: true \ No newline at end of file