Skip to main content

webiny-custom-graphql-api

Adding custom GraphQL queries and mutations using GraphQLSchemaFactory. Use this skill when the developer wants to add custom GraphQL endpoints, create custom queries or mutations, add business logic to the API layer, build custom resolvers, inject backend services (identity, tenancy, CMS use-cases) into their GraphQL schema, add authorization checks to resolvers, or build dynamic GraphQL inputs from CMS models. Covers the full pattern from simple queries to complex resolvers with dependency injection and permission transformers.

معلومات المصدر

المستودع
webiny/webiny-js
آخر نشاط في المصدر
٣٠ أكتوبر ٢٠٢٦ في ١١:٠٠
لغة SKILL.md المكتشفة
الإنجليزية
النجوم
٨٬٠٥١
التفرعات
٦٨٣

خيارات التثبيت

يُحدَّد Prompt الذي يراجع المصدر أولًا بشكل افتراضي. يمكنك التبديل إلى أمر مباشر أو تنزيل نسخة محلية.

مراجعة ملفات المصدر

اقرأ SKILL.md وأي ملفات مرافقة يعرضها SkillsMP قبل أن تقرر التثبيت.

عرض SKILL.md

SKILL.md
تعليمات المصدر · معاينة للقراءة فقط
name
webiny-custom-graphql-api
context
webiny-extensions
description
Adding custom GraphQL queries and mutations using GraphQLSchemaFactory. Use this skill when the developer wants to add custom GraphQL endpoints, create custom queries or mutations, add business logic to the API layer, build custom resolvers, inject backend services (identity, tenancy, CMS use-cases) into their GraphQL schema, add authorization checks to resolvers, or build dynamic GraphQL inputs from CMS models. Covers the full pattern from simple queries to complex resolvers with dependency injection and permission transformers.
# Custom GraphQL API ## TL;DR Add custom GraphQL queries and mutations using `GraphQLSchemaFactory`. Implement `GraphQLSchemaFactory.Interface`, use the schema builder to add type definitions and resolvers (with per-resolver DI), and export with `GraphQLSchemaFactory.createImplementation()`. Register as `<Api.Extension>`. **YOU MUST include the full file path with the `.ts` extension in every `src` prop.** For example, use `src={"/extensions/MySchema.ts"}`, NOT `src={"/extensions/MySchema"}`. Omitting the file extension will cause a build failure. **YOU MUST use `export default` for the `createImplementation()` call** when the file is targeted directly by an Extension `src` prop. Using a named export (`export const Foo = SomeFactory.createImplementation(...)`) will cause a build failure. Named exports are only valid inside files registered via `createFeature`. ## The GraphQLSchemaFactory Pattern The `execute` method receives a schema builder and returns it after adding type defs and resolvers. ```typescript // extensions/mySchema/MyGraphQLSchema.ts import { GraphQLSchemaFactory } from "webiny/api/graphql"; class MySchema implements GraphQLSchemaFactory.Interface { async execute( builder: GraphQLSchemaFactory.SchemaBuilder ): Promise<GraphQLSchemaFactory.SchemaBuilder> { builder.addTypeDefs(/* GraphQL */ ` extend type Query { hello: String! } `); builder.addResolver({ path: "Query.hello", resolver: () => { return () => "Hello, World!"; } }); return builder; } } export default GraphQLSchemaFactory.createImplementation({ implementation: MySchema, dependencies: [] }); ``` Register as an extension: ```tsx // extensions/mySchema/Extension.tsx import React from "react"; import { Api } from "webiny/extensions"; export const MySchema = () => { return <Api.Extension src={"@/extensions/mySchema/MyGraphQLSchema.ts"} />; }; ``` ## Schema Builder API Reference | Method | Description | | --------------------------------------- | --------------------------------------------------------------------------------------------- | | `builder.addTypeDefs(typeDefs: string)` | Add GraphQL type definitions (use `extend type Query/Mutation` to add to existing root types) | | `builder.addResolver<TArgs>(config)` | Add a resolver with optional per-resolver DI dependencies | ### `addResolver` Config ```typescript builder.addResolver<TArgs>({ path: "TypeName.fieldName", // dot-separated path dependencies: [SomeAbstraction], // optional: DI tokens resolved at request time resolver: (dep1, dep2, ...) => { // factory: receives resolved deps return ({ parent, args, context, info }) => { // actual resolver logic return result; }; } }); ``` Key points: - **`path`**: Dot-separated GraphQL type path, e.g. `"Query.hello"`, `"Mutation.createOrder"`, `"OrderMutation.create"` - **`dependencies`**: Array of DI abstraction tokens. Resolved **per-request** from `context.container`, not at schema build time - **`resolver`**: A factory function that receives resolved dependencies and returns the actual resolver function - **Resolver params**: The inner function receives `{ parent, args, context, info }` (named object, not positional) ## Per-Resolver Dependency Injection Dependencies in `addResolver` are resolved at request time from the request-scoped container. This is different from class-level constructor DI — it gives each resolver access to request-scoped services like identity and tenant context. ```typescript import { GraphQLSchemaFactory } from "webiny/api/graphql"; import { IdentityContext } from "webiny/api/security"; class WhoAmISchema implements GraphQLSchemaFactory.Interface { async execute( builder: GraphQLSchemaFactory.SchemaBuilder ): Promise<GraphQLSchemaFactory.SchemaBuilder> { builder.addTypeDefs(/* GraphQL */ ` extend type Query { whoAmI: String } `); builder.addResolver({ path: "Query.whoAmI", dependencies: [IdentityContext], resolver: (identityContext: IdentityContext.Interface) => { return () => { const identity = identityContext.getIdentity(); return `Hello, ${identity.displayName}!`; }; } }); return builder; } } export default GraphQLSchemaFactory.createImplementation({ implementation: WhoAmISchema, dependencies: [] }); ``` Register `GraphQLSchemaFactory` implementations with `dependencies: []` and declare every dependency a resolver needs in `addResolver({ dependencies })`. ## Never Use Constructor Dependencies Inside a Resolver The executable GraphQL schema is built once and kept in an in-memory cache that later requests share, including requests for other tenants whose models produce the same schema. The cached schema holds the resolvers of the factory instances that built it, and those instances got their constructor dependencies from the request that built it. A resolver that reads `this.someDependency` keeps that request's instance for every later request served from the cache. This holds even when your factory has no `getSchemaKey()` and runs on every request: on a cache hit the engine reuses the cached schema and discards the resolvers your factory just produced. With `IdentityContext` this is a security hole: if an editor's request built the schema, an anonymous request that arrives later passes `this.identityContext.getIdentity().isAnonymous()` checks and runs with the editor's permissions, including mutations. The same applies to `TenantContext`, every CMS/ACO/Webiny use case, and any service of your own built on top of them. It works in local testing and fails only "sometimes" in a deployed environment, depending on which request built the cached schema. ```typescript // WRONG: the resolver closes over the identity of the request that built the schema class MySchema implements GraphQLSchemaFactory.Interface { constructor(private identityContext: IdentityContext.Interface) {} async execute(builder: GraphQLSchemaFactory.SchemaBuilder) { builder.addResolver({ path: "Query.secret", resolver: () => async () => { if (this.identityContext.getIdentity().isAnonymous()) { return new NotAuthorizedResponse(); } // ... } }); return builder; } } export default GraphQLSchemaFactory.createImplementation({ implementation: MySchema, dependencies: [IdentityContext] }); // RIGHT: the resolver gets the current request's IdentityContext on every call class MySchema implements GraphQLSchemaFactory.Interface { async execute(builder: GraphQLSchemaFactory.SchemaBuilder) { builder.addResolver({ path: "Query.secret", dependencies: [IdentityContext], resolver: (identityContext: IdentityContext.Interface) => async () => { if (identityContext.getIdentity().isAnonymous()) { return new NotAuthorizedResponse(); } // ... } }); return builder; } } export default GraphQLSchemaFactory.createImplementation({ implementation: MySchema, dependencies: [] }); ``` Shared helpers follow the same rule: pass the resolver's own instance in (`authorize(identityContext)`) instead of reading it from `this`. Do not store anything built from constructor dependencies in module scope either. The only valid use of constructor dependencies is inside `execute()` and `getSchemaKey()`, to decide what type definitions to generate (see [Dynamic Input Fields from CMS Model](#dynamic-input-fields-from-cms-model)). --- ## Query Schema with UseCase DI Full pattern using `Response` / `ErrorResponse` wrappers and UseCase injection: ```typescript import { Response } from "@webiny/api-graphql"; import { ErrorResponse } from "@webiny/api-graphql"; import { GraphQLSchemaFactory } from "@webiny/api-graphql/graphql/abstractions.js"; import { GetCurrentEntityUseCase } from "../features/getCurrentEntity/abstractions.js"; class GetCurrentEntitySchema implements GraphQLSchemaFactory.Interface { async execute( builder: GraphQLSchemaFactory.SchemaBuilder ): Promise<GraphQLSchemaFactory.SchemaBuilder> { builder.addTypeDefs(/* GraphQL */ ` type EntityResponse { data: Entity error: Error } type Entity { id: ID! values: JSON! } type MyPackageQuery { getCurrentEntity: EntityResponse } extend type Query { myPackage: MyPackageQuery } `); // Pass-through resolver for the namespace builder.addResolver({ path: "Query.myPackage", resolver: () => { return () => ({}); } }); builder.addResolver({ path: "MyPackageQuery.getCurrentEntity", dependencies: [GetCurrentEntityUseCase], resolver: (getEntity: GetCurrentEntityUseCase.Interface) => { return async () => { const result = await getEntity.execute(); if (result.isFail()) { return new ErrorResponse(result.error); } return new Response(result.value); }; } }); return builder; } } export default GraphQLSchemaFactory.createImplementation({ implementation: GetCurrentEntitySchema, dependencies: [] }); ``` --- ## Namespaced Mutation Pattern For namespaced mutations (e.g. `mutation { myPackage { createEntity } }`): 1. **One schema** defines the base namespace type + extends `Mutation` 2. **Other schemas** extend the namespace type ```typescript // Schema 1: defines the namespace builder.addTypeDefs(/* GraphQL */ ` type MyPackageMutation { _empty: String } extend type Mutation { myPackage: MyPackageMutation } `); builder.addResolver({ path: "Mutation.myPackage", resolver: () => { return () => ({}); } }); // Schema 2: extends the namespace builder.addTypeDefs(/* GraphQL */ ` extend type MyPackageMutation { disableEntity(entityId: ID!): BooleanResponse } `); builder.addResolver<{ entityId: string }>({ path: "MyPackageMutation.disableEntity", dependencies: [DisableEntityUseCase], resolver: (disableEntity: DisableEntityUseCase.Interface) => { return async ({ args }) => { const result = await disableEntity.execute(args.entityId); if (result.isFail()) { return new ErrorResponse(result.error); } return new Response(true); }; } }); ``` --- ## Dynamic Input Fields from CMS Model When GraphQL inputs must reflect CMS model fields (e.g., an extensible "extensions" object), the factory needs services while it builds the type definitions. This is the one case for constructor dependencies: `pluginsContainer` and `listModelsUseCase` are used in `execute()` only, never inside a resolver. The resolver still gets `CreateEntityUseCase` through `addResolver({ dependencies })`. ```typescript import { GraphQLSchemaFactory } from "@webiny/api-graphql/graphql/abstractions.js"; import { Response, ErrorResponse } from "@webiny/api-graphql"; import { PluginsContainer } from "@webiny/api-headless-cms/legacy/abstractions.js"; import { renderInputFields } from "@webiny/api-headless-cms/utils/renderInputFields.js"; import { createFieldTypePluginRecords } from "@webiny/api-headless-cms/graphql/schema/createFieldTypePluginRecords.js"; import { ListModelsUseCase } from "@webiny/api-headless-cms/exports/api/cms/model.js"; import { CreateEntityUseCase } from "../features/createEntity/abstractions.js"; import { ENTITY_MODEL_ID } from "~/shared/constants.js"; class CreateEntitySchema implements GraphQLSchemaFactory.Interface { constructor( private pluginsContainer: PluginsContainer.Interface, private listModelsUseCase: ListModelsUseCase.Interface ) {} async execute( builder: GraphQLSchemaFactory.SchemaBuilder ): Promise<GraphQLSchemaFactory.SchemaBuilder> { const inputCreateFields = await this.getExtensionsInput(); builder.addTypeDefs(/* GraphQL */ ` ${inputCreateFields.map(f => f.typeDefs).join("\n")} input CreateEntityInput { id: ID name: String! description: String ${inputCreateFields.map(f => f.fields).join("\n")} } extend type MyPackageMutation { createEntity(input: CreateEntityInput!): BooleanResponse } `); builder.addResolver<{ input: CreateEntityUseCase.Input }>({ path: "MyPackageMutation.createEntity", dependencies: [CreateEntityUseCase], resolver: (createEntity: CreateEntityUseCase.Interface) => { return async ({ args }) => { const result = await createEntity.execute(args.input); if (result.isFail()) { return new ErrorResponse(result.error); } return new Response(true); }; } }); return builder; } private async getExtensionsInput() { const fieldTypePlugins = createFieldTypePluginRecords(this.pluginsContainer); const modelsResult = await this.listModelsUseCase.execute({ includePlugins: true, includePrivate: false }); if (modelsResult.isFail()) { return [{ typeDefs: "", fields: "extensions: JSON" }]; } const models = modelsResult.value; const model = models.find(m => m.modelId === ENTITY_MODEL_ID)!; return renderInputFields({ models, model, fields: model.fields.filter(f => f.fieldId === "extensions"), fieldTypePlugins }); } } // Note: constructor DI needed here because of PluginsContainer + ListModelsUseCase, // which are only used to build type definitions, never inside a resolver export default GraphQLSchemaFactory.createImplementation({ implementation: CreateEntitySchema, dependencies: [PluginsContainer, ListModelsUseCase] }); ``` --- ## Permission Transformer (Adding CMS Permissions)
عرض على GitHub
ملف SKILL.md هذا كبير جدا، لذلك يعرض SkillsMP القسم الاول فقط هنا. عرض على GitHub