Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(handler-graphql): add support for resolver decoration #4199

Merged
merged 4 commits into from
Oct 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/api-headless-cms/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"dependencies": {
"@babel/code-frame": "^7.24.7",
"@babel/runtime": "^7.24.0",
"@graphql-tools/merge": "^9.0.4",
"@graphql-tools/schema": "^10.0.6",
"@webiny/api": "0.0.0",
"@webiny/api-i18n": "0.0.0",
Expand Down
28 changes: 19 additions & 9 deletions packages/api-headless-cms/src/graphql/createExecutableSchema.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { makeExecutableSchema } from "@graphql-tools/schema";
import { mergeResolvers } from "@graphql-tools/merge";
import { ResolverDecoration } from "@webiny/handler-graphql";
import { Resolvers, TypeDefs } from "@webiny/handler-graphql/types";
import { ICmsGraphQLSchemaPlugin } from "~/plugins";

interface Params {
Expand All @@ -7,21 +10,28 @@ interface Params {

export const createExecutableSchema = (params: Params) => {
const { plugins } = params;
/**
* Really hard to type this to satisfy the makeExecutableSchema
*/
// TODO @ts-refactor
const typeDefs: any = [];
const resolvers: any = [];

const typeDefs: TypeDefs[] = [];
const resolvers: Resolvers<any>[] = [];

const resolverDecoration = new ResolverDecoration();

// Get schema definitions from plugins
for (const plugin of plugins) {
typeDefs.push(plugin.schema.typeDefs);
resolvers.push(plugin.schema.resolvers);
const schema = plugin.schema;
if (schema.typeDefs) {
typeDefs.push(schema.typeDefs);
}
if (schema.resolvers) {
resolvers.push(schema.resolvers);
}
if (schema.resolverDecorators) {
resolverDecoration.addDecorators(schema.resolverDecorators);
}
}

return makeExecutableSchema({
typeDefs,
resolvers
resolvers: resolverDecoration.decorateResolvers(mergeResolvers(resolvers))
});
};
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { createCmsGraphQLSchemaPlugin, ICmsGraphQLSchemaPlugin } from "~/plugins
import { getEntryDescription } from "~/utils/getEntryDescription";
import { getEntryImage } from "~/utils/getEntryImage";
import { entryFieldFromStorageTransform } from "~/utils/entryStorage";
import { Resolvers } from "@webiny/handler-graphql/types";
import { GraphQLFieldResolver } from "@webiny/handler-graphql/types";
import { ENTRY_META_FIELDS, isDateTimeEntryMetaField } from "~/constants";

interface EntriesByModel {
Expand Down Expand Up @@ -278,7 +278,7 @@ const getContentEntry = async (
/**
* As we support description field, we need to transform the value from storage.
*/
const createResolveDescription = (): Resolvers<CmsContext> => {
const createResolveDescription = (): GraphQLFieldResolver<any, any, CmsContext> => {
return async (parent, _, context) => {
const models = await context.cms.listModels();
const model = models.find(({ modelId }) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import set from "lodash/set";
import { ApiEndpoint, CmsContext, CmsFieldTypePlugins, CmsModel, CmsModelField } from "~/types";
import { entryFieldFromStorageTransform } from "~/utils/entryStorage";
import { Resolvers } from "@webiny/handler-graphql/types";
import WebinyError from "@webiny/error";
import { ApiEndpoint, CmsContext, CmsFieldTypePlugins, CmsModel, CmsModelField } from "~/types";
import { entryFieldFromStorageTransform } from "~/utils/entryStorage";
import { getBaseFieldType } from "~/utils/getBaseFieldType";

interface CreateFieldResolvers {
Expand Down Expand Up @@ -77,8 +77,6 @@ export const createFieldResolversFactory = (factoryParams: CreateFieldResolversF
}

const { fieldId } = field;
// TODO @ts-refactor figure out types for parameters
// @ts-expect-error
fieldResolvers[fieldId] = async (parent, args, context: CmsContext, info) => {
/**
* This is required because due to ref field can be requested without the populated data.
Expand Down
3 changes: 0 additions & 3 deletions packages/api-websockets/src/graphql/createResolvers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ export const createResolvers = (): Resolvers<Context> => {
}
},
WebsocketsMutation: {
// @ts-expect-error
disconnect: async (_, args: IWebsocketsMutationDisconnectConnectionsArgs, context) => {
return resolve(async () => {
await checkPermissions(context);
Expand All @@ -46,7 +45,6 @@ export const createResolvers = (): Resolvers<Context> => {
});
});
},
// @ts-expect-error
disconnectIdentity: async (
_,
args: IWebsocketsMutationDisconnectIdentityArgs,
Expand All @@ -61,7 +59,6 @@ export const createResolvers = (): Resolvers<Context> => {
});
});
},
// @ts-expect-error
disconnectTenant: async (_, args: IWebsocketsMutationDisconnectTenantArgs, context) => {
return resolve<IWebsocketsConnectionRegistryData[]>(async () => {
await checkPermissions(context);
Expand Down
5 changes: 1 addition & 4 deletions packages/cli/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,6 @@
* Rename file to types.ts when switching the package to Typescript.
*/

import glob from "fast-glob";
import { dirname, join } from "path";

export type GenericRecordKey = string | number | symbol;

export type GenericRecord<K extends GenericRecordKey = GenericRecordKey, V = any> = Record<K, V>;
Expand Down Expand Up @@ -166,7 +163,7 @@ export interface CliContext {
resolve: (dir: string) => string;

/**
* Provides a way to store some meta data in the project's local ".webiny/cli.json" file.
* Provides a way to store some metadata in the project's local ".webiny/cli.json" file.
* Only trivial data should be passed here, specific to the current project.
*/
localStorage: {
Expand Down
45 changes: 45 additions & 0 deletions packages/handler-graphql/__tests__/graphql.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import useGqlHandler from "./useGqlHandler";
import { booksSchema, booksCrudPlugin } from "~tests/mocks/booksSchema";
import { createGraphQLSchemaPlugin } from "~/plugins";
import { createResolverDecorator } from "~/index";
import { Context } from "./types";

describe("GraphQL Handler", () => {
test("should return errors if schema doesn't exist", async () => {
Expand Down Expand Up @@ -76,4 +79,46 @@ describe("GraphQL Handler", () => {
]
});
});

test("should compose resolvers", async () => {
const lowerCaseName = createResolverDecorator<any, any, Context>(
resolver => async (parent, args, context, info) => {
const name = await resolver(parent, args, context, info);

return name.toLowerCase();
}
);

const listBooks = createResolverDecorator(() => async () => {
return [{ name: "Article 1" }];
});

const decorator1 = createGraphQLSchemaPlugin({
resolverDecorators: {
"Query.books": [listBooks],
"Book.name": [lowerCaseName]
}
});

const addNameSuffix = createResolverDecorator(resolver => async (...args) => {
const name = await resolver(...args);

return `${name} (suffix)`;
});

const decorator2 = createGraphQLSchemaPlugin({
resolverDecorators: {
"Book.name": [addNameSuffix]
}
});

const { invoke } = useGqlHandler({
debug: true,
plugins: [booksCrudPlugin, booksSchema, decorator1, decorator2]
});
const [response] = await invoke({ body: { query: `{ books { name } }` } });
expect(response.errors).toBeFalsy();
expect(response.data.books.length).toBe(1);
expect(response.data.books[0].name).toBe("article 1 (suffix)");
});
});
5 changes: 5 additions & 0 deletions packages/handler-graphql/__tests__/mocks/booksSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ export const booksSchema = createGraphQLSchemaPlugin<Context>({
return null;
}
},
Book: {
name: book => {
return book.name;
}
},
Mutation: {
async createBook() {
return true;
Expand Down
3 changes: 3 additions & 0 deletions packages/handler-graphql/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@
],
"dependencies": {
"@babel/runtime": "^7.24.0",
"@graphql-tools/merge": "^9.0.4",
"@graphql-tools/resolvers-composition": "^7.0.1",
"@graphql-tools/schema": "^10.0.6",
"@graphql-tools/utils": "^10.3.1",
"@webiny/api": "0.0.0",
"@webiny/error": "0.0.0",
"@webiny/handler": "0.0.0",
Expand Down
22 changes: 22 additions & 0 deletions packages/handler-graphql/src/ResolverDecoration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { composeResolvers } from "@graphql-tools/resolvers-composition";
import { ResolverDecorators, Resolvers } from "./types";

export class ResolverDecoration {
private decorators: ResolverDecorators = {};

addDecorators(resolverDecorators: ResolverDecorators) {
for (const key in resolverDecorators) {
const decorators = resolverDecorators[key];
if (!decorators) {
continue;
}

const existingDecorators = this.decorators[key] ?? [];
this.decorators[key] = [...existingDecorators, ...decorators];
}
}

decorateResolvers(resolvers: Resolvers<unknown>) {
return composeResolvers(resolvers, this.decorators);
}
}
35 changes: 21 additions & 14 deletions packages/handler-graphql/src/createGraphQLSchema.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import gql from "graphql-tag";
import { makeExecutableSchema } from "@graphql-tools/schema";
import { GraphQLScalarPlugin, GraphQLSchemaPlugin } from "./types";
import { mergeResolvers } from "@graphql-tools/merge";
import { GraphQLScalarType } from "graphql/type/definition";
import { GraphQLScalarPlugin, GraphQLSchemaPlugin, Resolvers, TypeDefs } from "./types";
import { Context } from "@webiny/api/types";
import {
RefInputScalar,
Expand All @@ -12,7 +14,7 @@ import {
TimeScalar,
LongScalar
} from "./builtInTypes";
import { GraphQLScalarType } from "graphql/type/definition";
import { ResolverDecoration } from "./ResolverDecoration";

export const getSchemaPlugins = (context: Context) => {
return context.plugins.byType<GraphQLSchemaPlugin>("graphql-schema");
Expand All @@ -23,9 +25,9 @@ export const createGraphQLSchema = (context: Context) => {
.byType<GraphQLScalarPlugin>("graphql-scalar")
.map(item => item.scalar);

// TODO: once the API packages more closed, we'll have the opportunity
// TODO: once the API packages are more closed, we'll have the opportunity
// TODO: to maybe import the @ps directive from `api-prerendering-service` package.
const typeDefs = [
const typeDefs: TypeDefs[] = [
gql`
type Query
type Mutation
Expand All @@ -46,7 +48,7 @@ export const createGraphQLSchema = (context: Context) => {
`
];

const resolvers = [
const resolvers: Resolvers<any>[] = [
{
...scalars.reduce<Record<string, GraphQLScalarType>>((acc, s) => {
acc[s.name] = s;
Expand All @@ -63,21 +65,26 @@ export const createGraphQLSchema = (context: Context) => {
}
];

const resolverDecoration = new ResolverDecoration();

const plugins = getSchemaPlugins(context);

for (const plugin of plugins) {
/**
* TODO @ts-refactor
* Figure out correct types on typeDefs and resolvers
*/
// @ts-expect-error
typeDefs.push(plugin.schema.typeDefs);
// @ts-expect-error
resolvers.push(plugin.schema.resolvers);
const schema = plugin.schema;
if (schema.typeDefs) {
typeDefs.push(schema.typeDefs);
}
if (schema.resolvers) {
resolvers.push(schema.resolvers);
}
if (schema.resolverDecorators) {
resolverDecoration.addDecorators(schema.resolverDecorators);
}
}

return makeExecutableSchema({
typeDefs,
resolvers,
resolvers: resolverDecoration.decorateResolvers(mergeResolvers(resolvers)),
inheritResolversFromInterfaces: true
});
};
7 changes: 7 additions & 0 deletions packages/handler-graphql/src/createResolverDecorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { ResolverDecorator } from "./types";

export const createResolverDecorator = <TSource = any, TContext = any, TArgs = any>(
decorator: ResolverDecorator<TSource, TContext, TArgs>
) => {
return decorator;
};
2 changes: 2 additions & 0 deletions packages/handler-graphql/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,7 @@ export * from "./responses";
export * from "./utils";
export * from "./plugins";
export * from "./processRequestBody";
export * from "./createResolverDecorator";
export * from "./ResolverDecoration";

export default (options: HandlerGraphQLOptions = {}) => [createGraphQLHandler(options)];
10 changes: 6 additions & 4 deletions packages/handler-graphql/src/plugins/GraphQLSchemaPlugin.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import { Plugin } from "@webiny/plugins";
import { GraphQLSchemaDefinition, Resolvers, Types } from "~/types";
import { Context } from "@webiny/api/types";
import { Plugin } from "@webiny/plugins";
import { GraphQLSchemaDefinition, ResolverDecorators, Resolvers, TypeDefs } from "~/types";

export interface IGraphQLSchemaPlugin<TContext = Context> extends Plugin {
schema: GraphQLSchemaDefinition<TContext>;
}

export interface GraphQLSchemaPluginConfig<TContext> {
typeDefs?: Types;
typeDefs?: TypeDefs;
resolvers?: Resolvers<TContext>;
resolverDecorators?: ResolverDecorators;
}

export class GraphQLSchemaPlugin<TContext = Context>
Expand All @@ -26,7 +27,8 @@ export class GraphQLSchemaPlugin<TContext = Context>
get schema(): GraphQLSchemaDefinition<TContext> {
return {
typeDefs: this.config.typeDefs || "",
resolvers: this.config.resolvers
resolvers: this.config.resolvers,
resolverDecorators: this.config.resolverDecorators
};
}
}
Expand Down
Loading
Loading