diff --git a/.changeset/eleven-lobsters-develop.md b/.changeset/eleven-lobsters-develop.md new file mode 100644 index 000000000..5a3851bc8 --- /dev/null +++ b/.changeset/eleven-lobsters-develop.md @@ -0,0 +1,5 @@ +--- +'@pothos/plugin-relay': minor +--- + +Add new parse option for id field on nodes, and a `for` option on globalID args diff --git a/packages/deno/packages/plugin-relay/README.md b/packages/deno/packages/plugin-relay/README.md index 7d982d8c6..5409ffddb 100644 --- a/packages/deno/packages/plugin-relay/README.md +++ b/packages/deno/packages/plugin-relay/README.md @@ -116,6 +116,28 @@ globalIDs used in arguments expect the client to send a globalID string, but wil converted to an object with 2 properties (`id` and `typename`) before they are passed to your resolver in the arguments object. +#### Limiting global ID args to specific types + +`globalID` input's can be configured to validate the type of the globalID. This is useful if you +only want to accept IDs for specific node types. + +```typescript +builder.queryType({ + fields: (t) => ({ + fieldThatAcceptsGlobalID: t.boolean({ + args: { + id: t.arg.globalID({ + for: SomeType, + // or allow multiple types + for: [TypeOne, TypeTwo], + required: true, + }), + }, + }), + }), +}); +``` + ### Creating Nodes To create objects that extend the `Node` interface, you can use the new `builder.node` method. @@ -168,6 +190,24 @@ The means that for many cases if you are using classes in your type parameters, are instances of those classes, you won't need to implement an `isTypeOf` method, but it is usually better to explicitly define that behavior. +#### parsing node ids + +By default all node ids are parsed as string. This behavior can be customized by providing a custom +parse function for your node's ID field: + +```ts +builder.node(NumberThing, { + // define an id field + id: { + resolve: (num) => num.id, + parse: (id) => Number.parseInt(id, 10), + }, + // the ID is now a number + loadOne: (id) => new NumberThing(id), + ... +}); +``` + ### Creating Connections The `t.connection` field builder method can be used to define connections. This method will diff --git a/packages/deno/packages/plugin-relay/field-builder.ts b/packages/deno/packages/plugin-relay/field-builder.ts index 4067098a9..686e8bf04 100644 --- a/packages/deno/packages/plugin-relay/field-builder.ts +++ b/packages/deno/packages/plugin-relay/field-builder.ts @@ -53,7 +53,7 @@ fieldBuilderProto.node = function node({ id, ...options }) { return null; } const globalID = typeof rawID === "string" - ? internalDecodeGlobalID(this.builder, rawID, context) + ? internalDecodeGlobalID(this.builder, rawID, context, info, true) : rawID && { id: String(rawID.id), typename: this.builder.configStore.getTypeConfig(rawID.type).name, @@ -78,7 +78,7 @@ fieldBuilderProto.nodeList = function nodeList({ ids, ...options }) { } const rawIds = (await Promise.all(rawIDList)) as (GlobalIDShape | string | null | undefined)[]; const globalIds = rawIds.map((id) => typeof id === "string" - ? internalDecodeGlobalID(this.builder, id, context) + ? internalDecodeGlobalID(this.builder, id, context, info, true) : id && { id: String(id.id), typename: this.builder.configStore.getTypeConfig(id.type).name, diff --git a/packages/deno/packages/plugin-relay/global-types.ts b/packages/deno/packages/plugin-relay/global-types.ts index ff92505ab..55adf620a 100644 --- a/packages/deno/packages/plugin-relay/global-types.ts +++ b/packages/deno/packages/plugin-relay/global-types.ts @@ -1,5 +1,6 @@ // @ts-nocheck import { FieldKind, FieldNullability, FieldOptionsFromKind, FieldRef, FieldRequiredness, InputFieldMap, InputFieldRef, InputFieldsFromShape, InputShapeFromFields, InputShapeFromTypeParam, inputShapeKey, InterfaceParam, NormalizeArgs, ObjectFieldsShape, ObjectFieldThunk, ObjectParam, OutputShape, OutputType, ParentShape, Resolver, SchemaTypes, ShapeFromTypeParam, } from '../core/index.ts'; +import { NodeRef } from './node-ref.ts'; import { ConnectionShape, ConnectionShapeForType, ConnectionShapeFromResolve, GlobalIDFieldOptions, GlobalIDInputFieldOptions, GlobalIDInputShape, GlobalIDListFieldOptions, GlobalIDListInputFieldOptions, GlobalIDShape, InputShapeWithClientMutationId, NodeFieldOptions, NodeListFieldOptions, NodeObjectOptions, PageInfoShape, RelayMutationFieldOptions, RelayMutationInputOptions, RelayMutationPayloadOptions, RelayPluginOptions, } from './types.ts'; import type { DefaultEdgesNullability, PothosRelayPlugin } from './index.ts'; declare global { @@ -29,7 +30,7 @@ declare global { export interface SchemaBuilder { pageInfoRef: () => ObjectRef; nodeInterfaceRef: () => InterfaceRef; - node: [], Param extends ObjectParam>(param: Param, options: NodeObjectOptions, fields?: ObjectFieldsShape>) => ObjectRef, ParentShape>; + node: [], Param extends ObjectParam, IDShape = string>(param: Param, options: NodeObjectOptions, fields?: ObjectFieldsShape>) => NodeRef, ParentShape, IDShape>; globalConnectionFields: (fields: ObjectFieldsShape>) => void; globalConnectionField: (name: string, field: ObjectFieldThunk>) => void; relayMutationField: [], InputName extends string = "input">(name: string, inputOptions: InputObjectRef | RelayMutationInputOptions, fieldOptions: RelayMutationFieldOptions, payloadOptions: RelayMutationPayloadOptions) => { @@ -65,18 +66,18 @@ declare global { connectionArgs: () => { [K in keyof DefaultConnectionArguments]-?: InputFieldRef; }; - globalID: (...args: NormalizeArgs<[ - options: GlobalIDInputFieldOptions - ]>) => InputFieldRef, Kind>; + globalID: >(...args: NormalizeArgs<[ + options: GlobalIDInputFieldOptions + ]>) => InputFieldRef ? T : string>, Req>, Kind>; globalIDList: >(...args: NormalizeArgs<[ - options: GlobalIDListInputFieldOptions + ]>, For extends ObjectParam>(...args: NormalizeArgs<[ + options: GlobalIDListInputFieldOptions ]>) => InputFieldRef ? T : string; }; } ], Req>, Kind>; diff --git a/packages/deno/packages/plugin-relay/index.ts b/packages/deno/packages/plugin-relay/index.ts index 69ba1b850..577b60483 100644 --- a/packages/deno/packages/plugin-relay/index.ts +++ b/packages/deno/packages/plugin-relay/index.ts @@ -3,9 +3,10 @@ import './global-types.ts'; import './field-builder.ts'; import './input-field-builder.ts'; import './schema-builder.ts'; -import { GraphQLFieldResolver } from 'https://cdn.skypack.dev/graphql?dts'; +import { GraphQLFieldResolver, GraphQLResolveInfo } from 'https://cdn.skypack.dev/graphql?dts'; import SchemaBuilder, { BasePlugin, createInputValueMapper, mapInputFields, PothosOutputFieldConfig, SchemaTypes, } from '../core/index.ts'; import { internalDecodeGlobalID } from './utils/internal.ts'; +export * from './node-ref.ts'; export * from './types.ts'; export * from './utils/index.ts'; const pluginName = "relay"; @@ -14,28 +15,34 @@ export class PothosRelayPlugin extends BasePlugin, fieldConfig: PothosOutputFieldConfig): GraphQLFieldResolver { const argMappings = mapInputFields(fieldConfig.args, this.buildCache, (inputField) => { if (inputField.extensions?.isRelayGlobalID) { - return true; + return (inputField.extensions?.relayGlobalIDFor ?? true) as true | { + typename: string; + parseId: (id: string, ctx: object) => unknown; + }[]; } return null; }); if (!argMappings) { return resolver; } - const argMapper = createInputValueMapper(argMappings, (globalID, mappings, ctx: Types["Context"]) => internalDecodeGlobalID(this.builder, String(globalID), ctx)); - return (parent, args, context, info) => resolver(parent, argMapper(args, undefined, context), context, info); + const argMapper = createInputValueMapper(argMappings, (globalID, mappings, ctx: Types["Context"], info: GraphQLResolveInfo) => internalDecodeGlobalID(this.builder, String(globalID), ctx, info, Array.isArray(mappings.value) ? mappings.value : false)); + return (parent, args, context, info) => resolver(parent, argMapper(args, undefined, context, info), context, info); } override wrapSubscribe(subscribe: GraphQLFieldResolver | undefined, fieldConfig: PothosOutputFieldConfig): GraphQLFieldResolver | undefined { const argMappings = mapInputFields(fieldConfig.args, this.buildCache, (inputField) => { if (inputField.extensions?.isRelayGlobalID) { - return true; + return (inputField.extensions?.relayGlobalIDFor ?? true) as true | { + typename: string; + parseId: (id: string, ctx: object) => unknown; + }[]; } return null; }); if (!argMappings || !subscribe) { return subscribe; } - const argMapper = createInputValueMapper(argMappings, (globalID, mappings, ctx: Types["Context"]) => internalDecodeGlobalID(this.builder, String(globalID), ctx)); - return (parent, args, context, info) => subscribe(parent, argMapper(args, undefined, context), context, info); + const argMapper = createInputValueMapper(argMappings, (globalID, mappings, ctx: Types["Context"], info: GraphQLResolveInfo) => internalDecodeGlobalID(this.builder, String(globalID), ctx, info, Array.isArray(mappings.value) ? mappings.value : false)); + return (parent, args, context, info) => subscribe(parent, argMapper(args, undefined, context, info), context, info); } } SchemaBuilder.registerPlugin(pluginName, PothosRelayPlugin); diff --git a/packages/deno/packages/plugin-relay/input-field-builder.ts b/packages/deno/packages/plugin-relay/input-field-builder.ts index 5ccb83f02..57e36103e 100644 --- a/packages/deno/packages/plugin-relay/input-field-builder.ts +++ b/packages/deno/packages/plugin-relay/input-field-builder.ts @@ -1,31 +1,38 @@ // @ts-nocheck -import { FieldRequiredness, InputFieldBuilder, InputFieldRef, InputShapeFromTypeParam, } from '../core/index.ts'; +import { FieldRequiredness, InputFieldBuilder, InputFieldRef, InputShapeFromTypeParam, ObjectRef, SchemaTypes, } from '../core/index.ts'; +import { NodeRef } from './node-ref.ts'; import { GlobalIDInputFieldOptions, GlobalIDInputShape, GlobalIDListInputFieldOptions, } from './types.ts'; type DefaultSchemaTypes = PothosSchemaTypes.ExtendDefaultTypes<{}>; const inputFieldBuilder = InputFieldBuilder.prototype as PothosSchemaTypes.InputFieldBuilder; inputFieldBuilder.globalIDList = function globalIDList>(options: GlobalIDListInputFieldOptions = {} as never): InputFieldRef> { +]>>({ for: forTypes, ...options }: GlobalIDListInputFieldOptions = {} as never) { return this.idList({ ...options, extensions: { ...options.extensions, isRelayGlobalID: true, + relayGlobalIDFor: ((forTypes && + (Array.isArray(forTypes) ? forTypes : [forTypes])) as ObjectRef[])?.map((type: ObjectRef) => ({ + typename: this.builder.configStore.getTypeConfig(type).name, + parse: type instanceof NodeRef ? type.parseId : undefined, + })) ?? null, }, - }) as InputFieldRef>; + }) as never; }; -inputFieldBuilder.globalID = function globalID(options: GlobalIDInputFieldOptions = {} as never) { +inputFieldBuilder.globalID = function globalID({ for: forTypes, ...options }: GlobalIDInputFieldOptions = {} as never) { return this.id({ ...options, extensions: { ...options.extensions, isRelayGlobalID: true, + relayGlobalIDFor: ((forTypes && + (Array.isArray(forTypes) ? forTypes : [forTypes])) as ObjectRef[])?.map((type: ObjectRef) => ({ + typename: this.builder.configStore.getTypeConfig(type).name, + parse: type instanceof NodeRef ? type.parseId : undefined, + })) ?? null, }, - }) as unknown as InputFieldRef>; + }) as unknown as InputFieldRef> as never; }; inputFieldBuilder.connectionArgs = function connectionArgs() { const { diff --git a/packages/deno/packages/plugin-relay/node-ref.ts b/packages/deno/packages/plugin-relay/node-ref.ts new file mode 100644 index 000000000..7ccc5d0a9 --- /dev/null +++ b/packages/deno/packages/plugin-relay/node-ref.ts @@ -0,0 +1,13 @@ +// @ts-nocheck +import { ObjectRef } from '../core/index.ts'; +export const relayIDShapeKey = Symbol.for("Pothos.relayIDShapeKey"); +export class NodeRef extends ObjectRef { + [relayIDShapeKey]!: IDShape; + parseId: ((id: string, ctx: object) => IDShape) | undefined; + constructor(name: string, options: { + parseId?: (id: string, ctx: object) => IDShape; + }) { + super(name); + this.parseId = options.parseId; + } +} diff --git a/packages/deno/packages/plugin-relay/schema-builder.ts b/packages/deno/packages/plugin-relay/schema-builder.ts index 2d516f683..a4f3fd3b2 100644 --- a/packages/deno/packages/plugin-relay/schema-builder.ts +++ b/packages/deno/packages/plugin-relay/schema-builder.ts @@ -1,6 +1,7 @@ // @ts-nocheck import { defaultTypeResolver, GraphQLResolveInfo } from 'https://cdn.skypack.dev/graphql?dts'; import SchemaBuilder, { createContextCache, FieldRef, getTypeBrand, InputObjectRef, InterfaceParam, InterfaceRef, ObjectFieldsShape, ObjectFieldThunk, ObjectParam, ObjectRef, OutputRef, SchemaTypes, verifyRef, } from '../core/index.ts'; +import { NodeRef } from './node-ref.ts'; import { ConnectionShape, GlobalIDShape, PageInfoShape } from './types.ts'; import { capitalize, resolveNodes } from './utils/index.ts'; const schemaBuilderProto = SchemaBuilder.prototype as PothosSchemaTypes.SchemaBuilder; @@ -153,7 +154,7 @@ schemaBuilderProto.nodeInterfaceRef = function nodeInterfaceRef() { } return ref; }; -schemaBuilderProto.node = function node(param, { interfaces, ...options }, fields) { +schemaBuilderProto.node = function node(param, { interfaces, extensions, id, ...options }, fields) { verifyRef(param); const interfacesWithNode: () => InterfaceParam[] = () => [ this.nodeInterfaceRef(), @@ -163,6 +164,10 @@ schemaBuilderProto.node = function node(param, { interfaces, ...options }, field const ref = this.objectType<[ ], ObjectParam>(param, { ...(options as {}), + extensions: { + ...extensions, + pothosParseGlobalID: id.parse, + }, isTypeOf: options.isTypeOf ?? (typeof param === "function" ? (maybeNode: unknown, context: object, info: GraphQLResolveInfo) => { @@ -195,16 +200,20 @@ schemaBuilderProto.node = function node(param, { interfaces, ...options }, field this.objectField(ref, this.options.relayOptions.idFieldName ?? "id", (t) => t.globalID<{}, false, Promise>>({ nullable: false, ...this.options.relayOptions.idFieldOptions, - ...options.id, + ...id, args: {}, resolve: async (parent, args, context, info) => ({ type: nodeConfig.name, // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - id: await options.id.resolve(parent, args, context, info), + id: await id.resolve(parent, args, context, info), }), })); }); - return ref; + const nodeRef = new NodeRef(ref.name, { + parseId: id.parse, + }); + this.configStore.associateRefWithName(nodeRef, ref.name); + return nodeRef as never; }; schemaBuilderProto.globalConnectionField = function globalConnectionField(name, field) { const onRef = (ref: ObjectRef>) => { diff --git a/packages/deno/packages/plugin-relay/types.ts b/packages/deno/packages/plugin-relay/types.ts index e3d8a2507..d80f1cbd7 100644 --- a/packages/deno/packages/plugin-relay/types.ts +++ b/packages/deno/packages/plugin-relay/types.ts @@ -118,23 +118,29 @@ export type ConnectionShapeFromResolve, Interfaces extends InterfaceParam[]> = ObjectTypeOptions, Interfaces>; -export type NodeObjectOptions, Interfaces extends InterfaceParam[]> = NodeBaseObjectOptionsForParam & { - id: Omit, "ID", false, {}, "Object", OutputShape, MaybePromise>>, "args" | "nullable" | "type">; +export type NodeObjectOptions, Interfaces extends InterfaceParam[], IDShape = string> = NodeBaseObjectOptionsForParam & { + id: Omit, "ID", false, {}, "Object", OutputShape, MaybePromise>>, "args" | "nullable" | "type"> & { + parse?: (id: string, ctx: Types["Context"]) => IDShape; + }; brandLoadedObjects?: boolean; - loadOne?: (id: string, context: Types["Context"]) => MaybePromise | null | undefined>; - loadMany?: (ids: string[], context: Types["Context"]) => MaybePromise | null | undefined>[]>; - loadWithoutCache?: (id: string, context: Types["Context"], info: GraphQLResolveInfo) => MaybePromise | null | undefined>; - loadManyWithoutCache?: (ids: string[], context: Types["Context"]) => MaybePromise | null | undefined>[]>; + loadOne?: (id: IDShape, context: Types["Context"]) => MaybePromise | null | undefined>; + loadMany?: (ids: IDShape[], context: Types["Context"]) => MaybePromise | null | undefined>[]>; + loadWithoutCache?: (id: IDShape, context: Types["Context"], info: GraphQLResolveInfo) => MaybePromise | null | undefined>; + loadManyWithoutCache?: (ids: IDShape[], context: Types["Context"]) => MaybePromise | null | undefined>[]>; }; export type GlobalIDFieldOptions = Omit, "resolve" | "type"> & { resolve: Resolver, Types["Context"], ShapeFromTypeParam | string>, true>, ResolveReturnShape>; }; -export type GlobalIDInputFieldOptions = Omit[Kind], "type">; +export type GlobalIDInputFieldOptions = Omit[Kind], "type"> & { + for?: For | For[]; +}; export type GlobalIDListInputFieldOptions, Kind extends "Arg" | "InputObject"> = Omit, Kind extends "Arg" | "InputObject", For = unknown> = Omit[Kind], "type">; +], Req>[Kind], "type"> & { + for?: For | For[]; +}; export type NodeIDFieldOptions = Omit, "resolve" | "type"> & { resolve: Resolver, Types["Context"], ShapeFromTypeParam | string>, true>, ResolveReturnShape>; }; @@ -166,10 +172,10 @@ export type NodeListFieldOptions, ResolveReturnShape>; }; -export interface GlobalIDInputShape { +export interface GlobalIDInputShape { [inputShapeKey]: { typename: string; - id: string; + id: T; }; } export type RelayMutationInputOptions = Omit, "fields"> & { diff --git a/packages/deno/packages/plugin-relay/utils/internal.ts b/packages/deno/packages/plugin-relay/utils/internal.ts index b4329b422..93bd7447e 100644 --- a/packages/deno/packages/plugin-relay/utils/internal.ts +++ b/packages/deno/packages/plugin-relay/utils/internal.ts @@ -1,4 +1,5 @@ // @ts-nocheck +import { GraphQLResolveInfo } from 'https://cdn.skypack.dev/graphql?dts'; import { SchemaTypes } from '../../core/index.ts'; import { decodeGlobalID, encodeGlobalID } from './global-ids.ts'; export function internalEncodeGlobalID(builder: PothosSchemaTypes.SchemaBuilder, typename: string, id: bigint | number | string, ctx: object) { @@ -7,9 +8,36 @@ export function internalEncodeGlobalID(builder: Potho } return encodeGlobalID(typename, id); } -export function internalDecodeGlobalID(builder: PothosSchemaTypes.SchemaBuilder, globalID: string, ctx: object) { - if (builder.options.relayOptions.decodeGlobalID) { - return builder.options.relayOptions.decodeGlobalID(globalID, ctx); +export function internalDecodeGlobalID(builder: PothosSchemaTypes.SchemaBuilder, globalID: string, ctx: object, info: GraphQLResolveInfo, parseIdsForTypes: boolean | { + typename: string; + parseId: (id: string, ctx: object) => unknown; +}[]) { + const decoded = builder.options.relayOptions.decodeGlobalID + ? builder.options.relayOptions.decodeGlobalID(globalID, ctx) + : decodeGlobalID(globalID); + if (Array.isArray(parseIdsForTypes)) { + const entry = parseIdsForTypes.find(({ typename }) => typename === decoded.typename); + if (!entry) { + throw new Error(`ID: ${globalID} is not of type: ${parseIdsForTypes + .map(({ typename }) => typename) + .join(", ")}`); + } + if (entry.parseId) { + return { + ...decoded, + id: entry.parseId(decoded.id, ctx), + }; + } + return decoded; } - return decodeGlobalID(globalID); + if (parseIdsForTypes) { + const parseID = info.schema.getType(decoded.typename)?.extensions?.pothosParseGlobalID as (id: string, ctx: object) => string; + if (parseID) { + return { + ...decoded, + id: parseID(decoded.id, ctx), + }; + } + } + return decoded; } diff --git a/packages/deno/packages/plugin-relay/utils/resolve-nodes.ts b/packages/deno/packages/plugin-relay/utils/resolve-nodes.ts index 95b6063b6..f8b57c2c6 100644 --- a/packages/deno/packages/plugin-relay/utils/resolve-nodes.ts +++ b/packages/deno/packages/plugin-relay/utils/resolve-nodes.ts @@ -4,12 +4,12 @@ import { brandWithType, createContextCache, MaybePromise, ObjectParam, OutputTyp import { NodeObjectOptions } from '../types.ts'; const getRequestCache = createContextCache(() => new Map>()); export async function resolveNodes(builder: PothosSchemaTypes.SchemaBuilder, context: object, info: GraphQLResolveInfo, globalIDs: ({ - id: string; + id: unknown; typename: string; } | null | undefined)[]): Promise[]> { const requestCache = getRequestCache(context); - const idsByType: Record> = {}; - const results: Record = {}; + const idsByType: Record> = {}; + const results: Record> = {}; globalIDs.forEach((globalID, i) => { if (globalID == null) { return; @@ -39,11 +39,11 @@ export async function resolveNodes(builder: PothosSch })); return globalIDs.map((globalID) => globalID == null ? null : results[`${globalID.typename}:${globalID.id}`] ?? null); } -export async function resolveUncachedNodesForType(builder: PothosSchemaTypes.SchemaBuilder, context: object, info: GraphQLResolveInfo, ids: string[], type: OutputType | string): Promise { +export async function resolveUncachedNodesForType(builder: PothosSchemaTypes.SchemaBuilder, context: object, info: GraphQLResolveInfo, ids: unknown[], type: OutputType | string): Promise { const requestCache = getRequestCache(context); const config = builder.configStore.getTypeConfig(type, "Object"); const options = config.pothosOptions as NodeObjectOptions, [ - ]>; + ], unknown>; if (options.loadMany) { const loadManyPromise = Promise.resolve(options.loadMany(ids, context)); return Promise.all(ids.map((id, i) => { diff --git a/packages/plugin-relay/README.md b/packages/plugin-relay/README.md index 7d982d8c6..5409ffddb 100644 --- a/packages/plugin-relay/README.md +++ b/packages/plugin-relay/README.md @@ -116,6 +116,28 @@ globalIDs used in arguments expect the client to send a globalID string, but wil converted to an object with 2 properties (`id` and `typename`) before they are passed to your resolver in the arguments object. +#### Limiting global ID args to specific types + +`globalID` input's can be configured to validate the type of the globalID. This is useful if you +only want to accept IDs for specific node types. + +```typescript +builder.queryType({ + fields: (t) => ({ + fieldThatAcceptsGlobalID: t.boolean({ + args: { + id: t.arg.globalID({ + for: SomeType, + // or allow multiple types + for: [TypeOne, TypeTwo], + required: true, + }), + }, + }), + }), +}); +``` + ### Creating Nodes To create objects that extend the `Node` interface, you can use the new `builder.node` method. @@ -168,6 +190,24 @@ The means that for many cases if you are using classes in your type parameters, are instances of those classes, you won't need to implement an `isTypeOf` method, but it is usually better to explicitly define that behavior. +#### parsing node ids + +By default all node ids are parsed as string. This behavior can be customized by providing a custom +parse function for your node's ID field: + +```ts +builder.node(NumberThing, { + // define an id field + id: { + resolve: (num) => num.id, + parse: (id) => Number.parseInt(id, 10), + }, + // the ID is now a number + loadOne: (id) => new NumberThing(id), + ... +}); +``` + ### Creating Connections The `t.connection` field builder method can be used to define connections. This method will diff --git a/packages/plugin-relay/src/field-builder.ts b/packages/plugin-relay/src/field-builder.ts index e6011ccc8..0480eac8f 100644 --- a/packages/plugin-relay/src/field-builder.ts +++ b/packages/plugin-relay/src/field-builder.ts @@ -126,7 +126,7 @@ fieldBuilderProto.node = function node({ id, ...options }) { const globalID = typeof rawID === 'string' - ? internalDecodeGlobalID(this.builder, rawID, context) + ? internalDecodeGlobalID(this.builder, rawID, context, info, true) : rawID && { id: String(rawID.id), typename: this.builder.configStore.getTypeConfig(rawID.type).name, @@ -163,7 +163,7 @@ fieldBuilderProto.nodeList = function nodeList({ ids, ...options }) { const globalIds = rawIds.map((id) => typeof id === 'string' - ? internalDecodeGlobalID(this.builder, id, context) + ? internalDecodeGlobalID(this.builder, id, context, info, true) : id && { id: String(id.id), typename: this.builder.configStore.getTypeConfig(id.type).name, diff --git a/packages/plugin-relay/src/global-types.ts b/packages/plugin-relay/src/global-types.ts index 3418418d2..17386f2cd 100644 --- a/packages/plugin-relay/src/global-types.ts +++ b/packages/plugin-relay/src/global-types.ts @@ -22,6 +22,7 @@ import { SchemaTypes, ShapeFromTypeParam, } from '@pothos/core'; +import { NodeRef } from './node-ref'; import { ConnectionShape, ConnectionShapeForType, @@ -77,11 +78,15 @@ declare global { pageInfoRef: () => ObjectRef; nodeInterfaceRef: () => InterfaceRef; - node: [], Param extends ObjectParam>( + node: < + Interfaces extends InterfaceParam[], + Param extends ObjectParam, + IDShape = string, + >( param: Param, - options: NodeObjectOptions, + options: NodeObjectOptions, fields?: ObjectFieldsShape>, - ) => ObjectRef, ParentShape>; + ) => NodeRef, ParentShape, IDShape>; globalConnectionFields: ( fields: ObjectFieldsShape>, @@ -188,12 +193,19 @@ declare global { >; }; - globalID: ( - ...args: NormalizeArgs<[options: GlobalIDInputFieldOptions]> - ) => InputFieldRef, Kind>; + globalID: >( + ...args: NormalizeArgs<[options: GlobalIDInputFieldOptions]> + ) => InputFieldRef< + InputShapeFromTypeParam< + Types, + GlobalIDInputShape ? T : string>, + Req + >, + Kind + >; - globalIDList: >( - ...args: NormalizeArgs<[options: GlobalIDListInputFieldOptions]> + globalIDList: , For extends ObjectParam>( + ...args: NormalizeArgs<[options: GlobalIDListInputFieldOptions]> ) => InputFieldRef< InputShapeFromTypeParam< Types, @@ -201,7 +213,7 @@ declare global { { [inputShapeKey]: { typename: string; - id: string; + id: For extends NodeRef ? T : string; }; }, ], diff --git a/packages/plugin-relay/src/index.ts b/packages/plugin-relay/src/index.ts index 72a44c398..fbfc23c21 100644 --- a/packages/plugin-relay/src/index.ts +++ b/packages/plugin-relay/src/index.ts @@ -2,7 +2,7 @@ import './global-types'; import './field-builder'; import './input-field-builder'; import './schema-builder'; -import { GraphQLFieldResolver } from 'graphql'; +import { GraphQLFieldResolver, GraphQLResolveInfo } from 'graphql'; import SchemaBuilder, { BasePlugin, createInputValueMapper, @@ -12,6 +12,7 @@ import SchemaBuilder, { } from '@pothos/core'; import { internalDecodeGlobalID } from './utils/internal'; +export * from './node-ref'; export * from './types'; export * from './utils'; @@ -26,7 +27,9 @@ export class PothosRelayPlugin extends BasePlugin { const argMappings = mapInputFields(fieldConfig.args, this.buildCache, (inputField) => { if (inputField.extensions?.isRelayGlobalID) { - return true; + return (inputField.extensions?.relayGlobalIDFor ?? true) as + | true + | { typename: string; parseId: (id: string, ctx: object) => unknown }[]; } return null; @@ -38,12 +41,18 @@ export class PothosRelayPlugin extends BasePlugin - internalDecodeGlobalID(this.builder, String(globalID), ctx), + (globalID, mappings, ctx: Types['Context'], info: GraphQLResolveInfo) => + internalDecodeGlobalID( + this.builder, + String(globalID), + ctx, + info, + Array.isArray(mappings.value) ? mappings.value : false, + ), ); return (parent, args, context, info) => - resolver(parent, argMapper(args, undefined, context), context, info); + resolver(parent, argMapper(args, undefined, context, info), context, info); } override wrapSubscribe( @@ -52,7 +61,9 @@ export class PothosRelayPlugin extends BasePlugin | undefined { const argMappings = mapInputFields(fieldConfig.args, this.buildCache, (inputField) => { if (inputField.extensions?.isRelayGlobalID) { - return true; + return (inputField.extensions?.relayGlobalIDFor ?? true) as + | true + | { typename: string; parseId: (id: string, ctx: object) => unknown }[]; } return null; @@ -64,12 +75,18 @@ export class PothosRelayPlugin extends BasePlugin - internalDecodeGlobalID(this.builder, String(globalID), ctx), + (globalID, mappings, ctx: Types['Context'], info: GraphQLResolveInfo) => + internalDecodeGlobalID( + this.builder, + String(globalID), + ctx, + info, + Array.isArray(mappings.value) ? mappings.value : false, + ), ); return (parent, args, context, info) => - subscribe(parent, argMapper(args, undefined, context), context, info); + subscribe(parent, argMapper(args, undefined, context, info), context, info); } } diff --git a/packages/plugin-relay/src/input-field-builder.ts b/packages/plugin-relay/src/input-field-builder.ts index 416fea317..b3906bbe2 100644 --- a/packages/plugin-relay/src/input-field-builder.ts +++ b/packages/plugin-relay/src/input-field-builder.ts @@ -3,7 +3,10 @@ import { InputFieldBuilder, InputFieldRef, InputShapeFromTypeParam, + ObjectRef, + SchemaTypes, } from '@pothos/core'; +import { NodeRef } from './node-ref'; import { GlobalIDInputFieldOptions, GlobalIDInputShape, @@ -18,33 +21,51 @@ const inputFieldBuilder = InputFieldBuilder.prototype as PothosSchemaTypes.Input >; inputFieldBuilder.globalIDList = function globalIDList>( - options: GlobalIDListInputFieldOptions< - DefaultSchemaTypes, - Req, - 'Arg' | 'InputObject' - > = {} as never, -): InputFieldRef> { + { + for: forTypes, + ...options + }: GlobalIDListInputFieldOptions = {} as never, +) { return this.idList({ ...options, extensions: { ...options.extensions, isRelayGlobalID: true, + relayGlobalIDFor: + ( + (forTypes && + (Array.isArray(forTypes) ? forTypes : [forTypes])) as ObjectRef[] + )?.map((type: ObjectRef) => ({ + typename: this.builder.configStore.getTypeConfig(type).name, + parse: type instanceof NodeRef ? type.parseId : undefined, + })) ?? null, }, - }) as InputFieldRef>; + }) as never; }; inputFieldBuilder.globalID = function globalID( - options: GlobalIDInputFieldOptions = {} as never, + { + for: forTypes, + ...options + }: GlobalIDInputFieldOptions = {} as never, ) { return this.id({ ...options, extensions: { ...options.extensions, isRelayGlobalID: true, + relayGlobalIDFor: + ( + (forTypes && + (Array.isArray(forTypes) ? forTypes : [forTypes])) as ObjectRef[] + )?.map((type: ObjectRef) => ({ + typename: this.builder.configStore.getTypeConfig(type).name, + parse: type instanceof NodeRef ? type.parseId : undefined, + })) ?? null, }, }) as unknown as InputFieldRef< InputShapeFromTypeParam - >; + > as never; }; inputFieldBuilder.connectionArgs = function connectionArgs() { diff --git a/packages/plugin-relay/src/node-ref.ts b/packages/plugin-relay/src/node-ref.ts new file mode 100644 index 000000000..1c8701f10 --- /dev/null +++ b/packages/plugin-relay/src/node-ref.ts @@ -0,0 +1,18 @@ +import { ObjectRef } from '@pothos/core'; + +export const relayIDShapeKey = Symbol.for('Pothos.relayIDShapeKey'); + +export class NodeRef extends ObjectRef { + [relayIDShapeKey]!: IDShape; + parseId: ((id: string, ctx: object) => IDShape) | undefined; + + constructor( + name: string, + options: { + parseId?: (id: string, ctx: object) => IDShape; + }, + ) { + super(name); + this.parseId = options.parseId; + } +} diff --git a/packages/plugin-relay/src/schema-builder.ts b/packages/plugin-relay/src/schema-builder.ts index e13c5ca5c..3b5ae0f29 100644 --- a/packages/plugin-relay/src/schema-builder.ts +++ b/packages/plugin-relay/src/schema-builder.ts @@ -14,6 +14,7 @@ import SchemaBuilder, { SchemaTypes, verifyRef, } from '@pothos/core'; +import { NodeRef } from './node-ref'; import { ConnectionShape, GlobalIDShape, PageInfoShape } from './types'; import { capitalize, resolveNodes } from './utils'; @@ -222,7 +223,7 @@ schemaBuilderProto.nodeInterfaceRef = function nodeInterfaceRef() { return ref; }; -schemaBuilderProto.node = function node(param, { interfaces, ...options }, fields) { +schemaBuilderProto.node = function node(param, { interfaces, extensions, id, ...options }, fields) { verifyRef(param); const interfacesWithNode: () => InterfaceParam[] = () => [ this.nodeInterfaceRef(), @@ -235,6 +236,10 @@ schemaBuilderProto.node = function node(param, { interfaces, ...options }, field param, { ...(options as {}), + extensions: { + ...extensions, + pothosParseGlobalID: id.parse, + }, isTypeOf: options.isTypeOf ?? (typeof param === 'function' @@ -274,18 +279,24 @@ schemaBuilderProto.node = function node(param, { interfaces, ...options }, field t.globalID<{}, false, Promise>>({ nullable: false, ...this.options.relayOptions.idFieldOptions, - ...options.id, + ...id, args: {}, resolve: async (parent, args, context, info) => ({ type: nodeConfig.name, // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - id: await options.id.resolve(parent, args, context, info), + id: await id.resolve(parent, args, context, info), }), }), ); }); - return ref; + const nodeRef = new NodeRef(ref.name, { + parseId: id.parse, + }); + + this.configStore.associateRefWithName(nodeRef, ref.name); + + return nodeRef as never; }; schemaBuilderProto.globalConnectionField = function globalConnectionField(name, field) { diff --git a/packages/plugin-relay/src/types.ts b/packages/plugin-relay/src/types.ts index fb1bd94a0..e8fcfc3aa 100644 --- a/packages/plugin-relay/src/types.ts +++ b/packages/plugin-relay/src/types.ts @@ -348,6 +348,7 @@ export type NodeObjectOptions< Types extends SchemaTypes, Param extends ObjectParam, Interfaces extends InterfaceParam[], + IDShape = string, > = NodeBaseObjectOptionsForParam & { id: Omit< FieldOptionsFromKind< @@ -361,23 +362,25 @@ export type NodeObjectOptions< MaybePromise> >, 'args' | 'nullable' | 'type' - >; + > & { + parse?: (id: string, ctx: Types['Context']) => IDShape; + }; brandLoadedObjects?: boolean; loadOne?: ( - id: string, + id: IDShape, context: Types['Context'], ) => MaybePromise | null | undefined>; loadMany?: ( - ids: string[], + ids: IDShape[], context: Types['Context'], ) => MaybePromise | null | undefined>[]>; loadWithoutCache?: ( - id: string, + id: IDShape, context: Types['Context'], info: GraphQLResolveInfo, ) => MaybePromise | null | undefined>; loadManyWithoutCache?: ( - ids: string[], + ids: IDShape[], context: Types['Context'], ) => MaybePromise | null | undefined>[]>; }; @@ -415,13 +418,19 @@ export type GlobalIDInputFieldOptions< Types extends SchemaTypes, Req extends boolean, Kind extends 'Arg' | 'InputObject', -> = Omit[Kind], 'type'>; + For = unknown, +> = Omit[Kind], 'type'> & { + for?: For | For[]; +}; export type GlobalIDListInputFieldOptions< Types extends SchemaTypes, Req extends FieldRequiredness<['ID']>, Kind extends 'Arg' | 'InputObject', -> = Omit[Kind], 'type'>; + For = unknown, +> = Omit[Kind], 'type'> & { + for?: For | For[]; +}; export type NodeIDFieldOptions< Types extends SchemaTypes, @@ -554,10 +563,10 @@ export type NodeListFieldOptions< >; }; -export interface GlobalIDInputShape { +export interface GlobalIDInputShape { [inputShapeKey]: { typename: string; - id: string; + id: T; }; } diff --git a/packages/plugin-relay/src/utils/internal.ts b/packages/plugin-relay/src/utils/internal.ts index 3d68cf2d4..36710d7ce 100644 --- a/packages/plugin-relay/src/utils/internal.ts +++ b/packages/plugin-relay/src/utils/internal.ts @@ -1,3 +1,4 @@ +import { GraphQLResolveInfo } from 'graphql'; import { SchemaTypes } from '@pothos/core'; import { decodeGlobalID, encodeGlobalID } from './global-ids'; @@ -18,10 +19,46 @@ export function internalDecodeGlobalID( builder: PothosSchemaTypes.SchemaBuilder, globalID: string, ctx: object, + info: GraphQLResolveInfo, + parseIdsForTypes: boolean | { typename: string; parseId: (id: string, ctx: object) => unknown }[], ) { - if (builder.options.relayOptions.decodeGlobalID) { - return builder.options.relayOptions.decodeGlobalID(globalID, ctx); + const decoded = builder.options.relayOptions.decodeGlobalID + ? builder.options.relayOptions.decodeGlobalID(globalID, ctx) + : decodeGlobalID(globalID); + + if (Array.isArray(parseIdsForTypes)) { + const entry = parseIdsForTypes.find(({ typename }) => typename === decoded.typename); + if (!entry) { + throw new Error( + `ID: ${globalID} is not of type: ${parseIdsForTypes + .map(({ typename }) => typename) + .join(', ')}`, + ); + } + + if (entry.parseId) { + return { + ...decoded, + id: entry.parseId(decoded.id, ctx), + }; + } + + return decoded; + } + + if (parseIdsForTypes) { + const parseID = info.schema.getType(decoded.typename)?.extensions?.pothosParseGlobalID as ( + id: string, + ctx: object, + ) => string; + + if (parseID) { + return { + ...decoded, + id: parseID(decoded.id, ctx), + }; + } } - return decodeGlobalID(globalID); + return decoded; } diff --git a/packages/plugin-relay/src/utils/resolve-nodes.ts b/packages/plugin-relay/src/utils/resolve-nodes.ts index 846bd19dc..ad1633a86 100644 --- a/packages/plugin-relay/src/utils/resolve-nodes.ts +++ b/packages/plugin-relay/src/utils/resolve-nodes.ts @@ -15,11 +15,11 @@ export async function resolveNodes( builder: PothosSchemaTypes.SchemaBuilder, context: object, info: GraphQLResolveInfo, - globalIDs: ({ id: string; typename: string } | null | undefined)[], + globalIDs: ({ id: unknown; typename: string } | null | undefined)[], ): Promise[]> { const requestCache = getRequestCache(context); - const idsByType: Record> = {}; - const results: Record = {}; + const idsByType: Record> = {}; + const results: Record> = {}; globalIDs.forEach((globalID, i) => { if (globalID == null) { @@ -74,12 +74,12 @@ export async function resolveUncachedNodesForType( builder: PothosSchemaTypes.SchemaBuilder, context: object, info: GraphQLResolveInfo, - ids: string[], + ids: unknown[], type: OutputType | string, ): Promise { const requestCache = getRequestCache(context); const config = builder.configStore.getTypeConfig(type, 'Object'); - const options = config.pothosOptions as NodeObjectOptions, []>; + const options = config.pothosOptions as NodeObjectOptions, [], unknown>; if (options.loadMany) { const loadManyPromise = Promise.resolve(options.loadMany(ids, context)); diff --git a/packages/plugin-relay/tests/__snapshots__/index.test.ts.snap b/packages/plugin-relay/tests/__snapshots__/index.test.ts.snap index c97286f41..45ac44025 100644 --- a/packages/plugin-relay/tests/__snapshots__/index.test.ts.snap +++ b/packages/plugin-relay/tests/__snapshots__/index.test.ts.snap @@ -134,6 +134,8 @@ type Query { node(id: ID!): Node nodes(ids: [ID!]!): [Node]! numberRef: NumberRef! + numberThingByID(id: ID!): Number + numberThingsByIDs(ids: [ID!]!): [Number!] numbers(after: ID, before: ID, first: Int, last: Int): QueryNumbersConnection! oddNumbers(after: ID, before: ID, first: Int, last: Int): QueryOddNumbersConnection! poll(id: Int!): Poll diff --git a/packages/plugin-relay/tests/examples/relay/schema/numbers.ts b/packages/plugin-relay/tests/examples/relay/schema/numbers.ts index 87fa5fa95..d80b301e0 100644 --- a/packages/plugin-relay/tests/examples/relay/schema/numbers.ts +++ b/packages/plugin-relay/tests/examples/relay/schema/numbers.ts @@ -17,11 +17,12 @@ class BatchLoadableNumberThing { } } -builder.node(NumberThing, { +const NumberThingRef = builder.node(NumberThing, { id: { resolve: (n) => n.id, + parse: (id) => Number.parseInt(id, 10), }, - loadOne: (id) => new NumberThing(Number.parseInt(id, 10)), + loadOne: (id) => new NumberThing(id), name: 'Number', fields: (t) => ({ number: t.exposeInt('id', {}), @@ -205,3 +206,25 @@ builder.queryField('sharedEdgeConnection', (t) => SharedEdge, ), ); + +builder.queryField('numberThingByID', (t) => + t.field({ + type: NumberThing, + nullable: true, + args: { + id: t.arg.globalID({ required: true, for: [NumberThingRef] }), + }, + resolve: (root, args) => new NumberThing(args.id.id), + }), +); + +builder.queryField('numberThingsByIDs', (t) => + t.field({ + type: [NumberThing], + nullable: true, + args: { + ids: t.arg.globalIDList({ required: true, for: [NumberThingRef] }), + }, + resolve: (root, args) => args.ids.map(({ id }) => new NumberThing(id)), + }), +); diff --git a/packages/plugin-relay/tests/index.test.ts b/packages/plugin-relay/tests/index.test.ts index 10c56df2c..eb9f6322d 100644 --- a/packages/plugin-relay/tests/index.test.ts +++ b/packages/plugin-relay/tests/index.test.ts @@ -508,4 +508,62 @@ describe('relay example schema', () => { `); }); }); + + describe('parsing global ids', () => { + it('parses ids', async () => { + const query = gql` + query { + numberThingByID(id: "TnVtYmVyOjE=") { + id + number + } + numberThingsByIDs(ids: ["TnVtYmVyOjE=", "TnVtYmVyOjI="]) { + id + number + } + invalid: numberThingByID(id: "T3RoZXI6Mg==") { + id + number + } + invalidList: numberThingsByIDs(ids: ["T3RoZXI6Mg=="]) { + id + number + } + } + `; + + const result = await execute({ + schema, + document: query, + contextValue: {}, + }); + + expect(result).toMatchInlineSnapshot(` + { + "data": { + "invalid": null, + "invalidList": null, + "numberThingByID": { + "id": "TnVtYmVyOjE=", + "number": 1, + }, + "numberThingsByIDs": [ + { + "id": "TnVtYmVyOjE=", + "number": 1, + }, + { + "id": "TnVtYmVyOjI=", + "number": 2, + }, + ], + }, + "errors": [ + [GraphQLError: ID: T3RoZXI6Mg== is not of type: Number], + [GraphQLError: ID: T3RoZXI6Mg== is not of type: Number], + ], + } + `); + }); + }); }); diff --git a/website/pages/docs/plugins/relay.mdx b/website/pages/docs/plugins/relay.mdx index 760e6b708..f460ca2a5 100644 --- a/website/pages/docs/plugins/relay.mdx +++ b/website/pages/docs/plugins/relay.mdx @@ -130,6 +130,28 @@ globalIDs used in arguments expect the client to send a globalID string, but wil converted to an object with 2 properties (`id` and `typename`) before they are passed to your resolver in the arguments object. +#### Limiting global ID args to specific types + +`globalID` input's can be configured to validate the type of the globalID. This is useful if you +only want to accept IDs for specific node types. + +```typescript +builder.queryType({ + fields: (t) => ({ + fieldThatAcceptsGlobalID: t.boolean({ + args: { + id: t.arg.globalID({ + for: SomeType, + // or allow multiple types + for: [TypeOne, TypeTwo], + required: true, + }), + }, + }), + }), +}); +``` + ### Creating Nodes To create objects that extend the `Node` interface, you can use the new `builder.node` method. @@ -182,6 +204,24 @@ The means that for many cases if you are using classes in your type parameters, are instances of those classes, you won't need to implement an `isTypeOf` method, but it is usually better to explicitly define that behavior. +#### parsing node ids + +By default all node ids are parsed as string. This behavior can be customized by providing a custom +parse function for your node's ID field: + +```ts +builder.node(NumberThing, { + // define an id field + id: { + resolve: (num) => num.id, + parse: (id) => Number.parseInt(id, 10), + }, + // the ID is now a number + loadOne: (id) => new NumberThing(id), + ... +}); +``` + ### Creating Connections The `t.connection` field builder method can be used to define connections. This method will