如果您是熟悉Restful API的开发人员,则可能已经听说过OpenAPI。它是描述人类和机器可读格式的静态API的规范。建立面向公共的OpenAPI包括三个任务:
- 撰写OpenAPI规范,该规范是API提供商与API消费者之间的合同。
- 基于规范实现API端点。
- 可选,实施客户SDK用于消费API。
在这篇文章中,您将看到如何完成所有这些任务并在15分钟内建立安全和记录的以数据库为中心的OpenAPI服务。
您可以找到完成的项目here。
设想
我将以简单的宠物商店API为例,以方便更容易理解。 API将具有以下资源:
- 用户:谁可以注册,登录和订购宠物。
- 宠物:可以由用户列出和订购。
- 订单:由用户创建并包含宠物列表。
业务规则:
- 匿名用户可以注册并登录。
- 匿名用户可以列出未售的宠物。
- 身份验证的用户可以列出未售出的宠物和宠物。
- 身份验证的用户可以为未售出的宠物创建订单。
- 身份验证的用户可以查看其订单。
建立它
我们将使用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的最关键部分。在本指南中,我们将使用Prisma和ZenStack对数据库进行建模。 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 -User
,Pet
和Order
。通过获取所有宠物来测试它:
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方法-findMany
,findUnique
,create
,update
,update
,aggregate
等。它们也具有与输入参数和响应的Prismaclient相同的结构。对于POST
和PUT
请求,输入ARGS直接作为请求主体(应用程序/JSON)发送。对于GET
和DELETE
请求,输入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操作均被拒绝。让我们打开create
和read
操作,以支持注册/登录流量:
// 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服务。对Pet
和Order
模型进行以下更改:
// 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
(尚未出售)时,Pet
的orderId
字段才能更新。我们还使用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-swagger
和swagger-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规格。
生成客户端SDK
太好了!我们拥有具有正式规范的运行服务。现在,消费者可以使用任何HTTP客户端实现客户对其进行交谈。有了OpenAPI规范,我们可以再采取一步为其生成强大的客户端SDK。
在此样本中,我们将使用openapi-typescript和openapi-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,并释放其充分的全堆栈开发潜力。