site logo

Marico's space

构建类型安全的 Product Catalog API

前端技术 2026-04-27 11:37:55 6

如果你直接用过 Azure Cosmos DB,就会知道那种痛苦:手写原始 SQL 查询,管理参数化输入,数据入库前没有任何验证。

对比一下查询 100 美元以下电子产品的两种方式:

原始 @azure/cosmos SDK,要写一个 query 对象,parameters 数组手动拼参,一不小心就 SQL 注入。而用 Cosmoose,直接 Product.find({ category: 'electronics', price: { $gte: 10, $lte: 100 } }),链式调用,类型安全,底层自动生成参数化 SQL。

这就是 Cosmoose 解决的问题——为 Cosmos DB 打造的类型安全 ODM,用过 MongoDB 的 Mongoose 的话,上手非常快。

项目初始化

先建项目,装依赖:

mkdir cosmoose-product-catalog && cd cosmoose-product-catalog
pnpm init
pnpm add @cosmoose/core express dotenv
pnpm add -D typescript tsx @types/express @types/node

创建 .env 存放 Cosmos DB 凭据:

COSMOS_ENDPOINT=https://your-account.documents.azure.com:443/
COSMOS_KEY=your-primary-key-here
COSMOS_DATABASE=product-catalog

连接数据库

创建 src/db.ts,初始化 Cosmoose 并注册模型:

import { Cosmoose } from '@cosmoose/core';
import { productSchema, type Product } from './schemas/product.js';

export const cosmoose = new Cosmoose({
  endpoint: process.env.COSMOS_ENDPOINT!,
  key: process.env.COSMOS_KEY!,
  databaseName: process.env.COSMOS_DATABASE || 'product-catalog',
});

export let Product: ReturnType<typeof cosmoose.model<Product>>;

export async function connectDB() {
  await cosmoose.connect();
  Product = cosmoose.model<Product>('products', productSchema);
}

如果数据库不存在会自动创建。model() 注册 Product 模型,绑定到 Cosmos DB 的 products 容器。

定义 Product Schema

这是 Cosmoose 真正发挥优势的地方:

interface Specs {
  weight: number;
  dimensions: string;
  color?: string;
}

export interface Product {
  name: string;
  sku: string;
  description?: string;
  price: number;
  stock: number;
  category: string;
  tags: string[];
  status: string;
  specs: Specs;
}

const specsSchema = new Schema<Specs>({
  weight: { type: Type.NUMBER },
  dimensions: { type: Type.STRING },
  color: { type: Type.STRING, optional: true },
});

export const productSchema = new Schema<Product>(
  {
    name: { type: Type.STRING, trim: true },
    sku: { type: Type.STRING, uppercase: true },
    description: { type: Type.STRING, optional: true },
    price: { type: Type.NUMBER },
    stock: { type: Type.NUMBER },
    category: { type: Type.STRING },
    tags: { type: Type.ARRAY, items: { type: Type.STRING } },
    status: { type: Type.STRING, default: 'draft' },
    specs: { type: Type.OBJECT, schema: specsSchema },
  },
  {
    timestamps: true,
    container: { partitionKey: '/category' },
  },
);

这个 Schema 包含了不少东西,逐一解析:

  • trim: true:自动去除首尾空白。" Wireless Mouse " 变成 "Wireless Mouse"
  • uppercase: true:转大写。"wm-001" 变成 "WM-001"
  • optional: true:字段不是必需的
  • default: 'draft':未提供时使用默认值
  • 嵌套 specs 对象:独立 Schema 做类型安全嵌套验证
  • timestamps: true:自动管理 createdAtupdatedAt
  • partitionKey: '/category':按 category 分区

所有验证在数据进入 Cosmos DB 之前完成,底层由 Zod 提供支持。验证失败时抛出 SchemaValidationFailedException

CRUD 路由

创建产品

productsRouter.post('/', async (req, res) => {
  try {
    const product = await Product.create(req.body);
    res.status(201).json(product);
  } catch (err) {
    if (err instanceof SchemaValidationFailedException) {
      res.status(400).json({ error: 'Validation failed', details: err.errors });
      return;
    }
    throw err;
  }
});

Product.create() 会根据 Schema 验证输入、自动生成 UUID v7 id、设置时间戳,并应用 transforms——所有在插入 Cosmos DB 之前完成。

列表查询(带 Query Builder)

productsRouter.get('/', async (req, res) => {
  const { category, status, minPrice, maxPrice, sort, order, limit, offset } = req.query;
  const filter: Record<string, unknown> = {};
  if (category) filter.category = category;
  if (status) filter.status = status;
  if (minPrice || maxPrice) {
    filter.price = {};
    if (minPrice) (filter.price as Record<string, number>).$gte = Number(minPrice);
    if (maxPrice) (filter.price as Record<string, number>).$lte = Number(maxPrice);
  }
  let query = Product.find(filter);
  if (sort) query = query.sort({ [sort as string]: order === 'desc' ? -1 : 1 });
  if (limit) query = query.limit(Number(limit));
  if (offset) query = query.offset(Number(offset));
  const products = await query;
  res.json(products);
});

链式 API 允许在 await 之前调用 .sort().limit().offset()。Cosmoose 在底层生成参数化 SQL——没有 SQL 注入风险,没有字符串拼接。

按 ID 查询

productsRouter.get('/:id', async (req, res) => {
  const product = await Product.findOne({ id: req.params.id });
  if (!product) return res.status(404).json({ error: 'Product not found' });
  res.json(product);
});

部分更新($set)

productsRouter.patch('/:id', async (req, res) => {
  const { category } = req.query;
  const updated = await Product.patchById(
    req.params.id,
    { $set: req.body },
    { partitionKeyValue: category as string },
  );
  if (!updated) return res.status(404).json({ error: 'Product not found' });
  res.json(updated);
});

$set 只更新指定字段,其他不变。不需要发送完整文档。

库存递增($incr)

productsRouter.post('/:id/restock', async (req, res) => {
  const { category } = req.query;
  const { quantity } = req.body;
  const updated = await Product.patchById(
    req.params.id,
    { $incr: { stock: quantity } },
    { partitionKeyValue: category as string },
  );
  if (!updated) return res.status(404).json({ error: 'Product not found' });
  res.json(updated);
});

$incr 原子性地递增数值字段。无需先读取再写回,避免了并发场景下的竞态条件。

总结

Cosmoose 的核心价值在于:把 Cosmos DB 从「手写 SQL 的原始时代」拉回到「类型安全的现代 ORM 体验」。Schema 定义 + 自动验证 + 链式查询构建器,对于已经在用 MongoDB/Mongoose 的团队来说迁移成本很低。

如果你正在用 Cosmos DB 而不是在忍 Cosmos DB,Cosmoose 值得一试。

原文:Build a Type-Safe Product Catalog API with Express, Cosmoose, and Azure Cosmos DB