05Project
Vue Query example
The task list of the React Query example, built with Vue 3 single-file components, TanStack Vue Query and Valibot validation, from the same OpenAPI 3.1 document (openapi.yaml).
pnpm example:vue-query # from the repository root: build swaggerz, generate, start ViteOr, inside this directory once swaggerz is built:
pnpm generate # src/generated/apipnpm dev # http://localhost:5173, served by an in-memory mock API (mock-api.ts)pnpm check # type-checks the app (vue-tsc) and builds itWhat to look at:
- swaggerz.config.ts enables Vue Query composables (
features.query.framework: 'vue'), an infinite list forlistTaskspaginated by itscursorparameter, invalidation of everyTasksquery after mutations, and Valibot validation of responses. - src/App.vue passes a getter as the parameters of
useListTasksInfinite, so changing the filter refetches; src/TaskDetails.vue does the same with a prop and usesselect. Errors are typed:ApiErrorwith the operation’s error body (Problem) orRequestError. - mock-api.ts implements the API with the generated model types, so it stops compiling when the specification changes.
Config
import { defineConfig } from 'swaggerz';
export default defineConfig({ spec: { version: '3.1', input: './openapi.yaml' }, output: { directory: './src/generated/api' }, client: { baseUrl: '/api' }, features: { query: { framework: 'vue', // Load more tasks with `useListTasksInfinite`, driven by the `cursor` query parameter. infinite: { listTasks: { pageParam: 'cursor' } }, // Refresh every task query after a change. invalidates: { createTask: ['Tasks'], updateTask: ['Tasks'], deleteTask: ['Tasks'] }, }, // Check responses at runtime against the specification with Valibot. validation: 'valibot', },});Sources
<script setup lang="ts">import { ref } from 'vue';import type { TaskStatus } from './generated/api';import { useListTasksInfinite } from './generated/api/vue-query';import { errorMessage } from './errors.ts';import NewTaskForm from './NewTaskForm.vue';import TaskDetails from './TaskDetails.vue';import TaskItem from './TaskItem.vue';
const STATUSES: readonly TaskStatus[] = ['todo', 'doing', 'done'];const status = ref<TaskStatus | undefined>(undefined);const selected = ref<string | undefined>(undefined);
// Parameters may be getters: changing the filter refetches, and every filter is cached// separately because every parameter is part of the query key.const tasks = useListTasksInfinite(() => ({ status: status.value, limit: 2 }), { initialPageParam: undefined, getNextPageParam: (page) => page.nextCursor,});
function setStatus(event: Event) { const value = (event.target as HTMLSelectElement).value; status.value = STATUSES.find((s) => s === value);}</script>
<template> <main style="font-family: system-ui; max-width: 640px; margin: 2rem auto; padding: 0 1rem"> <h1>Tasks</h1> <NewTaskForm /> <p> Show: <select name="status" :value="status ?? ''" @change="setStatus"> <option value="">all</option> <option v-for="s in STATUSES" :key="s" :value="s">{{ s }}</option> </select> </p> <p v-if="tasks.isPending.value">Loading…</p> <p v-if="tasks.error.value" role="alert">{{ errorMessage(tasks.error.value) }}</p> <ul> <template v-for="page in tasks.data.value?.pages ?? []"> <TaskItem v-for="task in page.items" :key="task.id" :task="task" @select="selected = task.id" /> </template> </ul> <button v-if="tasks.hasNextPage.value" :disabled="tasks.isFetchingNextPage.value" @click="tasks.fetchNextPage()" > {{ tasks.isFetchingNextPage.value ? 'Loading…' : 'Load more' }} </button> <TaskDetails v-if="selected !== undefined" :task-id="selected" /> </main></template><script setup lang="ts">import { ref } from 'vue';import { useCreateTask } from './generated/api/vue-query';import { errorMessage } from './errors.ts';
const title = ref('');// `invalidates` in swaggerz.config.ts refreshes the task lists after the mutation succeeds.const createTask = useCreateTask();
function submit() { createTask.mutate({ body: { title: title.value } }, { onSuccess: () => (title.value = '') });}</script>
<template> <form @submit.prevent="submit"> <input v-model="title" name="title" placeholder="New task" /> <button :disabled="createTask.isPending.value">Add</button> <p v-if="createTask.error.value" role="alert">{{ errorMessage(createTask.error.value) }}</p> </form></template><script setup lang="ts">import { useGetTask } from './generated/api/vue-query';import { errorMessage } from './errors.ts';
const props = defineProps<{ taskId: string }>();
// A getter keeps the query in step with the prop; `select` narrows the cached data and its// result type is inferred.const created = useGetTask(() => ({ taskId: props.taskId }), { select: (task) => new Date(task.createdAt).toLocaleString(),});const notes = useGetTask(() => ({ taskId: props.taskId }), { select: (task) => task.notes ?? 'No notes',});</script>
<template> <p v-if="created.error.value" role="alert">{{ errorMessage(created.error.value) }}</p> <aside v-else> <h2>Details</h2> <p>Created: {{ created.data.value ?? '…' }}</p> <p>{{ notes.data.value }}</p> </aside></template><script setup lang="ts">import { computed } from 'vue';import type { Task, TaskStatus } from './generated/api';import { useDeleteTask, useUpdateTask } from './generated/api/vue-query';
const props = defineProps<{ task: Task }>();defineEmits<{ select: [] }>();
const updateTask = useUpdateTask();const deleteTask = useDeleteTask();const next = computed<TaskStatus>(() => (props.task.status === 'todo' ? 'doing' : 'done'));</script>
<template> <li> <button @click="$emit('select')">{{ task.title }}</button> <small>{{ task.status }}</small> <button v-if="task.status !== 'done'" @click="updateTask.mutate({ taskId: task.id, body: { status: next } })" > → {{ next }} </button> <button @click="deleteTask.mutate({ taskId: task.id })">Delete</button> </li></template>import { isApiError, type OperationError } from './generated/api';
/** A readable message for any error an operation can reject with. */export function errorMessage(error: OperationError<unknown>): string { if (isApiError(error)) { const body = error.body as { title?: unknown } | undefined; return typeof body?.title === 'string' ? body.title : `HTTP ${error.status}`; } // RequestError: network failure, timeout, or a response that failed Valibot validation. return `${error.kind}: ${error.message}`;}import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query';import { createApp } from 'vue';import App from './App.vue';
const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 10_000 } } });
createApp(App).use(VueQueryPlugin, { queryClient }).mount('#root');Generated files
34 files for 5 operations and 6 models, generated with this config when the site was built:
client.ts, index.tsmodels/6 filesruntime/7 filestasks/5 filesvalibot/7 filesvue-query/7 files