☰
Reaction API 的 api-utils 工具包:GraphQL 过滤、聚合分页与 URL 处理的四大核心函数实战指南
2026/10/14 2:44:03 网站建设 项目流程
  • 后端
  • 电商

【免费下载链接】reaction

Project has been discontinued ////// Mailchimp Open Commerce is an API-first, headless commerce platform built using Node.js, React, GraphQL. Deployed via Docker and Kubernetes.

项目地址:https://gitcode.com/gh_mirrors/re/reaction
点击查看免费下载

导读

@reactioncommerce/api-utils是 Mailchimp Open Commerce(Reaction)GraphQL API 的底层工具包,为各个 API 插件(products、catalogs、fulfillment、tags 等)提供开箱即用的通用函数。本文以 api-utils 官方文档 为主线,结合 源码实现 与真实插件调用链,系统讲解generateFilterQuery(GraphQL 条件转 MongoDB 查询)、getAbsoluteUrl(URL 拼接)、getPaginatedResponseFromAggregate(聚合管道分页)与tagsForCatalogProducts(产品标签映射)四个核心函数的输入格式、返回结构、边界行为与底层原理,帮助你在自定义插件或扩展 GraphQL resolver 时直接复用这些能力。

包概览与安装引入

api-utils的定位非常纯粹:为 Reaction API 提供一系列与业务无关的通用工具函数。它的包清单见 packages/api-utils/package.json,当前版本依赖simpl-schema、lodash、graphql-relay、accounting-js、envalid等库,自身不带任何 Meteor 运行时,是一个标准的 Node.js ES Module 包("type": "module",要求 Node >= 14.18.1)。

安装

npm install @reactioncommerce/api-utils

官方文档特别提醒:请确保使用 NPM 5+。该包以 ES Module(import)方式对外暴露,导入时按需引用具体函数文件即可:

import functionName from "@reactioncommerce/api-utils/functionName.js";

值得注意的是,包内不推荐从入口文件整体导入——packages/api-utils/index.js 会打印警告"Please import modules directly"。这是刻意的设计:通过 exports 字段 将./*映射到./lib/*、./graphql/映射到./lib/graphql/,从而允许按文件路径精确加载,配合"sideEffects": false可以让打包器更好地做 tree-shaking。

函数清单

完整函数索引见 docs 目录索引,包括 4 个重点讲解的实用函数,以及encodeOpaqueId/decodeOpaqueId(不透明 ID 编解码)、getSlug(字符串转 slug)、formatMoney(金额格式化)、hashToken(令牌哈希)、applyPaginationToMongoCursor等大量基础设施,均可按需直接导入。

generateFilterQuery:把 GraphQL 过滤条件编译成 MongoDB 查询

设计思路

generateFilterQuery解决的是一个非常典型的 GraphQL 服务端问题:客户端在查询参数里提交结构化的过滤条件(而不是原始 MongoDB 语法),服务端需要安全地把它们转换成合法的 MongoDB selector 再交给collection.find()。它期望的输入正是 GraphQL Schema 中定义的FilterConditionsInput类型,其完整定义见 api-plugin-products 的 Schema(products查询的conditions参数即使用此类型)。

FilterConditionsInput 输入格式

官方文档给出的类型定义如下,这是理解整个函数行为的基石:

input FilterConditionsInput { all: [ConditionsArray] any: [ConditionsArray] } input ConditionsArray { all: [SingleConditionInput] any: [SingleConditionInput] } input SingleConditionInput { booleanValue: Boolean caseSensitive: Boolean dateValue: DateTime floatArrayValue: [Float] floatValue: Float integerArrayValue: [Int] integerValue: Int key: String! logicalNot: Boolean relationalOperator: RelationalOperatorTypes! stringArrayValue: [String] stringValue: String } enum RelationalOperatorTypes{ beginsWith endsWith eq gt gte in lt lte ne nin regex }

结构上它有严格的三层嵌套:

  • 顶层:FilterConditionsInput只能有all或any两个键,值是ConditionsArray数组;
  • 中间层:每个ConditionsArray元素再包含一层all或any,值是SingleConditionInput数组;
  • 原子层:SingleConditionInput描述单个过滤条件,其中key(要过滤的字段名)与relationalOperator(关系运算符枚举)是必填项,并且必须在 8 个值字段(stringValue、integerValue、floatValue、booleanValue、dateValue、stringArrayValue、integerArrayValue、floatArrayValue)中恰好提供其中一个,外加两个可选布尔开关caseSensitive(regex 是否区分大小写)与logicalNot(是否取反)。

语义映射规则非常直观:all等价于 MongoDB 的$and,any等价于$or。这一映射在源码中由keyMap显式定义:

const keyMap = { all: "$and", any: "$or" };

官方示例:单条件查询

import generateFilterQuery from "@reactioncommerce/api-utils/generateFilterQuery.js"; const conditions = { all: [ { any: [ { key: "handle", stringValue: "mens-waterproof-outdoor-rain-jacket", relationalOperator: eq, logicalNot: false } ] } ] }; const { filterQuery } = generateFilterQuery(context, "Product", conditions, shopId); return Products.find(filterQuery);

因为只有单个条件,最外层用all还是any结果没有区别。调用签名是generateFilterQuery(context, collectionName, conditions, shopId):context携带 per-request 状态(尤其是context.simpleSchemas里的集合 Schema 与context.collections),collectionName是集合名(如"Product"),shopId会作为额外的等值条件注入最终 selector(见 generateQuery 实现)。

完整校验链:三层防御

从 generateFilterQuery.js 的实现看,函数在真正生成查询前会做三层校验,这也是把它直接暴露给客户端输入仍然安全的原因:

  1. 结构校验:用ConditionsArraySchema.validate(conditions)(基于 simpl-schema 定义,见 源码 L81-L96)检查嵌套结构、字段类型与枚举值合法性;
  2. 字段元数据校验:通过 collectCollectionFields 读取context.simpleSchemas[collectionName]合并后的 Schema,收集每个字段的类型(如SimpleSchema.String、SimpleSchema.Number),再调用validateConditions校验:
    • key必须存在于集合字段中,否则抛出Invalid key: ${key};
    • 值字段必须与字段类型匹配(String 字段必须给stringValue/stringArrayValue,Boolean 字段必须给booleanValue等);
    • 8 个值字段中只能提供一个值,多给抛Only one value must be provided...;
    • 关系运算符必须与字段类型兼容,具体矩阵定义在validCombos(见 源码 L98-L123):字符串支持eq/ne/in/nin/regex/beginsWith/endsWith,数字与整数支持eq/ne/gt/gte/lt/lte/in/nin,数组支持in/nin/eq/ne,布尔支持eq/ne,日期支持eq/ne/gt/gte/lt/lte;
    • 数组值不能为空(Array value cannot be empty)。
  3. 运算符映射:simpleConditionToQuery把枚举运算符翻译成 MongoDB 查询运算符。其中regex、beginsWith、endsWith会编译为$regex(beginsWith加^前缀、endsWith加$后缀),并且默认不区分大小写($options: "i",只有显式caseSensitive: true时才置空);logicalNot会把整个子查询包进$not(见 源码 L343-L414)。

进阶示例:多条件组合

当需要同时满足多组条件时,顶层all($and)与内层any($or)组合出完整的布尔表达式。下面的条件等价于:"handle 精确匹配 或 title 以 men 开头"且"_id属于两个 ID 之一、isDeleted为 false、workflow.status为 new"且"price.min>= 19.99 且 type 为 simple":

import generateFilterQuery from "@reactioncommerce/api-utils/generateFilterQuery.js"; const conditions = { all: [ { any: [ { key: "handle", stringValue: "mens-waterproof-outdoor-rain-jacket", relationalOperator: eq, logicalNot: false }, { key: "title", stringValue: "men", relationalOperator: beginsWith, logicalNot: false, caseSensitive: false } ] }, { all: [ { key: "_id", stringArrayValue: ["DZwLHk4EAzitRni8F", "Hn4BRaBvLkYffMq36"], relationalOperator: in, logicalNot: false }, { key: "isDeleted", booleanValue: false, relationalOperator: eq, logicalNot: false }, { key: "workflow.status", stringValue: "new", relationalOperator: eq, logicalNot: false } ] }, { all: [ { key: "price.min", floatValue: 19.99, relationalOperator: gte, logicalNot: false }, { key: "type", stringValue: "simple", relationalOperator: eq, logicalNot: false } ] } ] }; const { filterQuery } = generateFilterQuery(context, "Product", conditions, shopId); return Products.find(filterQuery);

最终生成的 MongoDB selector 形态(与 generateFilterQuery.test.js 中断言的一致):

{ $and: [ { $or: [ { handle: { $eq: "mens-waterproof-outdoor-rain-jacket" } }, { title: { $regex: "^men", $options: "i" } } ] }, { $and: [ { _id: { $in: ["DZwLHk4EAzitRni8F", "Hn4BRaBvLkYffMq36"] } }, { isDeleted: { $eq: false } }, { "workflow.status": { $eq: "new" } } ] }, { $and: [ { "price.min": { $gte: 19.99 } }, { type: { $eq: "simple" } } ] } ], shopId: "SHOP123" }

在真实插件中的用法

generateFilterQuery是通用的——任何定义了 simple-schema 的集合都能用。以 api-plugin-products 的 filterProducts.js 为例,它的完整调用链是:GraphQL resolver →filterProducts(context, conditions, shopId)→ 先做权限校验validatePermissions("reaction:legacy:products", "read", ...)→generateFilterQuery(context, "Product", conditions, shopId)→Products.find(filterQuery)。products 插件 README 中还有针对单条件、双条件、多条件的完整图解,见 api-plugin-products/README.md。

getAbsoluteUrl:安全拼接根地址与相对路径

函数行为

getAbsoluteUrl(rootUrl, path)接收一个根 URL 和一个相对 URL,把它们合并成完整地址。它的两个关键容错设计在官方文档中写得非常清楚:

  • 传入的path可以是undefined——此时只返回根 URL 本身;
  • 传入的path也可以是已经完整的 URL——此时原样返回,不做任何拼接。

官方示例:

const rootUrl = "http://localhost:3000/"; const path = "/media/test.jpg"; const url = getAbsoluteUrl(rootUrl, path); // http://localhost:3000/media/test.jpg

源码与边界行为

从 getAbsoluteUrl.js 的实现可以看出,它用截取path前 8 个字符并转小写的方式判断是否为绝对地址:

const pathStart = path.slice(0, 8).toLowerCase(); if (pathStart.startsWith("https://") || pathStart.startsWith("http://") || pathStart.startsWith("//")) { return path; }

也就是说,以下三类输入会被视为"已经是绝对地址"而直接返回:https://开头、http://开头、以及//开头的协议相对地址。其余情况会剥掉path最前面的/,然后直接追加到rootUrl后面——这意味着rootUrl末尾是否有/都不影响结果。测试用例(getAbsoluteUrl.test.js)覆盖了空 path、带前导斜杠、已含 rootUrl、第三方https://服务、协议相对 URL,以及一个有趣的特例:path 为"http.jpg"时(前 8 个字符为http.jpg,不含://)不会误判为绝对地址,而是正确拼接成http://localhost:3000/http.jpg。

在插件中的真实应用

getAbsoluteUrl被广泛用于把数据库里存储的相对媒体路径转换成对外可访问的完整 URL,例如:

  • api-plugin-tags 的 heroMediaUrl resolver:先判断url是否以http开头,不是则调用context.getAbsoluteUrl(url)补全;
  • api-plugin-shops 的 brandAssets、api-plugin-orders 的 imageURLs 等也都在做同样的处理。

它通常作为context上的方法注入(见 api-core 的 ReactionAPICore.js),这样各插件可以无感知地使用同一个根地址配置。

getPaginatedResponseFromAggregate:聚合管道的 Relay 风格分页

函数定位

在 Reaction API 中,返回多个文档的 resolver 需要以 Relay Connection 的形态输出结果。当数据源是聚合管道(而不是简单查询)时,getPaginatedResponseFromAggregate(collection, pipeline, args, options)负责往管道里追加排序、分页(skip/limit)与统计相关的 stage,并返回标准化结果。

参数与返回结构

官方文档给出如下用法:

import getPaginatedResponseFromAggregate from "@reactioncommerce/api-utils/getPaginatedResponseFromAggregate.js"; // the MongoDB collection you want to run your aggregation on const collection = getYourMongoDBCollectionSomehow(); const pipeline = [ { // for example, here's a $lookup operator $lookup: { // some options } }, // but you can add, // any, // other, // operators ]; const args = { // connection arguments from your GraphQL query or mutation // supported: // offset: Int, // first: Int, // last: Int, // sortBy: String (the name of the MongoDB field you want to sort results by) // sortOrder: String ("desc" for descending, or "asc" for ascending) }; const options = { // supported: // includeHasNextPage: Boolean, // includeHasPreviousPage: Boolean, // includeTotalCount: Boolean }; const results = await getPaginatedResponseFromAggregate(collection, pipeline, args, options);

返回对象结构:

{ pipeline: Array, // 传入的管道 + 函数追加的修改($sort、$limit、$skip、$group 等) nodes: Array, // 返回的条目 pageInfo: { hasNextPage: Boolean, // 仅在 options 请求时返回 hasPreviousPage: Boolean, // 仅在 options 请求时返回 startCursor: String, // nodes 中第一个条目的 _id endCursor: String // nodes 中最后一个条目的 _id }, totalCount: Int // 仅在 options 请求时返回 }

源码级原理

从 getPaginatedResponseFromAggregate.js 的实现可以看到几个关键设计:

  • after/before不受支持:传入这两个连接参数会直接抛错Connection args 'after' and 'before' aren't supported by getPaginatedResponseFromAggregate(测试见 getPaginatedResponseFromAggregate.test.js)。聚合分页只支持offset与first/last两种模式;
  • 默认分页大小 20:无论first、last还是offset模式,未显式给出first时都会应用DEFAULT_LIMIT = 20(见 applyPaginationToMongoAggregate.js 与 applyOffsetPaginationToMongoAggregate.js);
  • first与last互斥:同时传入会抛Request either 'first' or 'last' but not both;offset与last也互斥;
  • totalCount 的巧妙计算:由于聚合管道没有.count(),实现会先追加$group($addToSet: "$$ROOT"+$sum: 1)把全部结果收进单文档并计数,再$unwind还原、$set注入totalCount字段、$replaceRoot恢复根文档——整个过程见 源码 L40-L80。因此当includeTotalCount为false时可以省掉这批 stage;
  • 排序:通过 getMongoSort.js 把sortOrder: "asc"/"desc"映射为1/-1,且总是附带_id作为次级排序键以保证次序稳定;
  • cursor 模式对应物:如果数据源是普通查询而不是聚合,应改用 getPaginatedResponse(基于 Mongo Cursor,支持after/before)。

在插件中的真实应用

以 api-plugin-catalogs 的 vendors 查询 为例,可以看到完整的 resolver 集成模式:先由 queries/vendors.js 构造一个按product.vendor做$group的管道,然后在 resolver 里调用:

return getPaginatedResponseFromAggregate(collection, pipeline, { ...connectionArgs, sortBy: "name" }, { includeHasNextPage: wasFieldRequested("pageInfo.hasNextPage", info), includeHasPreviousPage: wasFieldRequested("pageInfo.hasPreviousPage", info), includeTotalCount: wasFieldRequested("totalCount", info) });

wasFieldRequested(同样来自 api-utils)让函数只在客户端真正请求了某个字段时才去计算它,从而避免多余的数据库命令——这是该包在性能细节上的典型设计。类似用法还出现在 fulfillment 插件的 fulfillmentMethods resolver 和 flatRateFulfillmentMethods resolver 中。

tagsForCatalogProducts:把 CatalogProduct 的 tagIds 映射为标签名

函数功能

tagsForCatalogProducts(Tags, catalogProducts)接收一个Tags集合实例与一组 Catalog 集合中的CatalogItemProduct对象,返回每个产品对应的标签名数组。

官方示例数据集:

// Tags [ { _id: "1", name: "ONE" }, { _id: "2", name: "TWO" }, { _id: "3", name: "THREE" } ] // CatalogProducts [ { product: { productId: "1", tagIds: ["1", "2"] } }, { product: { productId: "2", tagIds: ["3"] } } ]

用法与返回结果:

// Tags 是 "Tags" Mongo.Collection // `catalogProducts` 是 Catalog 集合中 CatalogItemProduct 的数组 const result = await tagsForCatalogProducts(Tags, catalogProducts); // 返回 [ { productId: "1", tags: ["ONE", "TWO"] }, { productId: "2", tags: ["THREE"] } ]

实现细节

从 tagsForCatalogProducts.js 的实现看,它先遍历catalogProducts收集所有product.tagIds去重成一个数组,然后一次Tags.find({ _id: { $in: tagIds } }).toArray()批量取回标签文档,最后再遍历产品、按tagIds的顺序把_id替换成name。因此:

  • 若传入的catalogProducts为空数组,直接返回[](不做任何数据库查询,测试覆盖于 tagsForCatalogProducts.test.js);
  • 某个tagIds中的 ID 在 Tags 集合中找不到时,对应位置返回null,保持数组顺序与tagIds一致;
  • 只进行一次查询,复杂度与产品数量线性相关,适合在批量场景下避免 N+1 查询。

在插件中的真实应用

该函数被多个插件在扩展公共订单上下文时复用,例如 api-plugin-fulfillment 的 extendCommonOrder.js、api-plugin-shipments 的 extendCommonOrder.js 与 api-plugin-surcharges 的 extendCommonOrder.js——它们在把订单数据传给 fulfillment/shipment/surcharge 提供方之前,先通过tagsForCatalogProducts把产品 ID 与标签名配对,供下游业务逻辑使用。

测试保障与发布机制

每个核心函数都配有完整的 Jest 测试:

  • generateFilterQuery.test.js:用 mock 的collectCollectionFields模拟 Product 集合字段表,逐条断言单条件、双条件、多条件生成的 selector 与期望逐字相等(toStrictEqual);
  • getAbsoluteUrl.test.js:覆盖空 path、带斜杠、已是绝对地址、第三方服务、协议相对地址等边界;
  • getPaginatedResponseFromAggregate.test.js:验证after/before抛错、管道追加的$group/$unwind/$set/$replaceRoot/$sort/$limitstage、空结果时totalCount为 0 等行为;
  • tagsForCatalogProducts.test.js:验证标签映射与空数组短路。

测试框架配置在 packages/api-utils/jest.config.cjs,package.json的 scripts 提供了npm test与npm run test:watch。

发布机制方面,官方文档说明:该 NPM 包会在每次 push 到trunk分支时自动发布,因此要求开发者遵循规范的 Git commit message 格式(semantic-release 风格),以便版本号自动递增并自动生成 release notes。

总结

api-utils的价值在于把"GraphQL 与 MongoDB 之间的翻译层"沉淀成可复用的纯函数:generateFilterQuery负责安全地把声明式条件编译为 MongoDB selector 并附带完整校验;getPaginatedResponseFromAggregate负责给聚合管道注入排序与分页并输出 Relay Connection 标准结构;getAbsoluteUrl与tagsForCatalogProducts则解决 URL 归一化和关联数据批量映射两个高频小问题。理解它们的输入契约与实现细节后,你在扩展 Reaction 插件、编写自定义 resolver 时就可以直接引用对应函数,保持与官方插件一致的行为与性能特征。更完整的函数索引与用法可继续查阅 packages/api-utils/docs。

  • 后端
  • 电商

【免费下载链接】reaction

Project has been discontinued ////// Mailchimp Open Commerce is an API-first, headless commerce platform built using Node.js, React, GraphQL. Deployed via Docker and Kubernetes.

项目地址:https://gitcode.com/gh_mirrors/re/reaction
点击查看免费下载

相关推荐

上一篇:detectron2(Vim/det)训练指南:自定义训练循环、Trainer 抽象与指标日志全解析
下一篇:告别焦虑等待:3分钟掌握Elsevier期刊审稿状态自动追踪神器

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询