Criar APIs RESTful com Node.js, Express e TypeScript
Crie uma API administrativa de perfis de usuários com Express, TypeScript e PostgreSQL. Depois, crie, consulte, atualize e exclua um perfil via HTTP. Você gerará tokens de teste locais e reiniciará a API para verificar se o perfil continua armazenado. A API armazena dados de perfil; ela não implementa login nem armazenamento de senhas.
Introdução
A API atende administradores confiáveis com tokens de acesso assinados. Um token precisa do
escopo profiles:read para consultar perfis e de profiles:write
para criá-los, editá-los ou excluí-los.
Esses escopos autorizam o acesso a todos os perfis; esta não é uma API de autoatendimento
para usuários. O emissor confiável atribui os escopos. Os corpos das requisições não podem
escolher uma identidade ou função para autorização.
Requisitos do sistema
Use Bash no Linux, uma versão de correção atual do Node.js 24 ou 26 LTS, cURL e um Docker Engine em execução. O exemplo completo foi testado em 6 de novembro de 2026, com Node.js 24.22.2 e 26.13.0. Mantenha a linha LTS escolhida atualizada; as versões com correções de segurança de outubro substituem as versões de correção anteriores.
Tenha o Corepack instalado antes de começar; o Node.js 26 não o inclui. O Corepack executa a versão fixada do Yarn 4.12.0 do projeto sem uma instalação global do Yarn. O exemplo usa PostgreSQL 17.11, Express 5.2.1, TypeORM 1.1.1 e TypeScript 5.9.3. O Express 5 encaminha as promises rejeitadas dos manipuladores de rotas para o middleware de tratamento de erros.
O assinador local abaixo fornece JWTs RS256 com emissor, público-alvo, sujeito, expiração e escopos separados por espaços. Ele permite testar a verificação real de assinaturas sem uma conta em um provedor de identidade. Qualquer pessoa com a chave privada do assinador local pode conceder acesso administrativo, portanto use-o apenas nesta demonstração em loopback.
Configurar o ambiente
Execute as etapas de configuração na ordem e pare se ocorrer qualquer erro. Se
stack-api já existir, escolha outro diretório em vez de sobrescrevê-lo.
O subshell mantém seu diretório original se a configuração falhar:
(
mkdir stack-api && cd stack-api &&
mkdir -p src/config src/controllers src/middleware src/models src/routes src/local
) && cd stack-api
Salve este package.json completo:
{
"name": "stack-api",
"private": true,
"type": "module",
"packageManager": "yarn@4.12.0",
"scripts": {
"build": "tsc --project tsconfig.json",
"start": "node dist/index.js"
},
"dependencies": {
"express": "5.2.1",
"helmet": "8.3.0",
"jose": "6.2.12",
"pg": "8.23.0",
"reflect-metadata": "0.2.2",
"typeorm": "1.1.1",
"zod": "3.25.76"
},
"devDependencies": {
"@types/express": "5.0.6",
"@types/node": "24.10.1",
"typescript": "5.9.3"
}
}
Salve .yarnrc.yml para usar uma instalação de
node_modules local ao projeto:
nodeLinker: node-modules
enableGlobalCache: false
enableTelemetry: false
ignorePath: true
injectEnvironmentFiles: []
Crie tsconfig.json. As configurações explícitas
types e typeRoots impedem que o compilador inclua
tipos de ambiente não relacionados de um projeto que englobe este:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"types": ["node"],
"typeRoots": ["./node_modules/@types"],
"strict": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true
},
"include": ["src/**/*.ts"]
}
Agora faça a instalação. Um yarn.lock vazio estabelece este diretório como um
projeto Yarn separado, inclusive dentro de um workspace.
Limpar NODE_OPTIONS impede que o carregador Plug’n’Play de um projeto que
englobe este seja injetado nestes comandos:
touch yarn.lock && NODE_OPTIONS='' corepack yarn install
Guarde o arquivo de lock gerado para as próximas instalações. Não coloque chaves de assinatura
locais nem credenciais de banco de dados no controle de versão; ignore
.local/ no seu próprio projeto.
Estrutura do projeto
src/
├── config/database.ts
├── controllers/userController.ts
├── middleware/errorHandler.ts
├── middleware/auth.ts
├── models/User.ts
├── routes/userRoutes.ts
├── local/createKeys.ts
├── local/issueToken.ts
├── app.ts
└── index.ts
Configurar a conexão com o banco de dados
Inicie um banco de dados descartável com a imagem oficial do PostgreSQL. O Docker atribui uma porta disponível em loopback. A senha abaixo é apenas para este exemplo local; o usuário do banco de dados do contêiner é um superusuário, inadequado para uma aplicação implantada.
: "${DB_CONTAINER:=stack-api-db-${RANDOM}-${RANDOM}}"
docker run --detach --name "$DB_CONTAINER" \
--publish 127.0.0.1::5432 \
--tmpfs /var/lib/postgresql/data:rw,size=256m \
--env POSTGRES_USER=profiles --env POSTGRES_DB=profiles \
--env POSTGRES_PASSWORD=local-only-password postgres:17.11-bookworm
Verifique se o serviço está pronto para conexões TCP, repetindo a tentativa 30 vezes com uma pausa de um segundo entre as tentativas. Continue apenas se este bloco for executado com sucesso:
(
for attempt in {1..30}; do
if docker exec "$DB_CONTAINER" pg_isready -h 127.0.0.1 -U profiles -d profiles; then
exit 0
fi
sleep 1
done
printf 'PostgreSQL did not become ready; inspect docker logs.\n' >&2
exit 1
)
Mantenha este terminal aberto: a configuração e a limpeza posteriores usam
DB_CONTAINER.
O banco de dados permanece disponível enquanto a API reinicia, mas seus dados temporários
desaparecem quando o contêiner do banco de dados para. Este é, intencionalmente, um exercício local.
Crie src/config/database.ts:
import 'reflect-metadata'
import { DataSource } from 'typeorm'
import { User } from '../models/User.js'
if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is required')
export const AppDataSource = new DataSource({
type: 'postgres',
url: process.env.DATABASE_URL,
synchronize: process.env.SCHEMA_SYNC === 'development-only',
logging: false,
entities: [User],
})
Os especificadores de importação .js se referem aos arquivos emitidos
neste projeto NodeNext independente. Compile com TypeScript:
a remoção de tipos do Node não transforma os decoradores do TypeORM.
Use migrações do TypeORM revisadas para bancos de dados implantados.
Criar o modelo de usuário
Crie src/models/User.ts:
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm'
@Entity()
export class User {
@PrimaryGeneratedColumn('uuid')
id!: string
@Column({ type: 'varchar', length: 100 })
name!: string
@Column({ type: 'varchar', length: 254, unique: true })
email!: string
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date
}
Não há coluna de senha. Criar um perfil não cria uma conta de login nem concede acesso.
timestamptz
armazena a data e a hora de criação como um instante, independentemente do fuso horário da sessão
do banco de dados.
Middleware de tratamento de erros
Crie src/middleware/errorHandler.ts. Gere um novo ID de requisição para cada requisição;
nunca confie no cabeçalho de ID de requisição de um cliente como identificador de auditoria.
import { randomUUID } from 'node:crypto'
import type { ErrorRequestHandler, RequestHandler } from 'express'
import { QueryFailedError } from 'typeorm'
import { z, ZodError } from 'zod'
export class AppError extends Error {
constructor(public statusCode: number, message: string) {
super(message)
}
}
export const requestId: RequestHandler = (_req, res, next) => {
res.locals.requestId = randomUUID()
res.setHeader('X-Request-ID', res.locals.requestId)
res.setHeader('Cache-Control', 'no-store')
next()
}
export const errorHandler: ErrorRequestHandler = (error, _req, res, next) => {
if (res.headersSent) {
next(error)
return
}
let status = 500
let message = 'Request failed'
if (error instanceof AppError) {
status = error.statusCode
message = error.message
} else if (error instanceof ZodError) {
status = 400
message = 'Invalid request'
} else if (error instanceof QueryFailedError &&
z.object({ code: z.literal('23505') }).safeParse(error.driverError).success) {
status = 409
message = 'Email is already in use'
} else {
const parsed = z.object({
type: z.enum(['entity.parse.failed', 'entity.too.large']),
}).safeParse(error)
if (parsed.success) {
status = parsed.data.type === 'entity.too.large' ? 413 : 400
message = 'Invalid request body'
}
}
console.error(JSON.stringify({ event: 'request_failed', requestId: res.locals.requestId, status }))
res.status(status).json({ error: message, requestId: res.locals.requestId })
}
Crie src/middleware/auth.ts. A chave pública, o emissor, o público-alvo e o algoritmo
configurados fazem parte da política do servidor; nenhum cabeçalho de token ou parâmetro de
requisição pode substituí-los. jose verifica a assinatura e as declarações usando
estas opções de verificação.
O servidor exige sub, exp e
iat e rejeita tokens emitidos há mais de 15 minutos, mesmo que sua
expiração seja posterior. Seu emissor de produção também deve fornecer essas declarações.
import type { RequestHandler } from 'express'
import { importSPKI, jwtVerify } from 'jose'
import { AppError } from './errorHandler.js'
const { JWT_PUBLIC_KEY, JWT_ISSUER, JWT_AUDIENCE } = process.env
if (!JWT_PUBLIC_KEY || !JWT_ISSUER || !JWT_AUDIENCE) {
throw new Error('JWT verification configuration is required')
}
const verificationKey = await importSPKI(JWT_PUBLIC_KEY, 'RS256')
export function authorize(scope: string): RequestHandler {
return async (req, _res, next) => {
const match = /^Bearer ([A-Za-z0-9_.-]+)$/.exec(req.headers.authorization ?? '')
if (!match) throw new AppError(401, 'Authentication required')
let payload
try {
const verified = await jwtVerify(match[1], verificationKey, {
algorithms: ['RS256'],
issuer: JWT_ISSUER,
audience: JWT_AUDIENCE,
requiredClaims: ['sub', 'exp', 'iat'],
maxTokenAge: '15m',
})
payload = verified.payload
} catch {
throw new AppError(401, 'Invalid access token')
}
if (typeof payload.scope !== 'string' || !payload.scope.split(' ').includes(scope)) {
throw new AppError(403, 'Permission denied')
}
next()
}
}
Configurar o servidor Express
Crie src/app.ts:
import express from 'express'
import helmet from 'helmet'
import { errorHandler, requestId } from './middleware/errorHandler.js'
import { userRoutes } from './routes/userRoutes.js'
export const app = express()
app.disable('x-powered-by')
app.use(requestId)
app.use(helmet())
app.use('/api/v1/users', userRoutes)
app.use((_req, res) => res.status(404).json({
error: 'Not found', requestId: res.locals.requestId,
}))
app.use(errorHandler)
Crie src/index.ts:
import { once } from 'node:events'
import { z } from 'zod'
import { app } from './app.js'
import { AppDataSource } from './config/database.js'
async function main(): Promise<void> {
const port = z.coerce.number().int().min(1).max(65535).parse(process.env.PORT ?? 3000)
await AppDataSource.initialize()
const server = app.listen(port, '127.0.0.1')
server.requestTimeout = 30_000
server.headersTimeout = 10_000
await once(server, 'listening')
console.log(`Listening on http://127.0.0.1:${port}`)
}
main().catch(async () => {
console.error('API startup failed; check database, JWT settings, and PORT')
if (AppDataSource.isInitialized) await AppDataSource.destroy()
process.exitCode = 1
})
A mensagem de que o servidor está pronto aparece apenas após a conexão com o PostgreSQL e a
vinculação à porta HTTP. Se a inicialização falhar, verifique o contêiner do banco de dados,
a configuração da chave e se outro processo já usa PORT.
Escolha uma porta livre em vez de parar um processo desconhecido.
Controlador de usuários
Crie src/controllers/userController.ts. Esquemas estritos rejeitam campos extras em vez de aceitar
silenciosamente id, password,
role ou marcas de data e hora.
import type { Request, Response } from 'express'
import { z } from 'zod'
import { AppDataSource } from '../config/database.js'
import { AppError } from '../middleware/errorHandler.js'
import { User } from '../models/User.js'
const profiles = AppDataSource.getRepository(User)
const profileSchema = z.object({
name: z.string().trim().min(1).max(100),
email: z.string().trim().email().max(254).transform((value) => value.toLowerCase()),
}).strict()
const patchSchema = profileSchema.partial().refine((value) => Object.keys(value).length > 0)
const idSchema = z.string().uuid()
export async function getUsers(req: Request, res: Response): Promise<void> {
const query = z.object({
offset: z.coerce.number().int().min(0).max(10000).default(0),
limit: z.coerce.number().int().min(1).max(100).default(20),
}).strict().parse(req.query)
const data = await profiles.find({ skip: query.offset, take: query.limit, order: { id: 'ASC' } })
res.json({ data })
}
export async function getUser(req: Request, res: Response): Promise<void> {
const user = await profiles.findOneBy({ id: idSchema.parse(req.params.id) })
if (!user) throw new AppError(404, 'User not found')
res.json({ data: user })
}
export async function createUser(req: Request, res: Response): Promise<void> {
const { name, email } = profileSchema.parse(req.body)
const user = await profiles.save(profiles.create({ name, email }))
res.location('/api/v1/users/' + user.id).status(201).json({ data: user })
}
export async function updateUser(req: Request, res: Response): Promise<void> {
const id = idSchema.parse(req.params.id)
const fields = patchSchema.parse(req.body)
const result = await profiles.update(id, fields)
if (result.affected === 0) throw new AppError(404, 'User not found')
res.status(204).end()
}
export async function deleteUser(req: Request, res: Response): Promise<void> {
const result = await profiles.delete(idSchema.parse(req.params.id))
if (result.affected === 0) throw new AppError(404, 'User not found')
res.status(204).end()
}
Atualizações e exclusões retornam uma resposta 204 vazia. E-mails duplicados após a normalização retornam 409. Consultas retornam apenas os campos públicos do modelo de perfil.
Rotas de usuários
Crie src/routes/userRoutes.ts. A autenticação ocorre antes da análise do JSON.
import { json, Router } from 'express'
import { createUser, deleteUser, getUser, getUsers, updateUser } from '../controllers/userController.js'
import { authorize } from '../middleware/auth.js'
export const userRoutes = Router()
const body = json({ limit: '10kb' })
userRoutes.get('/', authorize('profiles:read'), getUsers)
userRoutes.get('/:id', authorize('profiles:read'), getUser)
userRoutes.post('/', authorize('profiles:write'), body, createUser)
userRoutes.patch('/:id', authorize('profiles:write'), body, updateUser)
userRoutes.delete('/:id', authorize('profiles:write'), deleteUser)
Executar a aplicação
Crie src/local/createKeys.ts. Execute-o uma vez por projeto novo;
ele se recusa a sobrescrever chaves:
import { generateKeyPairSync } from 'node:crypto'
import { mkdir, writeFile } from 'node:fs/promises'
const pair = generateKeyPairSync('rsa', { modulusLength: 2048 })
await mkdir('.local', { mode: 0o700 })
await writeFile('.local/private.pem', pair.privateKey.export({ type: 'pkcs8', format: 'pem' }), {
flag: 'wx', mode: 0o600,
})
await writeFile('.local/public.pem', pair.publicKey.export({ type: 'spki', format: 'pem' }), {
flag: 'wx', mode: 0o600,
})
Crie src/local/issueToken.ts. Este emissor de teste local concede os dois escopos
administrativos por cinco minutos. Ele é um utilitário de linha de comando, não uma rota de
login da API:
import { readFile } from 'node:fs/promises'
import { importPKCS8, SignJWT } from 'jose'
const key = await importPKCS8(await readFile('.local/private.pem', 'utf8'), 'RS256')
const token = await new SignJWT({ scope: 'profiles:read profiles:write' })
.setProtectedHeader({ alg: 'RS256' })
.setIssuer('https://local-issuer.example.test')
.setAudience('profile-api')
.setSubject('local-admin')
.setIssuedAt()
.setExpirationTime('5m')
.sign(key)
console.log(token)
Compile o TypeScript e crie as chaves. O && impede a execução de uma
compilação anterior se a compilação atual falhar:
NODE_OPTIONS='' corepack yarn build && NODE_OPTIONS='' node dist/local/createKeys.js
Configure o servidor no mesmo terminal que iniciou o PostgreSQL. JWT_PUBLIC_KEY
contém o texto PEM com quebras de linha reais. SCHEMA_SYNC habilita a criação
de tabelas apenas para este banco de dados descartável; deixe-o sem definir para um banco de
dados implantado e gerenciado com migrações.
DB_ADDRESS="$(docker port "$DB_CONTAINER" 5432/tcp)" &&
LOCAL_PUBLIC_KEY="$(cat .local/public.pem)" &&
export DATABASE_URL="postgresql://profiles:local-only-password@${DB_ADDRESS}/profiles" \
JWT_PUBLIC_KEY="$LOCAL_PUBLIC_KEY" \
JWT_ISSUER='https://local-issuer.example.test' JWT_AUDIENCE='profile-api' \
SCHEMA_SYNC=development-only PORT="${PORT:-3000}"
Inicie a API em primeiro plano. Aguarde Listening on http://127.0.0.1:3000, ou a porta que você
selecionou:
NODE_OPTIONS='' corepack yarn start
Testar a API
Abra um segundo terminal em stack-api. Defina
PORT nele também se você selecionou uma porta diferente.
Execute estas etapas nesse mesmo segundo terminal para que o token e o UUID retornado continuem
disponíveis. Mantenha os tokens fora dos logs e do código-fonte do frontend. Para renovar um
token expirado, repita o comando do token.
BASE_URL="http://127.0.0.1:${PORT:-3000}"
NEXT_ACCESS_TOKEN="$(NODE_OPTIONS='' node dist/local/issueToken.js)" && ACCESS_TOKEN="$NEXT_ACCESS_TOKEN"
Crie o perfil e capture o UUID retornado apenas após uma resposta bem-sucedida. Se a requisição falhar, não prossiga para a próxima etapa. Um 409 significa que o e-mail já existe; use outro e-mail ou consulte o perfil existente em vez de repetir às cegas um POST que pode já ter sido efetivado.
CREATED="$(curl -fsS -X POST "$BASE_URL/api/v1/users" \
-H "Authorization: Bearer $ACCESS_TOKEN" -H 'Content-Type: application/json' \
-d '{"name":"John Doe","email":"john@example.com"}')" &&
NEXT_PROFILE_ID="$(printf '%s' "$CREATED" | NODE_OPTIONS='' node --input-type=module -e '
import { z } from "zod"
let text = ""
for await (const chunk of process.stdin) text += chunk
const { data } = z.object({ data: z.object({ id: z.string().uuid() }) }).parse(JSON.parse(text))
console.log(data.id)
')" && PROFILE_ID="$NEXT_PROFILE_ID" && printf '%s\n' "$CREATED"
A resposta é {"data":{"id":"…","name":"John Doe","email":"john@example.com","createdAt":"…"}},
com HTTP 201 e um cabeçalho Location para o novo perfil.
Consulte o perfil e a lista paginada:
curl -fsS "$BASE_URL/api/v1/users/$PROFILE_ID" \
-H "Authorization: Bearer $ACCESS_TOKEN" &&
curl -fsS "$BASE_URL/api/v1/users?limit=20&offset=0" \
-H "Authorization: Bearer $ACCESS_TOKEN"
Atualize o nome. -i mostra o status e os cabeçalhos porque PATCH
não retorna corpo:
curl -fsS -i -X PATCH "$BASE_URL/api/v1/users/$PROFILE_ID" \
-H "Authorization: Bearer $ACCESS_TOKEN" -H 'Content-Type: application/json' \
-d '{"name":"John Smith"}'
A resposta esperada é HTTP 204. No primeiro terminal, pressione Ctrl+C e repita o bloco de inicialização acima. Deixe o PostgreSQL em execução. No segundo terminal, repita o GET a seguir; o mesmo UUID, o nome atualizado, o e-mail e a data e hora de criação devem continuar presentes:
curl -fsS "$BASE_URL/api/v1/users/$PROFILE_ID" \
-H "Authorization: Bearer $ACCESS_TOKEN"
Exclua esse perfil e observe a resposta 404 ao consultá-lo. O último comando omite
-f intencionalmente para que você possa ler o JSON de erro:
curl -fsS -i -X DELETE "$BASE_URL/api/v1/users/$PROFILE_ID" \
-H "Authorization: Bearer $ACCESS_TOKEN" &&
curl -sS -i "$BASE_URL/api/v1/users/$PROFILE_ID" \
-H "Authorization: Bearer $ACCESS_TOKEN"
DELETE retorna uma resposta 204 vazia. O GET seguinte retorna 404 com
error e requestId.
Cada resposta tem um novo X-Request-ID; o ID no corpo de um erro corresponde
ao do cabeçalho. A autenticação ocorre antes da análise do corpo, e o analisador JSON limita o
corpo decodificado da requisição a 10 KiB.
Documentação da API
Use estes resultados ao descrever esta API em OpenAPI:
| Requisição | Status |
|---|---|
| Criar um perfil válido | 201, com Location |
| Consultar um perfil ou uma lista paginada | 200 |
| Atualizar ou excluir um perfil existente | 204, corpo vazio |
| Token ausente, expirado, com assinatura incorreta ou emissor/público-alvo incorreto | 401 |
| Token válido sem o escopo exigido pela rota | 403 |
| UUID, campos ou paginação inválidos, ou JSON malformado | 400 |
| E-mail duplicado após remover espaços nas extremidades e converter para minúsculas | 409 |
| Corpo JSON acima de 10 KiB | 413 |
| UUID de perfil desconhecido ou excluído | 404 |
As operações de gravação de perfis aceitam apenas name e
email. PATCH exige pelo menos um deles, e a paginação usa 20 itens por
padrão, com um máximo de 100 e um deslocamento entre zero e 10.000. Documente os dois escopos
como permissões administrativas sobre todos os perfis, incluindo o esquema de token bearer e
o cabeçalho de ID de requisição.
Considerações sobre implantação
Para uma aplicação com usuários reais, substitua o assinador local pelo fluxo de tokens de acesso administrativo do seu provedor de identidade. Obtenha a chave pública dele por meio de uma configuração confiável e defina o emissor e o público-alvo correspondentes no servidor. Este exemplo usa uma chave de verificação fixa; ele não implementa rotação de chaves nem revogação de tokens.
Mantenha a API atrás de HTTPS para acesso remoto, substitua o banco de dados descartável com superusuário por um banco com credenciais de aplicação restritas e use migrações em vez de sincronização de esquema. Parar o processo Node não para o PostgreSQL. Quando terminar, pare a API e remova apenas o contêiner de banco de dados que você criou, descartando seus dados temporários:
docker rm --force --volumes "$DB_CONTAINER"
Conclusão
Se esses perfis precisarem de arquivos anexados no futuro, a API de upload da Transloadit oferece um serviço separado de ingestão e processamento.
