在15分钟内建立以数据库为中心的安全开放
#database #api #openapi #prisma

如果您是熟悉Restful API的开发人员,则可能已经听说过OpenAPI。它是描述人类和机器可读格式的静态API的规范。建立面向公共的OpenAPI包括三个任务:

  1. 撰写OpenAPI规范,该规范是API提供商与API消费者之间的合同。
  2. 基于规范实现API端点。
  3. 可选,实施客户SDK用于消费API。

在这篇文章中,您将看到如何完成所有这些任务并在15分钟内建立安全和记录的以数据库为中心的OpenAPI服务。

您可以找到完成的项目here

设想

我将以简单的宠物商店API为例,以方便更容易理解。 API将具有以下资源:

  • 用户:谁可以注册,登录和订购宠物。
  • 宠物:可以由用户列出和订购。
  • 订单:由用户创建并包含宠物列表。

业务规则:

  1. 匿名用户可以注册并登录。
  2. 匿名用户可以列出未售的宠物。
  3. 身份验证的用户可以列出未售出的宠物和宠物。
  4. 身份验证的用户可以为未售出的宠物创建订单。
  5. 身份验证的用户可以查看其订单。

建立它

我们将使用express.js作为构建服务的框架。但是,也可以使用其他框架,例如Fastify,并且一般过程相似。

1.创建项目

让我们首先创建一个带有打字稿的新Express.js项目。

mkdir express-petstore
cd express-petstore
npm init -y
npm install express
npm install -D typescript tsx @types/node @types/express
npx tsc --init

使用以下内容创建服务入口点代码app.ts

// app.ts
import express from 'express';

const app = express();

// enable JSON body parser
app.use(express.json());

app.get('/', (req, res) => {
    res.send('Hello World!');
});

app.listen(3000, () => console.log('🚀 Server ready at: http://localhost:3000'));

启动服务器:

npx tsx watch app.ts

现在在新的外壳窗口中,点击服务端点并验证其有效:

curl localhost:3000

你好世界!

2.建模数据

数据建模是构建以资源为中心的API的最关键部分。在本指南中,我们将使用PrismaZenStack对数据库进行建模。 Prisma是一种提供声明性数据建模经验的工具包,Zenstack是Prisma的电源包,可提供诸如访问控制,规格生成,自动服务生成以及许多其他改进等增强功能。

让我们首先初始化我们的数据建模项目:

npm install -D prisma
npm install @prisma/client
npx zenstack@latest init

zenstack CLI安装Prisma和其他依赖关系,并创建一个样板schema.zmodel文件。用以下内容更新以反映我们的要求:

// schema.zmodel
datasource db {
    provider = 'sqlite'
    url = 'file:./petstore.db'
}

generator client {
    provider = "prisma-client-js"
}

model User {
    id String @id @default(cuid())
    email String @unique
    password String
    orders Order[]
}

model Pet {
    id String @id @default(cuid())
    createdAt DateTime @default(now())
    updatedAt DateTime @updatedAt
    name String
    category String
    order Order? @relation(fields: [orderId], references: [id])
    orderId String?
}

model Order {
    id String @id @default(cuid())
    createdAt DateTime @default(now())
    updatedAt DateTime @updatedAt
    pets Pet[]
    user User @relation(fields: [userId], references: [id])
    userId String
}

运行以下命令以生成Prisma模式并将其推入数据库:

npx zenstack generate
npx prisma db push

另外,创建一个用一些数据填充数据库的prisma/seed.ts文件。然后,当您重置本地数据库时,可以重新运行脚本以填写数据。

// prisma/seed.ts
import { PrismaClient, Prisma } from '@prisma/client';

const prisma = new PrismaClient();

const petData: Prisma.PetCreateInput[] = [
    {
        id: 'luna',
        name: 'Luna',
        category: 'kitten',
    },
    {
        id: 'max',
        name: 'Max',
        category: 'doggie',
    },
    {
        id: 'cooper',
        name: 'Cooper',
        category: 'reptile',
    },
];

async function main() {
    console.log(`Start seeding ...`);
    for (const p of petData) {
        const pet = await prisma.pet.create({
            data: p,
        });
        console.log(`Created Pet with id: ${pet.id}`);
    }
    console.log(`Seeding finished.`);
}

main()
    .then(async () => {
        await prisma.$disconnect();
    })
    .catch(async (e) => {
        console.error(e);
        await prisma.$disconnect();
        process.exit(1);
    });

运行脚本以播种我们的数据库:

npx tsx prisma/seed.ts

3.实施API

zenstack通过提供内置的恢复实现,大大简化了以数据库为中心的API的开发。您可以使用特定于框架的适配器将RESTFUL服务安装到应用程序中。让我们看看如何使用express.js。

npm install @zenstackhq/server

与Express.js的集成是由ZenStackMiddleware中间件工厂实现的。使用它将静止的API安装在您选择的路径上。 getPrisma回调用于获取当前请求的Prisma客户端实例。目前,我们将返回全球Prisma客户端。

// app.ts
import { PrismaClient } from '@prisma/client';
import { ZenStackMiddleware } from '@zenstackhq/server/express';
import express from 'express';

const app = express();
app.use(express.json());

const prisma = new PrismaClient();
app.use('/api', ZenStackMiddleware({ getPrisma: () => prisma }));

app.listen(3000, () => console.log('🚀 Server ready at: http://localhost:3000'));

使用了这几行代码,您就可以为所有资源运行crud apis -UserPetOrder。通过获取所有宠物来测试它:

curl localhost:3000/api/pet/findMany
[
    {
        "id": "luna",
        "createdAt": "2023-03-18T08:09:41.417Z",
        "updatedAt": "2023-03-18T08:09:41.417Z",
        "name": "Luna",
        "category": "kitten"
    },
    {
        "id": "max",
        "createdAt": "2023-03-18T08:09:41.419Z",
        "updatedAt": "2023-03-18T08:09:41.419Z",
        "name": "Max",
        "category": "doggie"
    },
    {
        "id": "cooper",
        "createdAt": "2023-03-18T08:09:41.420Z",
        "updatedAt": "2023-03-18T08:09:41.420Z",
        "name": "Cooper",
        "category": "reptile"
    }
]

容易,不是吗?自动生成的API具有1:1映射到Prisma Client方法-findManyfindUniquecreateupdateupdateaggregate等。它们也具有与输入参数和响应的Prismaclient相同的结构。对于POSTPUT请求,输入ARGS直接作为请求主体(应用程序/JSON)发送。对于GETDELETE请求,输入args被序列化并发送为q查询参数(url编码)。例如,您可以通过:
获得过滤的宠物列表

curl 'http://localhost:3000/api/pet/findMany?q=%7B%22where%22%3A%7B%22category%22%3A%22doggie%22%7D%7D'

url来自:http://localhost:3000/api/pet/findMany?q={"where":{"category":"doggie"}}

[
    {
        "id": "max",
        "createdAt": "2023-03-18T08:09:41.419Z",
        "updatedAt": "2023-03-18T08:09:41.419Z",
        "name": "Max",
        "category": "doggie"
    }
]

我们的API正在启动并运行,但它有一个大问题:任何安全措施都不谨防它。任何人都可以阅读和更新任何数据。让我们在以下各节中以两个步骤进行修复:身份验证和授权。

4.添加身份验证

对于此简单服务,我们将采用基于电子邮件/密码的身份验证并为每个成功登录发行JWT令牌。

让我们首先看一下注册部分。由于User资源已经具有CRUD API,因此我们不需要实现单独的API进行注册,因为注册只是创建User。我们唯一需要处理的是确保我们存储Hashed密码而不是纯文本。实现这一点很简单;只需在password字段中添加@password属性即可。 Zenstack将在将其存储在数据库中之前自动放置该字段。请注意,我们还将@omit属性添加到标记password字段中要从响应中删除,因为我们不希望它返回客户端。

// schema.prisma
model User {
    id String @id @default(cuid())
    email String @unique
    password String @password @omit
    orders Order[]
}

登录需要验证凭据,我们需要手动实施它。安装几个新依赖性:

npm install bcryptjs jsonwebtoken dotenv
npm install -D @types/jsonwebtoken

在根下创建一个.env文件,然后将JWT_SECRET环境变量放在其中。您应该始终在生产中使用强大的秘密。

JWT_SECRET=abc123

添加/api/login路线如下:

//app.ts
import dotenv from 'dotenv';
import jwt from 'jsonwebtoken';
import { compareSync } from 'bcryptjs';

// load .env environment variables
dotenv.config();

app.post('/api/login', async (req, res) => {
    const { email, password } = req.body;
    const user = await prisma.user.findFirst({
        where: { email },
    });
    if (!user || !compareSync(password, user.password)) {
        res.status(401).json({ error: 'Invalid credentials' });
    } else {
        // sign a JWT token and return it in the response
        const token = jwt.sign({ sub: user.id }, process.env.JWT_SECRET!);
        res.json({ id: user.id, email: user.email, token });
    }
});

最后,将getPrisma回调更改为ZenStackMiddleware中的withPresets调用返回的增强的Prisma客户端,以便@password@omit属性可以生效。

// app.ts
import { withPresets } from '@zenstackhq/runtime';
app.use('/api', ZenStackMiddleware({ getPrisma: () => withPresets(prisma) }));

注意,使用增强的Prisma客户端,除非您明确打开它们,否则所有CRUD操作均被拒绝。让我们打开createread操作,以支持注册/登录流量:

// schema.prisma
model User {
    id String @id @default(cuid())
    email String @unique
    password String @password @omit
    orders Order[]

    // everybody can signup
    @@allow('create', true)

    // user profile is publicly readable
    @@allow('read', true)
}

现在再生Prisma模式并将更改推向数据库:

npx zenstack generate && npx prisma db push

重新启动开发服务器,我们可以测试我们的注册/登录流程。

注册用户:

curl -X POST localhost:3000/api/user/create \
    -H 'Content-Type: application/json' \
    -d '{ "data": { "email": "tom@pet.inc", "password": "abc123" } }'
{
    "id": "clfan0lys0000vhtktutornel",
    "email": "tom@pet.inc"
}

登录:

curl -X POST localhost:3000/api/login \
    -H 'Content-Type: application/json' \
    -d '{ "email": "tom@pet.inc", "password": "abc123" }'
{
    "id": "clfan0lys0000vhtktutornel",
    "email": "tom@pet.inc",
    "token": "..."
}

5.添加授权

现在我们已经有身份验证了,我们可以将访问控制规则添加到我们的架构中以确保我们的CRUD服务。对PetOrder模型进行以下更改:

// schema.prisma
model Pet {
    id String @id @default(cuid())
    createdAt DateTime @default(now())
    updatedAt DateTime @updatedAt
    name String
    category String
    order Order? @relation(fields: [orderId], references: [id])
    orderId String?

    // unsold pets are readable to all; sold ones are readable to buyers only
    @@allow('read', orderId == null || order.user == auth())

    // only allow update to 'orderId' field if it's not set yet (unsold)
    @@allow('update', name == future().name && category == future().category && orderId == null )
}

model Order {
    id String @id @default(cuid())
    createdAt DateTime @default(now())
    updatedAt DateTime @updatedAt
    pets Pet[]
    user User @relation(fields: [userId], references: [id])
    userId String

    // users can read their orders
    @@allow('read,create', auth() == user)
}

@@allow@@deny的语法是相当不言自明的。需要注意的几件事:

  • auth()功能返回当前身份验证的用户。您会看到它很快就被挂起的。
  • future()函数在应用更新后返回实体值。
  • Pet模型上的第二个@@allow规则看起来有些复杂。这是需要的,因为我们要禁止创建包括已出售宠物的订单。在数据库级别上,这意味着只有在null(尚未出售)时,PetorderId字段才能更新。我们还使用future()函数禁止对其他字段的更新。

您可以了解有关访问策略here的更多信息。

通过声明定义模式中的访问策略,您无需在API中实现这些规则。确保一致性更容易,使模式成为数据形状和安全规则的单一真实来源。

仍然缺少一个部分:我们需要将身份验证的用户身份连接到系统中,以使auth()函数起作用。为此,我们要求API呼叫者在Authorization标头中携带JWT令牌作为携带者令牌。然后,在服务器端,我们将其从当前请求中提取,然后将其传递给withPresets呼叫作为上下文。

添加一个getUser助手以从令牌中解码用户,然后将其传递给withPresets呼叫:

// app.ts
import type { Request } from 'express';

function getUser(req: Request) {
    const token = req.headers.authorization?.split(' ')[1];
    console.log('TOKEN:', token);
    if (!token) {
        return undefined;
    }
    try {
        const decoded: any = jwt.verify(token, process.env.JWT_SECRET!);
        return { id: decoded.sub };
    } catch {
        // bad token
        return undefined;
    }
}

app.use(
    '/api',
    ZenStackMiddleware({
        getPrisma: (req) => {
            return withPresets(prisma, { user: getUser(req) });
        },
    })
);

现在,策略引擎可以访问已验证的用户,并可以执行授权规则。重新运行代码生成并重新启动开发服务器。现在让我们测试授权。

npx zenstack generate && npx prisma db push

6.测试授权

登录以获取令牌:

curl -X POST localhost:3000/api/login \
    -H 'Content-Type: application/json' \
    -d '{ "email": "tom@pet.inc", "password": "abc123" }'
{
    "id": "<user id>",
    "email": "tom@pet.inc",
    "token": "<token>"
}

将返回的用户ID和令牌存储在环境变量中以备将来使用:

userId=<user id>
token=<token>

创建一个顺序:

下订单“ Luna”猫。请注意,我们通过Authorization标头的令牌。

curl -X POST localhost:3000/api/order/create \
    -H 'Content-Type: application/json' -H "Authorization: Bearer $token"  \
    -d "{ \"data\": { \"userId\": \"$userId\", \"pets\": { \"connect\": { \"id\": \"luna\" } } } }"
{
    "id": "clfapaykz0002vhwr634sd9l7",
    "createdAt": "2023-03-16T05:59:04.586Z",
    "updatedAt": "2023-03-16T05:59:04.586Z",
    "userId": "clfan0lys0000vhtktutornel"
}

匿名列出宠物:

“ luna”现在消失了,因为它已出售。

curl localhost:3000/api/pet/findMany
[
    {
        "id": "clfamyjp90002vhql2ng70ay8",
        "createdAt": "2023-03-16T04:53:26.205Z",
        "updatedAt": "2023-03-16T04:53:26.205Z",
        "name": "Max",
        "category": "doggie"
    },
    {
        "id": "clfamyjpa0004vhql4u0ys8lf",
        "createdAt": "2023-03-16T04:53:26.206Z",
        "updatedAt": "2023-03-16T04:53:26.206Z",
        "name": "Cooper",
        "category": "reptile"
    }
]

列出具有凭据的宠物:

“ luna”再次可见(上面有orderId),因为订购订单的用户可以在其中读取宠物。

curl localhost:3000/api/pet/findMany -H "Authorization: Bearer $token"
[
    {
        "id": "clfamyjp60000vhql266hko28",
        "createdAt": "2023-03-16T04:53:26.203Z",
        "updatedAt": "2023-03-16T05:59:04.586Z",
        "name": "Luna",
        "category": "kitten",
        "orderId": "clfapaykz0002vhwr634sd9l7"
    },
    {
        "id": "clfamyjp90002vhql2ng70ay8",
        "createdAt": "2023-03-16T04:53:26.205Z",
        "updatedAt": "2023-03-16T04:53:26.205Z",
        "name": "Max",
        "category": "doggie"
    },
    {
        "id": "clfamyjpa0004vhql4u0ys8lf",
        "createdAt": "2023-03-16T04:53:26.206Z",
        "updatedAt": "2023-03-16T04:53:26.206Z",
        "name": "Cooper",
        "category": "reptile"
    }
]

再次为“ Luna”创建订单将导致错误:

curl -X POST localhost:3000/api/order/create \
    -H 'Content-Type: application/json' -H "Authorization: Bearer $token"  \
    -d "{ \"data\": { \"userId\": \"$userId\", \"pets\": { \"connect\": { \"id\": \"luna\" } } } }"
{
    "prisma": true,
    "rejectedByPolicy": true,
    "code": "P2004",
    "message": "denied by policy: Pet entities failed 'update' check, 1 entity failed policy check"
}

您可以继续使用Order模型进行测试,看看其行为是否符合访问策略。

生成OpenAPI规范

到目前为止,我们已经实施了类似于REST的API。它并不完全符合Restful API面向资源的API端点设计,但它完全保留了Prisma的数据查询灵活性。

要称其为OpenAPI,我们必须提供正式规格。幸运的是,Zenstack可以为您生成OpenAPI V3规格。您只需要打开模式中的插件:

npm install -D @zenstackhq/openapi
// schema.prisma
plugin openapi {
    provider = '@zenstackhq/openapi'
    prefix = '/api'
    title = 'Pet Store API'
    version = '0.1.0'
    description = 'My awesome pet store API'
    output = 'petstore-api.json'
}

运行zenstack generate时,它将为您生成petstore-api.json文件。您可以使用Swagger UI等工具将其提供给API消费者。

npx zenstack generate

有一个警告:还记得我们手动实现了/api/login端点吗? Zenstack不知道这一点,并且生成的JSON规范不包括它。但是,我们可以使用一些额外的工具来解决此问题。

首先,安装一些新的依赖项:

npm install swagger-ui-express express-jsdoc-swagger
npm install -D @types/swagger-ui-express

然后添加JSDOC以将其输入和输出指定到/api/login路线:

// app.ts
/**
 * Login input
 * @typedef {object} LoginInput
 * @property {string} email.required - The email
 * @property {string} password.required - The password
 */

/**
 * Login response
 * @typedef {object} LoginResponse
 * @property {string} id.required - The user id
 * @property {string} email.required - The user email
 * @property {string} token.required - The access token
 */

/**
 * POST /api/login
 * @tags user
 * @param {LoginInput} request.body.required - input
 * @return {LoginResponse} 200 - login response
 */
app.post('/api/login', async (req, res) => {
    ...
}

JSDOC将OpenAPI元数据连接到/api/login路线。然后,我们可以使用express-jsdoc-swaggerswagger-ui-express合并这两个API规范的片段,并将服务器用于它:

// app.ts
import expressJSDocSwagger from 'express-jsdoc-swagger';

// load the CRUD API spec from the JSON file generated by `zenstack`
const crudApiSpec = require('./petstore-api.json');

// options for loading the extra OpenAPI from JSDoc
const swaggerOptions = {
    info: {
        version: '0.1.0',
        title: 'Pet Store API',
    },
    filesPattern: './app.ts', // scan app.ts for OpenAPI JSDoc
    baseDir: __dirname,
    exposeApiDocs: true,
    apiDocsPath: '/v3/api-docs', // serve the merged JSON specifcation at /v3/api-docs
};

// merge two specs and serve the UI
expressJSDocSwagger(app)(swaggerOptions, crudApiSpec);

现在,如果您击中http://localhost:3000/api-docs,您将看到API文档UI。您也可以在http://localhost:3000/v3/api-docs上访问RAW JSON规格。

Swagger UI

生成客户端SDK

太好了!我们拥有具有正式规范的运行服务。现在,消费者可以使用任何HTTP客户端实现客户对其进行交谈。有了OpenAPI规范,我们可以再采取一步为其生成强大的客户端SDK。

在此样本中,我们将使用openapi-typescriptopenapi-typescript-fetch实现它。

npm install -D openapi-typescript @types/node-fetch
npm install node-fetch openapi-typescript-fetch
npx openapi-typescript http://localhost:3000/v3/api-docs --output ./client-types.ts

然后,我们可以使用生成的类型进行强型API调用(对于输入和输出)。创建一个client.ts尝试一下:

// client.ts
import fetch, { Headers, Request, Response } from 'node-fetch';
import { Fetcher } from 'openapi-typescript-fetch';
import { paths } from './client-types';

// polyfill `fetch` for node
if (!globalThis.fetch) {
    globalThis.fetch = fetch as any;
    globalThis.Headers = Headers as any;
    globalThis.Request = Request as any;
    globalThis.Response = Response as any;
}

async function main() {
    const fetcher = Fetcher.for<paths>();
    fetcher.configure({
        baseUrl: 'http://localhost:3000',
    });

    const login = fetcher.path('/api/login').method('post').create();
    const { data: loginResult } = await login({
        email: 'tom@pet.inc',
        password: 'abc123',
    });
    // loginResult is typed as { id: string, email: string, token: string }
    console.log('Login result:', JSON.stringify(loginResult, undefined, 2));
    const token = loginResult.token;

    // get orders together with their pets
    const getOrders = fetcher.path(`/api/order/findMany`).method('get').create();
    const { data: orders } = await getOrders(
        { q: JSON.stringify({ include: { pets: true } }) },
        { headers: { Authorization: `Bearer ${token}` } }
    );
    console.log('Orders:', JSON.stringify(orders, undefined, 2));
}

main();

您可以使用:

npx tsx client.ts

包起来

构建以数据库为中心的OpenAPI服务涉及许多任务:设计数据模型,创作规范,实施服务并生成客户端SDK。但是,如您所见,它不需要艰难且耗时。

关键要点是,如果您可以使用单个真理来表示您的数据模式和访问规则,则可以从中生成许多其他工件。它从编写样板代码中节省了您宝贵的时间,并且使所有内容保持同步变得更加容易。

可以找到完成的项目。


p.s。,我们正在构建ZenStack,这是一种工具包,它可以用强大的访问控制层增强Prisma Orm,并释放其充分的全堆栈开发潜力。