Files
attune/web/MIGRATION-TO-GENERATED-CLIENT.md
2026-02-04 17:46:30 -06:00

10 KiB

Migration Guide: Using Generated API Client

This guide shows how to migrate from manual Axios API calls to the auto-generated OpenAPI client.

Why Migrate?

Type Safety - Catch API schema mismatches at compile time
Auto-completion - Full IDE support for all API methods
Automatic Updates - Regenerate when backend changes
Less Code - No manual type definitions needed
Fewer Bugs - Schema validation prevents runtime errors

Quick Start

1. Import Services Instead of apiClient

Before (Manual):

import { apiClient } from '@/lib/api-client';

After (Generated):

import { AuthService, PacksService, ActionsService } from '@/api';

2. Use Service Methods Instead of HTTP Verbs

Before (Manual):

const response = await apiClient.get('/api/v1/packs', {
  params: { page: 1, page_size: 50 }
});

After (Generated):

const response = await PacksService.listPacks({
  page: 1,
  pageSize: 50
});

Real-World Examples

Authentication (AuthContext)

Before:

// src/contexts/AuthContext.tsx (OLD)
import { apiClient } from "@/lib/api-client";
import type { User, LoginRequest, LoginResponse, ApiResponse } from "@/types/api";

const login = async (credentials: LoginRequest) => {
  const response = await apiClient.post<ApiResponse<LoginResponse>>(
    "/auth/login",
    credentials
  );
  
  const { access_token, refresh_token } = response.data.data;
  localStorage.setItem("access_token", access_token);
  localStorage.setItem("refresh_token", refresh_token);
};

const loadUser = async () => {
  const response = await apiClient.get<ApiResponse<User>>("/auth/me");
  setUser(response.data.data);
};

After:

// src/contexts/AuthContext.tsx (NEW)
import { AuthService } from "@/api";
import type { UserInfo } from "@/api"; // Types are generated!

const login = async (credentials: { login: string; password: string }) => {
  const response = await AuthService.login({
    requestBody: credentials
  });
  
  const { access_token, refresh_token } = response.data;
  localStorage.setItem("access_token", access_token);
  localStorage.setItem("refresh_token", refresh_token);
};

const loadUser = async () => {
  const response = await AuthService.getCurrentUser();
  setUser(response.data);
};

Pack Management

Before:

// Manual pack API calls
import { apiClient } from '@/lib/api-client';

interface Pack {
  id: number;
  ref: string;
  label: string;
  // ... manual type definition
}

const fetchPacks = async () => {
  const response = await apiClient.get<ApiResponse<PaginatedResponse<Pack>>>(
    '/api/v1/packs',
    { params: { page: 1, page_size: 50 } }
  );
  return response.data.data;
};

const createPack = async (data: any) => {
  const response = await apiClient.post('/api/v1/packs', data);
  return response.data.data;
};

const updatePack = async (ref: string, data: any) => {
  const response = await apiClient.put(`/api/v1/packs/${ref}`, data);
  return response.data.data;
};

const deletePack = async (ref: string) => {
  await apiClient.delete(`/api/v1/packs/${ref}`);
};

After:

// Generated pack service (auto-typed!)
import { PacksService } from '@/api';
import type { CreatePackRequest, UpdatePackRequest } from '@/api';

const fetchPacks = async () => {
  const response = await PacksService.listPacks({
    page: 1,
    pageSize: 50
  });
  return response.data; // Already typed as PaginatedResponse<PackSummary>
};

const createPack = async (data: CreatePackRequest) => {
  const response = await PacksService.createPack({ requestBody: data });
  return response.data; // Already typed as PackResponse
};

const updatePack = async (ref: string, data: UpdatePackRequest) => {
  const response = await PacksService.updatePack({ ref, requestBody: data });
  return response.data;
};

const deletePack = async (ref: string) => {
  await PacksService.deletePack({ ref });
};

React Query Integration

Before:

import { useQuery, useMutation } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';

const { data } = useQuery({
  queryKey: ['packs'],
  queryFn: async () => {
    const response = await apiClient.get('/api/v1/packs');
    return response.data.data;
  }
});

const mutation = useMutation({
  mutationFn: async (data: any) => {
    const response = await apiClient.post('/api/v1/packs', data);
    return response.data.data;
  }
});

After:

import { useQuery, useMutation } from '@tanstack/react-query';
import { PacksService } from '@/api';
import type { CreatePackRequest } from '@/api';

const { data } = useQuery({
  queryKey: ['packs'],
  queryFn: () => PacksService.listPacks({ page: 1, pageSize: 50 })
});

const mutation = useMutation({
  mutationFn: (data: CreatePackRequest) => 
    PacksService.createPack({ requestBody: data })
});

Form Submissions

Before:

const handleSubmit = async (formData: any) => {
  try {
    const response = await apiClient.post('/api/v1/packs', {
      name: formData.name,      // ❌ Wrong field
      system: formData.system   // ❌ Wrong field
    });
    console.log(response.data.data);
  } catch (error) {
    // Runtime error when schema doesn't match!
  }
};

After:

import type { CreatePackRequest } from '@/api';

const handleSubmit = async (formData: CreatePackRequest) => {
  try {
    const response = await PacksService.createPack({
      requestBody: {
        ref: formData.ref,              // ✅ TypeScript enforces correct fields
        label: formData.label,          // ✅ Compile-time validation
        is_standard: formData.is_standard
      }
    });
    console.log(response.data);
  } catch (error) {
    // Caught at compile time!
  }
};

Error Handling

Before:

import { AxiosError } from 'axios';

try {
  await apiClient.get('/api/v1/packs/unknown');
} catch (error) {
  if (error instanceof AxiosError) {
    console.error(error.response?.status);
  }
}

After:

import { ApiError } from '@/api';

try {
  await PacksService.getPack({ ref: 'unknown' });
} catch (error) {
  if (error instanceof ApiError) {
    console.error(`${error.status}: ${error.message}`);
    console.error(error.body); // Response body
  }
}

Migration Checklist

Phase 1: Setup (Already Done)

  • Install openapi-typescript-codegen
  • Add generate:api script to package.json
  • Generate initial API client
  • Create src/lib/api-config.ts for configuration
  • Import config in src/main.tsx

Phase 2: Migrate Core Files

  • Update src/contexts/AuthContext.tsx to use AuthService
  • Update src/types/api.ts to re-export generated types
  • Create custom hooks using generated services

Phase 3: Migrate Pages

  • Update all pack-related pages (PacksPage, PackCreatePage, etc.)
  • Update all action-related pages
  • Update all rule-related pages
  • Update all execution-related pages
  • Update all event-related pages

Phase 4: Cleanup

  • Remove manual API type definitions from src/types/api.ts
  • Remove unused manual API calls
  • Update all import statements
  • Run TypeScript type checking: npm run build
  • Test all workflows end-to-end

Common Patterns

Pattern 1: Create Custom Hooks

// src/hooks/usePacks.ts
import { useQuery } from '@tanstack/react-query';
import { PacksService } from '@/api';

export const usePacks = (page = 1, pageSize = 50) => {
  return useQuery({
    queryKey: ['packs', page, pageSize],
    queryFn: () => PacksService.listPacks({ page, pageSize })
  });
};

export const usePack = (ref: string) => {
  return useQuery({
    queryKey: ['pack', ref],
    queryFn: () => PacksService.getPack({ ref }),
    enabled: !!ref
  });
};

Pattern 2: Type-Safe Form Handling

import { useForm } from 'react-hook-form';
import type { CreatePackRequest } from '@/api';

const PackForm = () => {
  const { register, handleSubmit } = useForm<CreatePackRequest>();
  
  const onSubmit = async (data: CreatePackRequest) => {
    await PacksService.createPack({ requestBody: data });
  };
  
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('ref')} />
      <input {...register('label')} />
      <button type="submit">Create</button>
    </form>
  );
};

Pattern 3: Optimistic Updates

const mutation = useMutation({
  mutationFn: (data: UpdatePackRequest) =>
    PacksService.updatePack({ ref: packRef, requestBody: data }),
  onMutate: async (newData) => {
    await queryClient.cancelQueries({ queryKey: ['pack', packRef] });
    const previous = queryClient.getQueryData(['pack', packRef]);
    queryClient.setQueryData(['pack', packRef], newData);
    return { previous };
  },
  onError: (err, variables, context) => {
    queryClient.setQueryData(['pack', packRef], context?.previous);
  },
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['pack', packRef] });
  }
});

Regenerating After API Changes

When the backend API changes:

  1. Start the API server:

    cd crates/api
    cargo run --bin attune-api
    
  2. Regenerate the client:

    cd web
    npm run generate:api
    
  3. Fix TypeScript errors:

    npm run build
    
  4. Test your changes:

    npm run dev
    

Tips & Best Practices

  1. Always regenerate after backend changes - Keep frontend in sync
  2. Use generated types - Don't create duplicate manual types
  3. Leverage TypeScript - Let the compiler catch schema mismatches
  4. Create custom hooks - Wrap services in React Query hooks for reusability
  5. Don't edit generated files - They'll be overwritten on next generation
  6. Use path aliases - Import as @/api instead of ../../../api

Troubleshooting

"Module not found: @/api"

Add to vite.config.ts:

resolve: {
  alias: {
    '@': '/src'
  }
}

"Property does not exist on type"

The backend schema changed. Regenerate the client:

npm run generate:api

Token not being sent

Make sure src/lib/api-config.ts is imported in src/main.tsx:

import './lib/api-config';

Resources

  • Generated API docs: src/api/README.md
  • OpenAPI spec: http://localhost:8080/docs (Swagger UI)
  • Backend API code: crates/api/src/routes/