Если вы являетесь разработчиком, знакомым с RESTful API, вы, возможно, слышали о OpenAPI. Это спецификация для описания RESTful API в формате, читаемом людьми и машинами. Создание публичного OpenAPI включает в себя три задачи:
- Разработка спецификации OpenAPI, которая служит контрактом между поставщиком и потребителем API.
- Реализация конечных точек API на основе спецификации.
- Опционально, реализация клиентских SDK для использования API.
В этом посте вы увидите, как выполнить все эти задачи и создать OpenAPI-сервис, ориентированный на базу данных, безопасный и документированный, за 15 минут.
Готовый проект вы можете найти здесь.
Оглавление
Сценарий
Для облегчения понимания я буду использовать в качестве примера простой API зоомагазина. API будет иметь следующие ресурсы:
- Пользователь: который может зарегистрироваться, войти в систему и заказать питомца.
- Питомец: который может быть перечислен и заказан пользователями.
- Заказ: который создается пользователями и содержит список питомцев.
Бизнес-правила:
- Анонимные пользователи могут регистрироваться и входить в систему.
- Анонимные пользователи могут перечислять непроданных питомцев.
- Аутентифицированные пользователи могут перечислять непроданных питомцев и питомцев, заказанных ими.
- Аутентифицированные пользователи могут создавать заказы на непроданных питомцев.
- Аутентифицированные пользователи могут просматривать свои заказы.
Создание
Мы будем использовать Express.js в качестве фреймворка для создания сервиса. Однако можно использовать и другие фреймворки, например Fastify, и общий процесс будет аналогичным.
1. Создание проекта
Сначала создадим новый проект Express.js с помощью Typescript.
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
Hello World!
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. Вы можете использовать адаптер, специфичный для фреймворка, для установки RESTful-сервисов в ваше приложение. Давайте посмотрим, как это сделать с помощью Express.js.
npm install @zenstackhq/server
Интеграция с Express.js осуществляется с помощью фабрики промежуточного ПО ZenStackMiddleware. Используйте его для монтирования RESTful 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 API для всех ресурсов - пользователя, питомца и заказа. Протестируйте его, получив всех питомцев:
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 - findMany, findUnique, create, update, aggregate и т.д. Они также имеют ту же структуру, что и PrismaClient, для входных аргументов и ответов. Для запросов POST и PUT входные аргументы отправляются непосредственно как тело запроса (application/json). Для запросов GET и DELETE входные аргументы сериализуются в JSON и отправляются как параметры запроса 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. Добавление аутентификации
Для этого простого сервиса мы примем аутентификацию на основе email/пароля и будем выдавать JWT-токен для каждого успешного входа.
Сначала рассмотрим часть, связанную с регистрацией. Поскольку ресурс User уже имеет CRUD API, нам не нужно реализовывать отдельный API для регистрации, так как регистрация - это просто создание пользователя. Единственное, о чем нам нужно позаботиться, это убедиться, что мы храним хэшированные пароли, а не простой текст. Достичь этого просто: достаточно добавить атрибут @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 на улучшенный клиент Prisma, возвращаемый вызовом withPresets, чтобы атрибуты @password и @omit могли вступить в силу.
// app.ts
import { withPresets } from '@zenstackhq/runtime';
app.use('/api', ZenStackMiddleware({ getPrisma: () => withPresets(prisma) }));
Имейте в виду, что в расширенном клиенте Prisma все операции CRUD по умолчанию запрещены, если вы не откроете их явно. Давайте откроем операции создания и чтения для User, чтобы поддержать поток регистрации/входа в систему:
// 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() возвращает значение сущности после применения обновления.
- Второе правило @@allow в модели Pet выглядит немного сложным. Оно необходимо, потому что мы хотим запретить создание заказов, включающих проданных питомцев. На уровне базы данных это означает, что поле orderId модели Pet может быть обновлено только в том случае, если оно равно null (то есть животное еще не продано). Мы также использовали функцию future(), чтобы запретить обновление других полей.
Подробнее о политиках доступа вы можете узнать здесь.
Декларативно определяя политики доступа в схеме, вам больше не нужно реализовывать эти правила в API. Легче обеспечить согласованность, что делает схему единым источником истины для формы ваших данных и правил безопасности.
Однако одной детали все еще не хватает: нам нужно подключить аутентифицированную личность пользователя к системе, чтобы функция auth() работала. Для этого мы потребуем от вызывающих API передавать токен 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) });
},
})
);
Теперь механизм политики имеет доступ к аутентифицированному пользователю и может применять правила авторизации. Перезапустите генерацию кода и перезапустите сервер dev. Теперь давайте протестируем авторизацию.
npx zenstack generate && npx prisma db push
- Тестирование авторизации
Войдите в систему, чтобы получить токен:
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>"
}
Сохраните возвращенные идентификатор пользователя и токен в переменных окружения для дальнейшего использования:
userId=<user id>
token=<token>
Создание заказа:
Разместите заказ на кошку ”Луна”. Обратите внимание, что мы передаем маркер в заголовке авторизации.
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"
}
Разместите список домашних животных анонимно:
”Луна” больше нет, потому что она продана.
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, ориентированному на ресурсы, но полностью сохраняет гибкость запросов к данным 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. Вы можете передать его потребителю API с помощью таких инструментов, как Swagger UI.
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 и создать для них Swagger UI:
// 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. Вы также можете получить доступ к необработанной спецификации JSON по адресу http://localhost:3000/v3/api-docs.

Генерация 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 мощным слоем управления доступом и раскрывает его полный потенциал для фуллстэк разработки.