Schemas (base, resposta e paginação)¶
Todo endpoint recebe e devolve dados. Em vez de validar "na mão" e montar o
JSON de resposta com objetos soltos, o SDK dá uma fundação de schemas Zod —
o porte de schemas.base (BaseSchema / BaseResponseSchema) e
schemas.pagination do tempest-fastapi-sdk.
Um schema faz três coisas de uma vez:
- Valida a entrada (query, body, params) e lança
ZodErrorquando algo está errado. - Tipa o resultado — o TypeScript infere o tipo direto do schema.
- Documenta — como
zjá vem com.openapi(), cada campo alimenta o Swagger/Redoc (veja API: app, OpenAPI e docs).
De onde vem o z
z é o Zod já aumentado com .openapi(), re-exportado pelo SDK.
Importe sempre de tempest-express-sdk, nunca de zod direto — senão o
.openapi() não existe no seu z.
1. toDict — serializar limpando nulos¶
toDict espelha o BaseSchema.to_dict do FastAPI SDK: transforma um objeto
validado num record simples, descartando null/undefined, removendo
chaves e mesclando extras.
import { toDict } from "tempest-express-sdk";
const user = { id: "u1", name: "Ana", nickname: null, password: "secret" };
toDict(user);
// { id: "u1", name: "Ana" } ← nickname (null) sumiu
toDict(user, { exclude: ["password"] });
// { id: "u1", name: "Ana" } ← password removido
toDict(user, { include: { role: "admin" } });
// { id: "u1", name: "Ana", role: "admin" } ← extra mesclado por cima
Para que serve
Útil ao montar filtros para o repositório (só as chaves preenchidas) ou ao
devolver um payload sem campos sensíveis. É a mesma ideia do
exclude_none=True do Pydantic.
2. baseResponseSchema — os campos que todo registro tem¶
Todo registro do tempest-db-js carrega id, isActive, createdAt e
updatedAt. Em vez de repetir isso em cada *ResponseSchema, estenda o
base:
import { baseResponseSchema, z } from "tempest-express-sdk";
const userResponseSchema = baseResponseSchema.extend({
name: z.string().openapi({ description: "O nome de exibição do usuário." }),
email: z.string().email().openapi({ description: "O e-mail do usuário." }),
});
type UserResponse = z.infer<typeof userResponseSchema>;
// { id: string; isActive: boolean; createdAt: Date; updatedAt: Date;
// name: string; email: string }
O que o base já traz:
| Campo | Tipo | Descrição |
|---|---|---|
id |
string (uuid) |
Identificador único do registro. |
isActive |
boolean |
Flag de soft-delete. |
createdAt |
Date (coerce) |
Timestamp de criação (UTC). |
updatedAt |
Date (coerce) |
Timestamp da última atualização (UTC). |
z.coerce.date() aceita string
createdAt/updatedAt usam z.coerce.date(), então uma string ISO vinda
do banco ou de JSON vira Date automaticamente no parse.
3. Os três schemas de um recurso¶
O padrão do SDK: Create (entrada), Update (entrada parcial) e Response (saída). Nomeie sempre com o sufixo do propósito.
import { baseResponseSchema, z } from "tempest-express-sdk";
// entrada de criação — sem id/timestamps (o banco gera)
export const userCreateSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
password: z.string().min(8),
});
// entrada de atualização — tudo opcional
export const userUpdateSchema = userCreateSchema.partial();
// saída — base + campos públicos, SEM a senha
export const userResponseSchema = baseResponseSchema.extend({
name: z.string(),
email: z.string().email(),
});
export type UserCreate = z.infer<typeof userCreateSchema>;
export type UserUpdate = z.infer<typeof userUpdateSchema>;
export type UserResponse = z.infer<typeof userResponseSchema>;
Uso num router:
router.post("/api/users", async (req, res) => {
const data = userCreateSchema.parse(req.body); // 400 automático se inválido
const created = await repo.create(data);
res.status(201).json(userResponseSchema.parse(created)); // saída limpa
});
Faça o parse da saída também
userResponseSchema.parse(created) não é só firula: ele garante que a
senha (ausente no schema de resposta) nunca vaze, mesmo que o repositório
devolva a coluna. Validar a saída é sua última linha de defesa.
4. Paginação por offset¶
Para listas com página + tamanho. paginationFilterSchema faz o parse da query;
paginationSchema(item) monta o envelope da resposta.
import {
paginationFilterSchema,
paginationSchema,
getConditions,
getPaginationConditions,
z,
} from "tempest-express-sdk";
const userItem = z.object({ id: z.string(), name: z.string() });
const UserPage = paginationSchema(userItem);
router.get("/api/users", async (req, res) => {
// estenda o filtro base com filtros de domínio
const filter = paginationFilterSchema
.extend({ isActive: z.coerce.boolean().optional() })
.parse(req.query);
const page = await repo.paginate({
...getPaginationConditions(filter), // { page, pageSize, orderBy, ascending }
filters: getConditions(filter), // só os filtros de domínio (isActive)
});
res.json(UserPage.parse(page));
});
paginationFilterSchema traz page (≥1, default 1), pageSize (≥1, default 20),
orderBy? e ascending (default true) — os nomes casam com os argumentos do
BaseRepository.paginate, então repassam sem renomear.
getPaginationConditions(filter)→ extrai{ page, pageSize, orderBy, ascending }.getConditions(filter)→ remove as chaves de paginação e devolve só os filtros de domínio.
O envelope paginationSchema(item) devolve { items, total, page, pageSize, pages }.
5. Paginação por cursor¶
Para feeds/scroll infinito, onde offset fica caro. Cursor opaco, sem pular páginas.
import {
cursorPaginationFilterSchema,
cursorPaginationSchema,
encodeCursor,
decodeCursor,
z,
} from "tempest-express-sdk";
const userItem = z.object({ id: z.string(), name: z.string() });
const UserFeed = cursorPaginationSchema(userItem);
router.get("/api/users/feed", async (req, res) => {
const f = cursorPaginationFilterSchema.parse(req.query); // { cursor?, limit, orderBy, ascending }
const after = f.cursor ? decodeCursor(f.cursor) : undefined;
// `BaseRepository.list(filters)` devolve as linhas que casam (sem limit no
// repo); ordene por `id` e recorte a página no app.
const rows = (await repo.list(after ? { id: { gt: String(after.id) } } : {})).sort(
(a, b) => a.id.localeCompare(b.id),
);
const hasMore = rows.length > f.limit;
const items = rows.slice(0, f.limit);
const last = items.at(-1);
res.json(
UserFeed.parse({
items: items.map((u) => ({ id: u.id, name: u.name })),
nextCursor: hasMore && last ? encodeCursor({ id: last.id }) : null,
hasMore,
limit: f.limit,
}),
);
});
encodeCursor(payload)→ base64url URL-safe (sem padding).decodeCursor(cursor)→ volta ao objeto; lançaErrorse o cursor for inválido.
O repositório pagina offset nativamente
BaseRepository.paginate cobre offset embutido. O list(filters) recebe
só condições (WhereInput) — sem limit/orderBy —, então o cursor
você monta com list({ id: { gt } }) + estes helpers + recorte no app. O
padrão completo está em Banco de dados.
Offset ou cursor?
Offset para telas de tabela com "página 3 de 12". Cursor para feeds infinitos e sync — não sofre com inserções deslocando as páginas.
Para o modo delta-sync (offline-first, since/serverTime) veja
Campos validados e paginação.
Recapitulando¶
zdo SDK vem com.openapi()— importe dele, não dozod.toDictlimpa nulos, exclui e mescla — comoto_dictdo Pydantic.baseResponseSchema.extend({...})=id/isActive/createdAt/updatedAt+ seus campos.- Padrão de recurso:
*CreateSchema,*UpdateSchema(.partial()),*ResponseSchema— e parseie a saída para não vazar campos. paginationFilterSchema+paginationSchema(item)para offset;cursorPaginationFilterSchema+cursorPaginationSchema(item)+encodeCursor/decodeCursorpara cursor. ✅