Saltearse al contenido

Guía de GraphQL

Usa el plugin GraphQL y los decoradores con Veloce-TS. Las queries y mutaciones (@Resolver, @GQLQuery, @GQLMutation, @Arg) se ejecutan de extremo a extremo desde v2.0.0 — las versiones anteriores lo anunciaban pero en realidad no invocaban tus resolvers, ver Tipos de retorno. Las suscripciones se ejecutan desde la v3.4.0 sobre graphql-transport-ws — ver Suscripciones.

Requiere la dependencia opcional graphql:

Ventana de terminal
bun add graphql

Veloce-TS proporciona soporte completo de GraphQL con:

  • Generación Automática de Esquemas a partir de metadatos de decoradores
  • Resolvers basados en Decoradores para código limpio y declarativo
  • Integración Zod para validación de argumentos y seguridad de tipos
  • Argumentos con Seguridad de Tipos con validación automática
  • Suscripciones sobre WebSocket con el subprotocolo graphql-transport-ws
  • Playground e Introspección para desarrollo
import { Resolver, GQLQuery, GQLMutation, Arg } from 'veloce-ts/graphql';
import { z } from 'zod';
@Resolver()
class UserResolver {
@GQLQuery('getUser')
async getUser(@Arg('id', z.string()) id: string) {
return { id, name: 'Alicia' };
}
@GQLMutation('createUser')
async createUser(@Arg('name', z.string()) name: string) {
return { id: '1', name };
}
}

Opción A — pasar resolvers directamente al plugin (recomendado):

import { Veloce, GraphQLPlugin } from 'veloce-ts';
const app = new Veloce();
app.usePlugin(new GraphQLPlugin({
path: '/graphql',
playground: true,
resolvers: [UserResolver], // todas las clases @Resolver van aquí
}));
await app.compile();
app.listen(3000);

Opción B — usar app.include() antes del plugin:

app.include(UserResolver); // reconocido como clase @Resolver
app.usePlugin(new GraphQLPlugin({ playground: true }));

Ambas opciones se pueden combinar; los resolvers se deduplicarán automáticamente.

OpciónPor defectoDescripción
path'/graphql'Ruta HTTP del endpoint GraphQL
playgroundPath'/graphql/playground'Ruta del Playground
playgroundtrueHabilitar/deshabilitar el Playground
resolvers[]Clases resolver a incluir en el esquema
context(req) => ({ request: req })Fábrica de contexto personalizada (async permitido)
subscriptionsfalseSirve suscripciones sobre WebSocket. true para los valores por defecto, o un objeto — ver Suscripciones

Para CORS usa app.useCors(...) u otro middleware global; no se configura en el objeto del plugin.

import { Resolver, GQLQuery, Arg } from 'veloce-ts/graphql';
import { z } from 'zod';
const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email()
});
@Resolver()
export class UserResolver {
@GQLQuery()
async users(): Promise<User[]> {
return await userService.findAll();
}
@GQLQuery()
async user(@Arg('id', z.string()) id: string): Promise<User | null> {
return await userService.findById(id);
}
@GQLQuery()
async userByName(@Arg('name', z.string()) name: string): Promise<User[]> {
return await userService.findByName(name);
}
}
import { GQLMutation, Arg } from 'veloce-ts/graphql';
const CreateUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
password: z.string().min(6)
});
@Resolver()
export class UserResolver {
@GQLMutation()
async createUser(@Arg('input', CreateUserSchema) input: z.infer<typeof CreateUserSchema>): Promise<User> {
const hashedPassword = await bcrypt.hash(input.password, 10);
const user = await userService.create({
...input,
password: hashedPassword
});
return user;
}
@GQLMutation()
async updateUser(
@Arg('id', z.string()) id: string,
@Arg('input', CreateUserSchema.partial()) input: Partial<z.infer<typeof CreateUserSchema>>
): Promise<User> {
return await userService.update(id, input);
}
@GQLMutation()
async deleteUser(@Arg('id', z.string()) id: string): Promise<boolean> {
await userService.delete(id);
return true;
}
}

Un resolver de suscripción devuelve un async iterable. PubSub es la fuente habitual, y un async generator normal también sirve.

import { Resolver, GQLSubscription, GQLMutation, Arg, Returns, PubSub } from 'veloce-ts';
export const pubsub = new PubSub();
@Resolver()
export class ChatResolver {
@GQLSubscription()
@Returns(MessageSchema, { name: 'ChatMessage' })
messageAdded() {
return pubsub.subscribe('MESSAGE_ADDED');
}
@GQLMutation()
@Returns(MessageSchema, { name: 'ChatMessage' })
async sendMessage(@Arg('input', SendMessageSchema) input: SendMessage) {
const message = await db.insert(input);
await pubsub.publish('MESSAGE_ADDED', message);
return message;
}
}

Publicar el valor del campo directamente es el caso común, así que lo que publicas es lo que recibe el cliente: pubsub.publish('MESSAGE_ADDED', message) llega como { data: { messageAdded: message } }.

Desactivado por defecto — abrir un endpoint WebSocket debería ser una decisión deliberada.

app.usePlugin(new GraphQLPlugin({
resolvers: [ChatResolver],
subscriptions: true, // o un objeto de opciones
}));

El endpoint comparte la ruta de GraphQL, que es lo que asumen los clientes: un upgrade y un POST pueden convivir ahí porque son métodos distintos.

OpciónPor defectoDescripción
pathla ruta de GraphQLDónde escucha el endpoint WebSocket
onConnect—Filtra la conexión en connection_init. false la rechaza (cierre 4403); un objeto se devuelve como payload del connection_ack
context—Construye el context que reciben los resolvers, una vez por conexión
connectionInitWaitTimeout3000Cuánto puede tardar un cliente en enviar connection_init antes de cerrarse con 4408

Un WebSocket de navegador no puede enviar cabecera Authorization, así que las credenciales viajan en connectionParams:

subscriptions: {
onConnect: ({ connectionParams }) => {
const user = verifyToken(connectionParams?.token as string);
return Boolean(user);
},
context: ({ connectionParams }) => ({
user: verifyToken(connectionParams?.token as string),
}),
}
// cliente
createClient({
url: 'ws://localhost:3000/graphql',
connectionParams: { token: localStorage.getItem('token') },
});
const pubsub = new PubSub();
await pubsub.publish('TOPIC', payload); // reparte a todos los suscriptores
pubsub.subscribe('TOPIC'); // AsyncIterableIterator
pubsub.subscribe(['ORDERS', 'REFUNDS']); // varios topics a la vez
pubsub.subscriberCount('TOPIC');
pubsub.close('TOPIC'); // termina todos los suscriptores

Está construido sobre EventBus, así que pasar el bus compartido hace que un listener @On y una suscripción GraphQL reaccionen al mismo publish:

import { globalEvents } from 'veloce-ts';
const pubsub = new PubSub({ eventBus: globalEvents });

Contrapresión. Un suscriptor que no da abasto haría crecer una cola sin límite. maxQueueSize (1000 por defecto) la acota, y backpressure decide qué cede: 'drop-oldest' (por defecto, conserva el estado más reciente), 'drop-newest' (conserva los más antiguos, preservando el orden desde el inicio), o 'error' (termina ese suscriptor con un SubscriberOverflowError).

  • Hace falta el paquete graphql, como peer dependency opcional: npm install graphql.
  • En Node el endpoint necesita además @hono/node-ws, y la app debe servirse con app.listen() — el manejador WebSocket se engancha al http.Server real, que no existe hasta entonces.
  • Bun y Deno hacen el upgrade nativamente y no necesitan paquete extra.
  • Un gateway de WebSocketPlugin y el endpoint de suscripciones pueden convivir en el mismo servidor.

Terminar una operación, un socket caído y app.shutdown() ejecutan todos el teardown del iterador, que desengancha su listener del bus. No queda nada enganchado cuando un cliente se va.

CódigoSignificado
4400Petición inválida — mensaje mal formado, tipo desconocido, id/query ausente
4401Llegó subscribe antes de que se reconociera connection_init
4403onConnect rechazó la conexión
4408connection_init no llegó a tiempo
4409Ya hay una operación corriendo con ese id
4429Más de un connection_init

El tipo GraphQL de un campo se resuelve en este orden:

  1. un tipo de retorno explícito en los metadatos del decorador, si existe;
  2. el decorador @Returns();
  3. en su defecto un escalar JSON nullable, como fallback documentado.
import { Resolver, GQLQuery, GQLMutation, Arg } from 'veloce-ts';
import { Returns } from 'veloce-ts/graphql';
import { z } from 'zod';
const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
});
@Resolver()
class UserResolver {
// Emite `type User { id: String!, name: String!, email: String! }`
@GQLQuery()
@Returns(UserSchema, { name: 'User' })
async user(@Arg('id') id: string) {
return userService.findById(id);
}
// Una lista: `[User!]!`
@GQLQuery()
@Returns(UserSchema, { name: 'User', list: true })
async users() {
return userService.findAll();
}
// Un escalar integrado por nombre
@GQLQuery()
@Returns('Int')
async userCount() {
return userService.count();
}
// Nullable — se emite sin el `!` final
@GQLQuery()
@Returns(UserSchema, { name: 'User', nullable: true })
async maybeUser(@Arg('id') id: string) {
return userService.findById(id) ?? null;
}
}

ReturnsOptions:

OpciónSignificado
nameNombre del tipo de objeto a emitir para un ZodObject. Por defecto el nombre del campo capitalizado.
nullabletrue quita el !. Por defecto false.
listtrue envuelve el tipo como lista.

Los argumentos tipados con Zod generan tipos de entrada nombrados automáticamente, así que @Arg('input', AlgunZodObject) produce un tipo input real en lugar de un escalar.

Todos los decoradores GraphQL se importan desde 'veloce-ts/graphql' (también re-exportados desde 'veloce-ts').

DecoradorPropósitoEjemplo
@Resolver(name?)Marca una clase como resolver GraphQL@Resolver('user')
@GQLQuery(name?)Define una consulta GraphQL@GQLQuery('getUser')
@GQLMutation(name?)Define una mutación GraphQL@GQLMutation('createUser')
@GQLSubscription(name?)Define una suscripción GraphQL@GQLSubscription()
@Arg(name, schema?)Define argumentos con validación Zod opcional@Arg('id', z.string())
@GQLContext()Inyecta el objeto de contexto GraphQL@GQLContext() ctx: any
import { Arg } from 'veloce-ts/graphql';
const PaginationSchema = z.object({
page: z.number().min(1).default(1),
limit: z.number().min(1).max(100).default(10),
sortBy: z.enum(['name', 'email', 'createdAt']).default('createdAt'),
sortOrder: z.enum(['asc', 'desc']).default('desc')
});
const FilterSchema = z.object({
name: z.string().optional(),
email: z.string().email().optional(),
role: z.enum(['admin', 'user', 'guest']).optional()
});
@Resolver()
export class UserResolver {
@GQLQuery()
async users(
@Arg('pagination', PaginationSchema) pagination: z.infer<typeof PaginationSchema>,
@Arg('filters', FilterSchema) filters: z.infer<typeof FilterSchema>
): Promise<{ users: User[]; total: number; page: number }> {
const result = await userService.findWithPagination(pagination, filters);
return {
users: result.users,
total: result.total,
page: pagination.page
};
}
}

Veloce-TS convierte automáticamente esquemas Zod a tipos GraphQL:

// Esquema Zod
const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
age: z.number().int().min(0),
isActive: z.boolean(),
createdAt: z.date(),
tags: z.array(z.string())
});
// Genera automáticamente el tipo GraphQL:
// type User {
// id: String!
// name: String!
// email: String!
// age: Int!
// isActive: Boolean!
// createdAt: DateTime!
// tags: [String!]!
// }
import { GraphQLScalarType } from 'graphql';
const DateScalar = new GraphQLScalarType({
name: 'Date',
description: 'Tipo escalar personalizado de fecha',
serialize: (value: Date) => value.toISOString(),
parseValue: (value: string) => new Date(value),
parseLiteral: (ast) => {
if (ast.kind === Kind.STRING) {
return new Date(ast.value);
}
return null;
}
});
// Scalares personalizados: sigue el schema builder GraphQL en el repo veloce-ts según tu versión.
// El fragmento anterior muestra la forma típica de un scalar; el cableado depende de GraphQLPlugin.
import { Auth, CurrentUser } from 'veloce-ts';
@Resolver()
export class UserResolver {
@GQLQuery()
@Auth() // Requerir autenticación
async me(@CurrentUser() user: any): Promise<User> {
return user;
}
@GQLMutation()
@Auth()
async updateProfile(
@CurrentUser() currentUser: any,
@Arg('input', UpdateProfileSchema) input: any
): Promise<User> {
// Solo permitir que los usuarios actualicen su propio perfil
if (currentUser.id !== input.userId) {
throw new ForbiddenException('No puedes actualizar otros usuarios');
}
return await userService.update(currentUser.id, input);
}
}
import { NotFoundException, ValidationException } from 'veloce-ts';
@Resolver()
export class UserResolver {
@GQLQuery()
async user(@Arg('id', z.string()) id: string): Promise<User> {
const user = await userService.findById(id);
if (!user) {
throw new NotFoundException(`Usuario con id ${id} no encontrado`);
}
return user;
}
@GQLMutation()
async createUser(@Arg('input', CreateUserSchema) input: any): Promise<User> {
try {
return await userService.create(input);
} catch (error) {
if (error.code === 'DUPLICATE_EMAIL') {
throw new ValidationException('El email ya existe');
}
throw error;
}
}
}
import DataLoader from 'dataloader';
@Resolver()
export class UserResolver {
private userLoader = new DataLoader(async (ids: string[]) => {
const users = await userService.findByIds(ids);
return ids.map(id => users.find(user => user.id === id) || null);
});
@GQLQuery()
async users(): Promise<User[]> {
const userIds = await userService.getAllUserIds();
return await this.userLoader.loadMany(userIds);
}
}
import { Resolver, GQLQuery, GQLMutation, Arg } from 'veloce-ts/graphql';
import { z } from 'zod';
const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
role: z.enum(['admin', 'user', 'guest']),
createdAt: z.date()
});
const CreateUserInput = z.object({
name: z.string().min(1),
email: z.string().email(),
role: z.enum(['admin', 'user', 'guest']).default('user')
});
const UpdateUserInput = CreateUserInput.partial();
@Resolver()
export class UserResolver {
@GQLQuery()
async users(): Promise<User[]> {
return await userService.findAll();
}
@GQLQuery()
async user(@Arg('id', z.string()) id: string): Promise<User | null> {
return await userService.findById(id);
}
@GQLMutation()
async createUser(@Arg('input', CreateUserInput) input: z.infer<typeof CreateUserInput>): Promise<User> {
return await userService.create(input);
}
@GQLMutation()
async updateUser(
@Arg('id', z.string()) id: string,
@Arg('input', UpdateUserInput) input: z.infer<typeof UpdateUserInput>
): Promise<User> {
return await userService.update(id, input);
}
@GQLMutation()
async deleteUser(@Arg('id', z.string()) id: string): Promise<boolean> {
await userService.delete(id);
return true;
}
}
# Consultar todos los usuarios
query GetUsers {
users {
id
name
email
role
createdAt
}
}
# Consultar usuario específico
query GetUser($id: String!) {
user(id: $id) {
id
name
email
role
}
}
# Mutación para crear usuario
mutation CreateUser($input: CreateUserInput!) {
createUser(input: $input) {
id
name
email
role
createdAt
}
}
# Mutación para actualizar usuario
mutation UpdateUser($id: String!, $input: UpdateUserInput!) {
updateUser(id: $id, input: $input) {
id
name
email
role
}
}
# Mutación para eliminar usuario
mutation DeleteUser($id: String!) {
deleteUser(id: $id)
}
{
"id": "user-123",
"input": {
"name": "Juan Pérez",
"email": "juan@ejemplo.com",
"role": "user"
}
}
// Bueno: Tipado fuerte con Zod
const CreateUserSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
age: z.number().int().min(0).max(150)
});
@GQLMutation()
async createUser(@Arg('input', CreateUserSchema) input: z.infer<typeof CreateUserSchema>) {
// input está completamente tipado y validado
}
@GQLQuery()
async user(@Arg('id', z.string()) id: string): Promise<User> {
try {
const user = await userService.findById(id);
if (!user) {
throw new NotFoundException('Usuario no encontrado');
}
return user;
} catch (error) {
// Registrar error para depuración
console.error('Error al obtener usuario:', error);
throw error;
}
}
// Evitar consultas N+1
@Resolver()
export class PostResolver {
private userLoader = new DataLoader(async (userIds: string[]) => {
return await userService.findByIds(userIds);
});
@GQLQuery()
async posts(): Promise<Post[]> {
const posts = await postService.findAll();
// Cargar usuarios eficientemente
const users = await this.userLoader.loadMany(
posts.map(post => post.authorId)
);
return posts.map(post => ({
...post,
author: users.find(user => user?.id === post.authorId)
}));
}
}