← 中文资源

简体中文版本

用 SQLite 构建通讯录 REST API

以 SQLite 为存储的通讯录 REST API:写入校验、大小写不敏感搜索、分页,以及首次启动自动执行的迁移。

阅读英文原文

项目目标

本项目用 SQLite 构建一个通讯录 REST API,验证、持久化、搜索、分页与一致的错误响应一应俱全。SQLite 让持久化保持真实,又无需读者运行数据库服务;启动时自动执行迁移,重启即可完成升级。

核心特性

  • 联系人的增删改查与服务端校验
  • 对姓名与邮箱做大小写不敏感的子串搜索
  • limit 与 offset 分页并返回总数
  • 首次启动自动执行有序迁移
  • 统一的 JSON 错误信封与状态码

架构与分层

HTTP 层把路由映射到处理器;仓储封装全部 SQL,处理器从不拼查询;校验模块规范化并检查输入;迁移运行器按有序语句执行并在迁移表中记账。连接只打开一次并共享。

实施阶段

六个阶段为:数据库连接、迁移、联系人仓储、校验、路由与错误信封、服务接线与测试。启动幂等是贯穿始终的约束。

测试与验收

用 vitest 运行。仓储测试打开内存库执行迁移并断言增删改查与搜索;API 测试在临时端口启动服务,逐个路由覆盖正常与错误路径,断言状态码与响应体。

延伸方向

新增字段是一次迁移加一条校验规则,新增端点是一条路由加一个仓储方法。若将来出现第二个写入进程,SQLite 文件就是共享点,写串行化应移入服务端队列。

本译文由 AI 依据英文原文完整重写,覆盖目标、特性、架构、分阶段实现、测试与扩展方向,供中文读者对照英文原稿阅读。

代码示例

import { createServer } from 'node:http';
import { routes } from './routes.js';

export function createApi({ repo }) {
  return createServer(async (req, res) => {
    const url = new URL(req.url, 'http://localhost');
    const route = routes.find(
      (r) => r.method === req.method && r.pattern.test(url.pathname),
    );
    if (!route) return send(res, 404, { error: 'not found' });
    const params = url.pathname.match(route.pattern).slice(1);
    const body = await readJson(req);
    const result = await route.handler({ repo, params, query: url.searchParams, body });
    return send(res, result.status ?? 200, result.body);
  });
}
mkdir contact-api && cd contact-api
npm init -y
npm install -D vitest@^2.1.0
{
  "name": "contact-api",
  "type": "module",
  "scripts": {
    "start": "node src/server.js",
    "test": "vitest run"
  }
}
import { DatabaseSync } from 'node:sqlite';

export function openDatabase(path = ':memory:') {
  const db = new DatabaseSync(path);
  db.exec('PRAGMA foreign_keys = ON;');
  return {
    run: (sql, params = []) => db.prepare(sql).run(...params),
    all: (sql, params = []) => db.prepare(sql).all(...params),
    get: (sql, params = []) => db.prepare(sql).get(...params),
    close: () => db.close(),
  };
}
const MIGRATIONS = [
  {
    id: 1,
    up: `CREATE TABLE contacts (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      name TEXT NOT NULL,
      email TEXT NOT NULL UNIQUE,
      phone TEXT,
      created_at TEXT NOT NULL
    );`,
  },
];

export function migrate(db) {
  db.run('CREATE TABLE IF NOT EXISTS migrations (id INTEGER PRIMARY KEY, applied_at TEXT)');
  const applied = new Set(db.all('SELECT id FROM migrations').map((r) => r.id));
  for (const m of MIGRATIONS) {
    if (applied.has(m.id)) continue;
    db.run(m.up);
    db.run('INSERT INTO migrations (id, applied_at) VALUES (?, ?)', [m.id, new Date().toISOString()]);
  }
  return MIGRATIONS.length;
}
export function createContactsRepo(db) {
  return {
    create(c) {
      const info = db.run(
        'INSERT INTO contacts (name, email, phone, created_at) VALUES (?, ?, ?, ?)',
        [c.name, c.email, c.phone ?? null, new Date().toISOString()],
      );
      return this.find(info.lastInsertRowid);
    },
    find(id) {
      return db.get('SELECT * FROM contacts WHERE id = ?', [id]) ?? null;
    },
    search(q, { limit = 20, offset = 0 } = {}) {
      const like = '%' + q.toLowerCase() + '%';
      return db.all(
        'SELECT * FROM contacts WHERE lower(name) LIKE ? OR lower(email) LIKE ? ORDER BY id LIMIT ? OFFSET ?',
        [like, like, limit, offset],
      );
    },
    remove(id) {
      return db.run('DELETE FROM contacts WHERE id = ?', [id]).changes;
    },
  };
}
const EMAIL = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;

export function validateContact(input) {
  const errors = {};
  const name = typeof input.name === 'string' ? input.name.trim() : '';
  const email = typeof input.email === 'string' ? input.email.trim().toLowerCase() : '';
  const phone = typeof input.phone === 'string' ? input.phone.trim() : undefined;
  if (name.length < 2) errors.name = 'name must be at least 2 characters';
  if (!EMAIL.test(email)) errors.email = 'email is not valid';
  if (Object.keys(errors).length) return { ok: false, errors };
  return { ok: true, value: { name, email, phone } };
}
import { validateContact } from './validation.js';

export const routes = [
  {
    method: 'POST',
    pattern: /^\/contacts$/,
    async handler({ repo, body }) {
      const result = validateContact(body ?? {});
      if (!result.ok) return { status: 422, body: { error: 'validation_failed', fields: result.errors } };
      try {
        return { status: 201, body: await repo.create(result.value) };
      } catch (err) {
        if (String(err.message).includes('UNIQUE')) {
          return { status: 409, body: { error: 'email_exists' } };
        }
        throw err;
      }
    },
  },
  {
    method: 'GET',
    pattern: /^\/contacts$/,
    async handler({ repo, query }) {
      const q = query.get('q') ?? '';
      const limit = Math.min(100, Number(query.get('limit') ?? 20));
      const offset = Number(query.get('offset') ?? 0);
      return { status: 200, body: { items: repo.search(q, { limit, offset }) } };
    },
  },
];
import { openDatabase } from './db.js';
import { migrate } from './migrations.js';
import { createContactsRepo } from './contacts.repo.js';
import { createApi } from './api.js';

const db = openDatabase(process.env.DB_PATH ?? './contacts.db');
migrate(db);
const repo = createContactsRepo(db);
const server = createApi({ repo });

server.listen(process.env.PORT ?? 3000, () => {
  console.log('listening on ' + (process.env.PORT ?? 3000));
});

export { server };