Skip to content

Commit

Permalink
feat(handler-graphql): add support for resolver decoration (#4199)
Browse files Browse the repository at this point in the history
  • Loading branch information
Pavel910 authored Oct 1, 2024
1 parent 91b712f commit c4892c6
Show file tree
Hide file tree
Showing 17 changed files with 184 additions and 54 deletions.
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

0 comments on commit c4892c6

Please sign in to comment.