Guía de GraphQL
Guía de GraphQL
Sección titulada «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
Tabla de Contenidos
Sección titulada «Tabla de Contenidos»- Resumen
- Configuración
- Resolvers
- Decoradores
- Generación de Esquemas
- Características Avanzadas
- Ejemplos
Resumen
Sección titulada «Resumen»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
Configuración
Sección titulada «Configuración»1. Definir las clases resolver
Sección titulada «1. Definir las clases resolver»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 }; }}2. Registrar el plugin GraphQL
Sección titulada «2. Registrar el plugin GraphQL»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 @Resolverapp.usePlugin(new GraphQLPlugin({ playground: true }));Ambas opciones se pueden combinar; los resolvers se deduplicarán automáticamente.
3. Opciones de GraphQLPlugin
Sección titulada «3. Opciones de GraphQLPlugin»| Opción | Por defecto | Descripción |
|---|---|---|
path | '/graphql' | Ruta HTTP del endpoint GraphQL |
playgroundPath | '/graphql/playground' | Ruta del Playground |
playground | true | Habilitar/deshabilitar el Playground |
resolvers | [] | Clases resolver a incluir en el esquema |
context | (req) => ({ request: req }) | Fábrica de contexto personalizada (async permitido) |
subscriptions | false | Sirve 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.
Resolvers
Sección titulada «Resolvers»Resolver Básico
Sección titulada «Resolver Básico»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); }}Mutaciones
Sección titulada «Mutaciones»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; }}Suscripciones
Sección titulada «Suscripciones»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 } }.
Activar el transporte
Sección titulada «Activar el transporte»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ón | Por defecto | Descripción |
|---|---|---|
path | la ruta de GraphQL | Dó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 |
connectionInitWaitTimeout | 3000 | Cuánto puede tardar un cliente en enviar connection_init antes de cerrarse con 4408 |
Autenticar una suscripción
Sección titulada «Autenticar una suscripción»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), }),}// clientecreateClient({ url: 'ws://localhost:3000/graphql', connectionParams: { token: localStorage.getItem('token') },});const pubsub = new PubSub();
await pubsub.publish('TOPIC', payload); // reparte a todos los suscriptorespubsub.subscribe('TOPIC'); // AsyncIterableIteratorpubsub.subscribe(['ORDERS', 'REFUNDS']); // varios topics a la vezpubsub.subscriberCount('TOPIC');pubsub.close('TOPIC'); // termina todos los suscriptoresEstá 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).
Requisitos y notas por runtime
Sección titulada «Requisitos y notas por runtime»- 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 conapp.listen()— el manejador WebSocket se engancha alhttp.Serverreal, que no existe hasta entonces. - Bun y Deno hacen el upgrade nativamente y no necesitan paquete extra.
- Un gateway de
WebSocketPluginy el endpoint de suscripciones pueden convivir en el mismo servidor.
Limpieza
Sección titulada «Limpieza»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ódigos de cierre
Sección titulada «Códigos de cierre»| Código | Significado |
|---|---|
| 4400 | Petición inválida — mensaje mal formado, tipo desconocido, id/query ausente |
| 4401 | Llegó subscribe antes de que se reconociera connection_init |
| 4403 | onConnect rechazó la conexión |
| 4408 | connection_init no llegó a tiempo |
| 4409 | Ya hay una operación corriendo con ese id |
| 4429 | Más de un connection_init |
Tipos de retorno
Sección titulada «Tipos de retorno»El tipo GraphQL de un campo se resuelve en este orden:
- un tipo de retorno explícito en los metadatos del decorador, si existe;
- el decorador
@Returns(); - en su defecto un escalar
JSONnullable, 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ón | Significado |
|---|---|
name | Nombre del tipo de objeto a emitir para un ZodObject. Por defecto el nombre del campo capitalizado. |
nullable | true quita el !. Por defecto false. |
list | true 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.
Decoradores
Sección titulada «Decoradores»Todos los decoradores GraphQL se importan desde 'veloce-ts/graphql' (también re-exportados desde 'veloce-ts').
Decoradores GraphQL
Sección titulada «Decoradores GraphQL»| Decorador | Propósito | Ejemplo |
|---|---|---|
@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 |
Manejo Avanzado de Argumentos
Sección titulada «Manejo Avanzado de Argumentos»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 }; }}Generación de Esquemas
Sección titulada «Generación de Esquemas»Conversión Automática de Tipos
Sección titulada «Conversión Automática de Tipos»Veloce-TS convierte automáticamente esquemas Zod a tipos GraphQL:
// Esquema Zodconst 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!]!// }Tipos Escalares Personalizados
Sección titulada «Tipos Escalares Personalizados»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.Características Avanzadas
Sección titulada «Características Avanzadas»Integración de Autenticación
Sección titulada «Integración de Autenticación»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); }}Manejo de Errores
Sección titulada «Manejo de Errores»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; } }}Integración DataLoader
Sección titulada «Integración DataLoader»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); }}Ejemplos
Sección titulada «Ejemplos»API Completa de Gestión de Usuarios
Sección titulada «API Completa de Gestión de Usuarios»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; }}Ejemplos de Consultas GraphQL
Sección titulada «Ejemplos de Consultas GraphQL»# Consultar todos los usuariosquery GetUsers { users { id name email role createdAt }}
# Consultar usuario específicoquery GetUser($id: String!) { user(id: $id) { id name email role }}
# Mutación para crear usuariomutation CreateUser($input: CreateUserInput!) { createUser(input: $input) { id name email role createdAt }}
# Mutación para actualizar usuariomutation UpdateUser($id: String!, $input: UpdateUserInput!) { updateUser(id: $id, input: $input) { id name email role }}
# Mutación para eliminar usuariomutation DeleteUser($id: String!) { deleteUser(id: $id)}Ejemplo de Variables
Sección titulada «Ejemplo de Variables»{ "id": "user-123", "input": { "name": "Juan Pérez", "email": "juan@ejemplo.com", "role": "user" }}Mejores Prácticas
Sección titulada «Mejores Prácticas»1. Usar Zod para Validación
Sección titulada «1. Usar Zod para Validación»// Bueno: Tipado fuerte con Zodconst 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}2. Manejar Errores con Gracia
Sección titulada «2. Manejar Errores con Gracia»@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; }}3. Usar DataLoaders para Rendimiento
Sección titulada «3. Usar DataLoaders para Rendimiento»// 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) })); }}Próximos Pasos
Sección titulada «Próximos Pasos»- Ejemplo TaskMaster - Ver GraphQL en acción
- Guía de Autenticación - Asegurar tu API GraphQL
- Guía de WebSockets - Suscripciones en tiempo real
- Guía de Pruebas - Probar tus resolvers GraphQL