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, Node.js 24.21.0, Corepack, cURL e um Docker Engine em execução. 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 Node.js 24 é uma versão LTS que recebe manutenção. 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.
