vtex-io-graphql-api

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

GraphQL Schemas & Resolvers

GraphQL Schemas & Resolvers

When this skill applies

本技能的适用场景

Use this skill when your VTEX IO app needs to expose a GraphQL API — either for frontend React components to query, for other VTEX IO apps to consume, or for implementing custom data aggregation layers over VTEX Commerce APIs.
  • Defining schemas in
    .graphql
    files in the
    /graphql
    directory
  • Writing resolver functions in TypeScript in
    /node/resolvers/
  • Configuring
    @cacheControl
    and
    @auth
    directives
  • Wiring resolvers into the Service class
Do not use this skill for:
  • Backend service structure and client system (use
    vtex-io-service-apps
    instead)
  • Manifest and builder configuration (use
    vtex-io-app-structure
    instead)
  • MasterData integration details (use
    vtex-io-masterdata
    instead)
当你的VTEX IO应用需要暴露GraphQL API时使用本技能——无论是供前端React组件查询、供其他VTEX IO应用调用,还是用于在VTEX Commerce API之上实现自定义数据聚合层。
  • /graphql
    目录下的
    .graphql
    文件中定义schema
  • /node/resolvers/
    目录下用TypeScript编写resolver函数
  • 配置
    @cacheControl
    @auth
    指令
  • 将resolver接入Service类
以下场景请勿使用本技能:
  • 后端服务结构与客户端系统(请使用
    vtex-io-service-apps
  • 清单(Manifest)与构建器配置(请使用
    vtex-io-app-structure
  • MasterData集成细节(请使用
    vtex-io-masterdata

Decision rules

决策规则

  • The
    graphql
    builder processes
    .graphql
    files in
    /graphql
    and merges them into a single schema.
  • Split definitions across multiple files for maintainability:
    schema.graphql
    for root types,
    directives.graphql
    for directive declarations,
    types/*.graphql
    for custom types.
  • Use
    @cacheControl(scope: PUBLIC, maxAge: SHORT|MEDIUM|LONG)
    on all public Query fields.
    PUBLIC
    = shared CDN cache,
    PRIVATE
    = per-user cache.
  • Use
    @auth
    on all Mutations and on Queries that return sensitive or user-specific data.
  • Never use
    @cacheControl
    on Mutations.
  • Resolver function keys in the Service entry point MUST exactly match the field names in
    schema.graphql
    .
  • Always use
    ctx.clients
    in resolvers for data access — never raw HTTP calls.
Recommended directory structure:
text
graphql/
├── schema.graphql        # Query and Mutation root type definitions
├── directives.graphql    # Custom directive declarations (@cacheControl, @auth)
└── types/
    ├── Review.graphql    # Custom type definitions
    └── Product.graphql   # One file per type for organization
Built-in directives:
  • @cacheControl
    :
    scope
    (
    PUBLIC
    /
    PRIVATE
    ),
    maxAge
    (
    SHORT
    30s,
    MEDIUM
    5min,
    LONG
    1h)
  • @auth
    : Enforces valid VTEX authentication token. Without it, unauthenticated users can call the endpoint.
  • @smartcache
    : Automatically caches query results in VTEX infrastructure.
  • graphql
    构建器会处理
    /graphql
    目录下的
    .graphql
    文件,并将它们合并为一个单一的schema。
  • 为了可维护性,将定义拆分到多个文件中:
    schema.graphql
    用于根类型,
    directives.graphql
    用于指令声明,
    types/*.graphql
    用于自定义类型。
  • 在所有公开的Query字段上使用
    @cacheControl(scope: PUBLIC, maxAge: SHORT|MEDIUM|LONG)
    PUBLIC
    表示共享CDN缓存,
    PRIVATE
    表示按用户缓存。
  • 在所有Mutation以及返回敏感或用户特定数据的Query上使用
    @auth
  • 切勿在Mutation上使用
    @cacheControl
  • Service入口点中的resolver函数键必须与
    schema.graphql
    中的字段名称完全匹配。
  • 在resolver中始终使用
    ctx.clients
    进行数据访问——绝不使用原始HTTP调用。
推荐的目录结构:
text
graphql/
├── schema.graphql        # Query和Mutation根类型定义
├── directives.graphql    # 自定义指令声明(@cacheControl、@auth)
└── types/
    ├── Review.graphql    # 自定义类型定义
    └── Product.graphql   # 每个类型对应一个文件,便于组织
内置指令:
  • @cacheControl
    :包含
    scope
    PUBLIC
    /
    PRIVATE
    )、
    maxAge
    SHORT
    30秒、
    MEDIUM
    5分钟、
    LONG
    1小时)
  • @auth
    :强制要求有效的VTEX身份验证令牌。如果不使用该指令,未认证用户也可以调用端点。
  • @smartcache
    :自动在VTEX基础设施中缓存查询结果。

Hard constraints

硬性约束

Constraint: Declare the graphql Builder

约束:声明graphql构建器

Any app using
.graphql
schema files MUST declare the
graphql
builder in
manifest.json
. The
graphql
builder interprets the schema and registers it with the VTEX IO runtime.
Why this matters
Without the
graphql
builder declaration, the
/graphql
directory is completely ignored. Schema files will not be processed, resolvers will not be registered, and GraphQL queries will return "schema not found" errors. The app will link without errors but GraphQL will silently not work.
Detection
If you see
.graphql
files in a
/graphql
directory but the manifest does not include
"graphql": "1.x"
in
builders
, STOP and add the builder declaration.
Correct
json
{
  "builders": {
    "node": "7.x",
    "graphql": "1.x"
  }
}
Wrong
json
{
  "builders": {
    "node": "7.x"
  }
}
Missing
"graphql": "1.x"
— the
/graphql
directory with schema files is ignored. GraphQL queries return errors because no schema is registered. The app links successfully, masking the problem.

任何使用
.graphql
schema文件的应用必须在
manifest.json
中声明
graphql
构建器。
graphql
构建器会解析schema并将其注册到VTEX IO运行时。
重要性
如果没有声明
graphql
构建器,
/graphql
目录会被完全忽略。Schema文件不会被处理,resolver不会被注册,GraphQL查询会返回"schema not found"错误。应用可以正常链接,但GraphQL会静默失效。
检测方式
如果在
/graphql
目录中存在
.graphql
文件,但清单中
builders
没有包含
"graphql": "1.x"
,请立即停止并添加该构建器声明。
正确示例
json
{
  "builders": {
    "node": "7.x",
    "graphql": "1.x"
  }
}
错误示例
json
{
  "builders": {
    "node": "7.x"
  }
}
缺少
"graphql": "1.x"
——包含schema文件的
/graphql
目录会被忽略。GraphQL查询会返回错误,因为没有注册schema。应用可以成功链接,从而掩盖问题。

Constraint: Use @cacheControl on Public Queries

约束:在公开Query上使用@cacheControl

All public-facing Query fields (those fetching data that is not user-specific) MUST include the
@cacheControl
directive with an appropriate
scope
and
maxAge
. Mutations MUST NOT use
@cacheControl
.
Why this matters
Without
@cacheControl
, every query hits your resolver on every request — no CDN caching, no edge caching, no shared caching. This leads to unnecessary load on VTEX infrastructure, slow response times, and potential rate limiting. For public product data, caching is critical for performance.
Detection
If a Query field returns public data (not user-specific) and does not have
@cacheControl
, warn the developer to add it. If a Mutation has
@cacheControl
, STOP and remove it.
Correct
graphql
type Query {
  reviews(productId: String!, limit: Int): [Review]
    @cacheControl(scope: PUBLIC, maxAge: SHORT)

  productMetadata(slug: String!): ProductMetadata
    @cacheControl(scope: PUBLIC, maxAge: MEDIUM)

  myReviews: [Review]
    @cacheControl(scope: PRIVATE, maxAge: SHORT)
    @auth
}

type Mutation {
  createReview(review: ReviewInput!): Review @auth
}
Wrong
graphql
type Query {
  reviews(productId: String!, limit: Int): [Review]

  myReviews: [Review]
}

type Mutation {
  createReview(review: ReviewInput!): Review
    @cacheControl(scope: PUBLIC, maxAge: LONG)
}
No cache control on queries (every request hits the resolver), missing
@auth
on user-specific data, and
@cacheControl
on a mutation (makes no sense).

所有面向公开的Query字段(返回非用户特定数据的字段)必须包含
@cacheControl
指令,并指定合适的
scope
maxAge
。Mutation绝对不能使用
@cacheControl
重要性
如果没有
@cacheControl
,每个查询的每次请求都会触发resolver——没有CDN缓存、边缘缓存或共享缓存。这会导致VTEX基础设施承受不必要的负载、响应时间缓慢,甚至可能触发速率限制。对于公开的产品数据,缓存对性能至关重要。
检测方式
如果某个Query字段返回公开数据(非用户特定)但没有
@cacheControl
,请提醒开发者添加该指令。如果Mutation使用了
@cacheControl
,请立即停止并移除它。
正确示例
graphql
type Query {
  reviews(productId: String!, limit: Int): [Review]
    @cacheControl(scope: PUBLIC, maxAge: SHORT)

  productMetadata(slug: String!): ProductMetadata
    @cacheControl(scope: PUBLIC, maxAge: MEDIUM)

  myReviews: [Review]
    @cacheControl(scope: PRIVATE, maxAge: SHORT)
    @auth
}

type Mutation {
  createReview(review: ReviewInput!): Review @auth
}
错误示例
graphql
type Query {
  reviews(productId: String!, limit: Int): [Review]

  myReviews: [Review]
}

type Mutation {
  createReview(review: ReviewInput!): Review
    @cacheControl(scope: PUBLIC, maxAge: LONG)
}
查询没有缓存控制(每次请求都触发resolver),用户特定数据缺少
@auth
,Mutation使用了
@cacheControl
(毫无意义)。

Constraint: Resolver Names Must Match Schema Fields

约束:Resolver名称必须与Schema字段匹配

Resolver function keys in the Service entry point MUST exactly match the field names defined in
schema.graphql
. The resolver object structure must mirror the GraphQL type hierarchy.
Why this matters
The GraphQL runtime maps incoming queries to resolver functions by name. If the resolver key does not match the schema field name, the field will resolve to
null
without any error — a silent failure that is extremely difficult to debug.
Detection
If a schema field has no matching resolver key (or vice versa), STOP. Cross-check every Query and Mutation field against the resolver registration in
node/index.ts
.
Correct
graphql
type Query {
  reviews(productId: String!): [Review]
  reviewById(id: ID!): Review
}
typescript
// node/index.ts — resolver keys match schema field names exactly
export default new Service({
  graphql: {
    resolvers: {
      Query: {
        reviews: reviewsResolver,
        reviewById: reviewByIdResolver,
      },
    },
  },
})
Wrong
typescript
// node/index.ts — resolver key "getReviews" does not match schema field "reviews"
export default new Service({
  graphql: {
    resolvers: {
      Query: {
        getReviews: reviewsResolver,    // Wrong! Schema says "reviews", not "getReviews"
        getReviewById: reviewByIdResolver, // Wrong! Schema says "reviewById"
      },
    },
  },
})
Both fields will silently resolve to null. No error in logs.
Service入口点中的resolver函数键必须与
schema.graphql
中定义的字段名称完全匹配。Resolver对象结构必须镜像GraphQL类型的层级。
重要性
GraphQL运行时通过名称将传入的查询映射到resolver函数。如果resolver键与schema字段名称不匹配,该字段会解析为
null
且不会有任何错误——这是一种极难调试的静默失败。
检测方式
如果某个schema字段没有对应的resolver键(反之亦然),请立即停止。仔细核对每个Query和Mutation字段与
node/index.ts
中的resolver注册。
正确示例
graphql
type Query {
  reviews(productId: String!): [Review]
  reviewById(id: ID!): Review
}
typescript
// node/index.ts — resolver键与schema字段名称完全匹配
export default new Service({
  graphql: {
    resolvers: {
      Query: {
        reviews: reviewsResolver,
        reviewById: reviewByIdResolver,
      },
    },
  },
})
错误示例
typescript
// node/index.ts — resolver键"getReviews"与schema字段"reviews"不匹配
export default new Service({
  graphql: {
    resolvers: {
      Query: {
        getReviews: reviewsResolver,    // 错误!Schema中是"reviews",不是"getReviews"
        getReviewById: reviewByIdResolver, // 错误!Schema中是"reviewById"
      },
    },
  },
})
这两个字段都会静默解析为null,日志中不会有错误。

Preferred pattern

推荐模式

Add the GraphQL builder to manifest:
json
{
  "builders": {
    "node": "7.x",
    "graphql": "1.x"
  }
}
Define the schema:
graphql
type Query {
  reviews(productId: String!, limit: Int, offset: Int): ReviewsResponse
    @cacheControl(scope: PUBLIC, maxAge: SHORT)

  review(id: ID!): Review
    @cacheControl(scope: PUBLIC, maxAge: SHORT)
}

type Mutation {
  createReview(input: ReviewInput!): Review @auth
  updateReview(id: ID!, input: ReviewInput!): Review @auth
  deleteReview(id: ID!): Boolean @auth
}
Define custom types:
graphql
type Review {
  id: ID!
  productId: String!
  author: String!
  rating: Int!
  title: String!
  text: String!
  createdAt: String!
  approved: Boolean!
}

type ReviewsResponse {
  data: [Review!]!
  total: Int!
  hasMore: Boolean!
}

input ReviewInput {
  productId: String!
  rating: Int!
  title: String!
  text: String!
}
Declare directives:
graphql
directive @cacheControl(
  scope: CacheControlScope
  maxAge: CacheControlMaxAge
) on FIELD_DEFINITION

enum CacheControlScope {
  PUBLIC
  PRIVATE
}

enum CacheControlMaxAge {
  SHORT
  MEDIUM
  LONG
}

directive @auth on FIELD_DEFINITION
directive @smartcache on FIELD_DEFINITION
Implement resolvers:
typescript
// node/resolvers/reviews.ts
import type { ServiceContext } from '@vtex/api'
import type { Clients } from '../clients'

type Context = ServiceContext<Clients>

export const queries = {
  reviews: async (
    _root: unknown,
    args: { productId: string; limit?: number; offset?: number },
    ctx: Context
  ) => {
    const { productId, limit = 10, offset = 0 } = args
    const reviews = await ctx.clients.masterdata.searchDocuments<Review>({
      dataEntity: 'reviews',
      fields: ['id', 'productId', 'author', 'rating', 'title', 'text', 'createdAt', 'approved'],
      where: `productId=${productId} AND approved=true`,
      pagination: { page: Math.floor(offset / limit) + 1, pageSize: limit },
      schema: 'review-schema-v1',
    })

    return {
      data: reviews,
      total: reviews.length,
      hasMore: reviews.length === limit,
    }
  },

  review: async (
    _root: unknown,
    args: { id: string },
    ctx: Context
  ) => {
    return ctx.clients.masterdata.getDocument<Review>({
      dataEntity: 'reviews',
      id: args.id,
      fields: ['id', 'productId', 'author', 'rating', 'title', 'text', 'createdAt', 'approved'],
    })
  },
}

export const mutations = {
  createReview: async (
    _root: unknown,
    args: { input: ReviewInput },
    ctx: Context
  ) => {
    const { input } = args

    const documentResponse = await ctx.clients.masterdata.createDocument({
      dataEntity: 'reviews',
      fields: {
        ...input,
        author: ctx.vtex.storeUserEmail ?? 'anonymous',
        approved: false,
        createdAt: new Date().toISOString(),
      },
      schema: 'review-schema-v1',
    })

    return ctx.clients.masterdata.getDocument<Review>({
      dataEntity: 'reviews',
      id: documentResponse.DocumentId,
      fields: ['id', 'productId', 'author', 'rating', 'title', 'text', 'createdAt', 'approved'],
    })
  },

  deleteReview: async (
    _root: unknown,
    args: { id: string },
    ctx: Context
  ) => {
    await ctx.clients.masterdata.deleteDocument({
      dataEntity: 'reviews',
      id: args.id,
    })

    return true
  },
}
Wire resolvers into the Service:
typescript
// node/index.ts
import type { ParamsContext, RecorderState } from '@vtex/api'
import { Service } from '@vtex/api'

import { Clients } from './clients'
import { queries, mutations } from './resolvers/reviews'

export default new Service<Clients, RecorderState, ParamsContext>({
  clients: {
    implementation: Clients,
    options: {
      default: {
        retries: 2,
        timeout: 5000,
      },
    },
  },
  graphql: {
    resolvers: {
      Query: queries,
      Mutation: mutations,
    },
  },
})
Testing the GraphQL API after linking:
graphql
query GetReviews {
  reviews(productId: "12345", limit: 5) {
    data {
      id
      author
      rating
      title
      text
      createdAt
    }
    total
    hasMore
  }
}

mutation CreateReview {
  createReview(input: {
    productId: "12345"
    rating: 5
    title: "Excellent product"
    text: "Really happy with this purchase."
  }) {
    id
    author
    createdAt
  }
}
在清单中添加GraphQL构建器:
json
{
  "builders": {
    "node": "7.x",
    "graphql": "1.x"
  }
}
定义schema:
graphql
type Query {
  reviews(productId: String!, limit: Int, offset: Int): ReviewsResponse
    @cacheControl(scope: PUBLIC, maxAge: SHORT)

  review(id: ID!): Review
    @cacheControl(scope: PUBLIC, maxAge: SHORT)
}

type Mutation {
  createReview(input: ReviewInput!): Review @auth
  updateReview(id: ID!, input: ReviewInput!): Review @auth
  deleteReview(id: ID!): Boolean @auth
}
定义自定义类型:
graphql
type Review {
  id: ID!
  productId: String!
  author: String!
  rating: Int!
  title: String!
  text: String!
  createdAt: String!
  approved: Boolean!
}

type ReviewsResponse {
  data: [Review!]!
  total: Int!
  hasMore: Boolean!
}

input ReviewInput {
  productId: String!
  rating: Int!
  title: String!
  text: String!
}
声明指令:
graphql
directive @cacheControl(
  scope: CacheControlScope
  maxAge: CacheControlMaxAge
) on FIELD_DEFINITION

enum CacheControlScope {
  PUBLIC
  PRIVATE
}

enum CacheControlMaxAge {
  SHORT
  MEDIUM
  LONG
}

directive @auth on FIELD_DEFINITION
directive @smartcache on FIELD_DEFINITION
实现resolvers:
typescript
// node/resolvers/reviews.ts
import type { ServiceContext } from '@vtex/api'
import type { Clients } from '../clients'

type Context = ServiceContext<Clients>

export const queries = {
  reviews: async (
    _root: unknown,
    args: { productId: string; limit?: number; offset?: number },
    ctx: Context
  ) => {
    const { productId, limit = 10, offset = 0 } = args
    const reviews = await ctx.clients.masterdata.searchDocuments<Review>({
      dataEntity: 'reviews',
      fields: ['id', 'productId', 'author', 'rating', 'title', 'text', 'createdAt', 'approved'],
      where: `productId=${productId} AND approved=true`,
      pagination: { page: Math.floor(offset / limit) + 1, pageSize: limit },
      schema: 'review-schema-v1',
    })

    return {
      data: reviews,
      total: reviews.length,
      hasMore: reviews.length === limit,
    }
  },

  review: async (
    _root: unknown,
    args: { id: string },
    ctx: Context
  ) => {
    return ctx.clients.masterdata.getDocument<Review>({
      dataEntity: 'reviews',
      id: args.id,
      fields: ['id', 'productId', 'author', 'rating', 'title', 'text', 'createdAt', 'approved'],
    })
  },
}

export const mutations = {
  createReview: async (
    _root: unknown,
    args: { input: ReviewInput },
    ctx: Context
  ) => {
    const { input } = args

    const documentResponse = await ctx.clients.masterdata.createDocument({
      dataEntity: 'reviews',
      fields: {
        ...input,
        author: ctx.vtex.storeUserEmail ?? 'anonymous',
        approved: false,
        createdAt: new Date().toISOString(),
      },
      schema: 'review-schema-v1',
    })

    return ctx.clients.masterdata.getDocument<Review>({
      dataEntity: 'reviews',
      id: documentResponse.DocumentId,
      fields: ['id', 'productId', 'author', 'rating', 'title', 'text', 'createdAt', 'approved'],
    })
  },

  deleteReview: async (
    _root: unknown,
    args: { id: string },
    ctx: Context
  ) => {
    await ctx.clients.masterdata.deleteDocument({
      dataEntity: 'reviews',
      id: args.id,
    })

    return true
  },
}
将resolver接入Service:
typescript
// node/index.ts
import type { ParamsContext, RecorderState } from '@vtex/api'
import { Service } from '@vtex/api'

import { Clients } from './clients'
import { queries, mutations } from './resolvers/reviews'

export default new Service<Clients, RecorderState, ParamsContext>({
  clients: {
    implementation: Clients,
    options: {
      default: {
        retries: 2,
        timeout: 5000,
      },
    },
  },
  graphql: {
    resolvers: {
      Query: queries,
      Mutation: mutations,
    },
  },
})
链接后测试GraphQL API:
graphql
query GetReviews {
  reviews(productId: "12345", limit: 5) {
    data {
      id
      author
      rating
      title
      text
      createdAt
    }
    total
    hasMore
  }
}

mutation CreateReview {
  createReview(input: {
    productId: "12345"
    rating: 5
    title: "Excellent product"
    text: "Really happy with this purchase."
  }) {
    id
    author
    createdAt
  }
}

Common failure modes

常见失败模式

  • Defining resolvers without matching schema fields: The GraphQL runtime only exposes fields defined in the schema. Resolvers without matching fields are silently ignored. Conversely, schema fields without resolvers return
    null
    . Always define the schema first, then implement matching resolvers with identical names.
  • Querying external APIs directly in resolvers: Using
    fetch()
    or
    axios
    bypasses the
    @vtex/api
    client system, losing caching, retries, metrics, and authentication. Always use
    ctx.clients
    in resolvers.
  • Missing @auth on mutation endpoints: Without
    @auth
    , any anonymous user can call the mutation — a critical security vulnerability. Always add
    @auth
    to mutations and queries returning sensitive data.
  • Missing @cacheControl on public queries: Every request hits the resolver without caching, causing unnecessary load and slow responses. Add appropriate cache directives to all public Query fields.
  • 定义了resolver但没有匹配的schema字段:GraphQL运行时只暴露schema中定义的字段。没有匹配字段的resolver会被静默忽略。反之,没有resolver的schema字段会返回
    null
    。请始终先定义schema,再实现名称完全匹配的resolver。
  • 在resolver中直接调用外部API:使用
    fetch()
    axios
    会绕过
    @vtex/api
    客户端系统,丢失缓存、重试、指标和身份验证功能。在resolver中始终使用
    ctx.clients
  • Mutation端点缺少@auth:如果没有
    @auth
    ,任何匿名用户都可以调用该Mutation——这是严重的安全漏洞。请始终为Mutation和返回敏感数据的Query添加
    @auth
  • 公开Query缺少@cacheControl:每次请求都会触发resolver,没有缓存会导致不必要的负载和缓慢的响应。为所有公开Query字段添加合适的缓存指令。

Review checklist

审核清单

  • Is the
    graphql
    builder declared in
    manifest.json
    ?
  • Do all public Query fields have
    @cacheControl
    with appropriate scope and maxAge?
  • Do all Mutations and sensitive Queries have
    @auth
    ?
  • Do resolver function keys exactly match schema field names?
  • Are resolvers using
    ctx.clients
    for data access (no raw HTTP calls)?
  • Are directive declarations present in
    directives.graphql
    ?
  • Is the resolver wired into the Service entry point under
    graphql.resolvers
    ?
  • 是否在
    manifest.json
    中声明了
    graphql
    构建器?
  • 所有公开Query字段是否都带有
    @cacheControl
    ,并指定了合适的scope和maxAge?
  • 所有Mutation和敏感Query是否都带有
    @auth
  • Resolver函数键是否与schema字段名称完全匹配?
  • Resolver是否使用
    ctx.clients
    进行数据访问(没有原始HTTP调用)?
  • 指令声明是否存在于
    directives.graphql
    中?
  • Resolver是否在Service入口点的
    graphql.resolvers
    下完成接入?

Related skills

相关技能

  • vtex-io-service-apps
    — Service app fundamentals needed for all GraphQL resolvers
  • vtex-io-app-structure
    — Manifest and builder configuration that GraphQL depends on
  • vtex-io-masterdata
    — MasterData integration commonly used as a data source in resolvers
  • vtex-io-service-apps
    — 所有GraphQL resolver都需要的Service应用基础知识
  • vtex-io-app-structure
    — GraphQL依赖的清单与构建器配置
  • vtex-io-masterdata
    — 常用于resolver数据源的MasterData集成

Reference

参考资料