Open n1ru4l opened 3 years ago
Could be a great idea - any rough example what would the API look like?
A quick sketch:
type VisibilityExtension = {
visibilityAccess?: "public" | "hidden";
}
type LiveQueryExtensions = {
liveQuery?: {
buildResourceIdentifier: (source: unknown) => string;
},
}
type TExtensions = VisibilityExtension & LiveQueryExtensions
const t = createTypesFactory<TContext, TExtensions>();
const Character = t.objectType({
name: "Character",
extensions: {
visibilityAccess: "public"
},
fields: () => [
t.field("name", {
type: t.NonNull(t.String),
resolve: (character) => "HI",
}),
],
});
Note extensions are just there for adding metadata to the schema. Actually doing sth with the metadata must be done in user-land. We could however provide some way of at least providing typings for the available extensions.
See https://github.com/graphql/graphql-js/pull/2097
I guess we will hit limits on what we can do. E.g. buildResourceIdentifier
would receive the source
object of the object type (in the user-land implementation), however, I am not aware of how that could be typed properly in a general way.
I created https://github.com/sikanhe/gqtx/pull/36 for a basic implementation. It does however not use any typing magic, but I guess stuff like that could still be introduced later on.
I was asked for some examples of use cases, here are some.
interface TExtensions {
authorize?: (ctx: TContext) => boolean;
authenticate?: boolean;
}
const t = createTypesFactory<TContext, TExtensions>({
extensions: {
authorize: { // first arg is whatever value passed to authorize, in this case a fn
extendObjectTypeResolver: (checkAuth) => async (parent, args, ctx, info) => {
const isAuthorized = await checkAuth(ctx);
if (!isAuthorized) throw new AuthorizationError()
}
},
authenticate: {
extendObjectTypeResolver: (needsAuthentication) => async (parent, args, ctx, info) => {
const isAuthenticated = await ctx.checkDbForSession(ctx.user.id);
if (!isAuthenticated) throw new AuthenticationError()
}
},
}
});
const Mutation = t.mutationType({
fields: [
t.field('doStuffOnlyAdminShouldDo', {
type: SomeResponseType,
args: {
id: t.arg(t.NonNullInput(t.ID)),
},
extensions: {
authorize: (ctx) => ctx.checkRoles(['ADMIN']),
authenticate: true,
},
resolve: (_, args, ctx) => {
...
},
}),
],
});
Now for a more complicated example, and probably much harder to define with TS
import { fromGlobalId } from 'graphql-relay';
const t = createTypesFactory<TContext, TExtensions>({
extensions: {
parse: {
extendInputType: (parseFn) => async (args, argName) => {
// mutates the args object before the resolver runs
// parse function will also validate inputs and throw if invalid
args[argName] = await parseFn(args[argName]);
},
},
},
});
const MyInputType = t.inputObjectType<{ id: string }>({
name: 'MyInputType',
extensions: {
// arg should have the same type as input
parse: async (input) => {
// input.id is a Relay Global Object Identifier, it needs to be parsed into the id that is actually used in the db
return {
realId: fromGlobalId(input.id),
};
},
},
fields: () => [t.defaultField('id', t.ID)],
});
const Mutation = t.mutationType({
fields: [
t.field('doStuffOnlyAdminShouldDo', {
type: SomeResponseType,
args: {
input: t.arg(t.NonNullInput(MyInputType)),
},
// type of `args.input` is now the return type of its parse extension
// { realId: string }
resolve: (_, args, ctx) => {},
}),
],
});
I expect it wouldnt be feasible, but a simpler version where you only validate the input and throw an exception if its bad (without mutating args) might be.
There is a lot of inspiration to be taken from nexus's plugins https://nexusjs.org/docs/api/plugins
I looked through Nexus' plugin, most of them should not need extensions. Just plain, reuse-able higher order functions.
Take your authentication for example, we just create a function that takes a regular resolver, and return a new resolver, and can be used on any field that requires auth
const adminOnly = (resolver) => (parent, args, ctx) => {
if (Permissions.isAdmin(ctx.currentUser)) return resolver(ctx, parent, args);
return null // or return error
}
fields: [
t.field('allUsers', {
type: ListOfUsers,
resolver: adminOnly(() => { return Users.getAll() })
})
]
The same pattern can be applied to pretty everything on that plugin list.
For the relay ID example. You create a new GraphQL RelayId Scalar type, which is used for parsing and serializing a leaf node. Roughly
const globalId = t.scalarType('globalId', {
serialize: toGlobalId,
parse: fromGlobalId
})
fields: [
t.defaultField("id", globalId)
]
yea, thats a totally fair decision. These are quality of life features only that could make the complexity explode
It would be nice if extensions could be specified for the fields/types. We could make it type-safe by allowing a generic extension type on the
createTypesFactory
function.