# API Clients

Configured axios instances with interceptors for auth, error handling, and request/response transformation.

## Base API Client Setup

### Step 1: Create the Base Client

```typescript
// src/config/apiClient.ts
import axios, { AxiosError } from 'axios';

const apiClient = axios.create({
  baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000',
  timeout: 30000,
  headers: { 'Content-Type': 'application/json' }
});

// Request interceptor: inject auth token
apiClient.interceptors.request.use(
  (config) => {
    const token = localStorage.getItem('authToken');
    if (token) config.headers.Authorization = `Bearer ${token}`;
    return config;
  },
  (error) => Promise.reject(error)
);

// Response interceptor: handle errors
apiClient.interceptors.response.use(
  (response) => response,
  (error: AxiosError) => {
    if (error.response?.status === 401) {
      localStorage.removeItem('authToken');
      window.location.href = '/login';
    }
    return Promise.reject(error);
  }
);

export { apiClient };
```

### Step 2: Environment Configuration

```bash
# .env.development
VITE_API_URL=http://localhost:3000
VITE_TIMEOUT=30000

# .env.production
VITE_API_URL=https://api.production.com
VITE_TIMEOUT=30000

# .env.staging
VITE_API_URL=https://api.staging.com
VITE_TIMEOUT=30000
```

> **Note:** Vite requires the `VITE_` prefix for environment variables exposed to client code. Variables without this prefix are not available via `import.meta.env`.

### Step 3: Use in Repositories

```typescript
// src/services/repositories/PolicyRepository.ts
import { apiClient } from '@/config/apiClient';

class PolicyRepository {
  private baseUrl = '/api/policies';

  async getById(id: string, signal?: AbortSignal): Promise<Policy> {
    // apiClient automatically adds auth headers and handles errors
    const response = await apiClient.get(`${this.baseUrl}/${id}`, {
      signal
    });
    return response.data;
  }
}
```

## Advanced Patterns

### Pattern 1: Multiple API Clients

When your app needs to communicate with multiple backend services:

```typescript
// src/config/apiClients.ts

// Main API client
export const apiClient = axios.create({
  baseURL: import.meta.env.VITE_API_URL,
  timeout: 30000
});

// Payment service client
export const paymentClient = axios.create({
  baseURL: import.meta.env.VITE_PAYMENT_API_URL,
  timeout: 60000,  // Longer timeout for payment processing
  headers: {
    'X-API-Key': import.meta.env.VITE_PAYMENT_API_KEY
  }
});

// Analytics client
export const analyticsClient = axios.create({
  baseURL: import.meta.env.VITE_ANALYTICS_URL,
  timeout: 10000
});

// Add interceptors to each client
[apiClient, paymentClient, analyticsClient].forEach((client) => {
  client.interceptors.request.use(addAuthHeader);
  client.interceptors.response.use(handleSuccess, handleError);
});
```

### Pattern 2: Typed Error Handling

```typescript
// src/types/api.ts
export interface ApiError {
  message: string;
  code: string;
  field?: string;
  details?: Record<string, unknown>;
}

export class ApiException extends Error {
  constructor(
    public statusCode: number,
    public error: ApiError,
    public originalError: AxiosError
  ) {
    super(error.message);
    this.name = 'ApiException';
  }
}

// src/config/apiClient.ts
apiClient.interceptors.response.use(
  (response) => response,
  (error: AxiosError<ApiError>) => {
    if (error.response) {
      // Server responded with error
      throw new ApiException(
        error.response.status,
        error.response.data,
        error
      );
    }

    if (error.request) {
      // Request made but no response
      throw new ApiException(
        0,
        {
          message: 'Network error',
          code: 'NETWORK_ERROR'
        },
        error
      );
    }

    // Something else happened
    throw new ApiException(
      0,
      {
        message: error.message,
        code: 'UNKNOWN_ERROR'
      },
      error
    );
  }
);
```

### Pattern 3: Request Retry Logic

```typescript
// src/config/apiClient.ts
import axiosRetry from 'axios-retry';

const apiClient = axios.create({
  baseURL: import.meta.env.VITE_API_URL
});

// Retry failed requests up to 3 times
axiosRetry(apiClient, {
  retries: 3,
  retryDelay: axiosRetry.exponentialDelay,
  retryCondition: (error) => {
    // Retry on network errors or 5xx errors
    return (
      axiosRetry.isNetworkOrIdempotentRequestError(error) ||
      (error.response?.status ?? 0) >= 500
    );
  }
});
```

### Pattern 4: Request Deduplication

```typescript
// src/config/apiClient.ts
import axios, { AxiosRequestConfig } from 'axios';

const pendingRequests = new Map<string, Promise<unknown>>();

function generateRequestKey(config: AxiosRequestConfig): string {
  return `${config.method}:${config.url}:${JSON.stringify(config.params)}`;
}

apiClient.interceptors.request.use((config) => {
  const requestKey = generateRequestKey(config);

  // If identical request is pending, return cached promise
  if (pendingRequests.has(requestKey)) {
    return Promise.reject({
      __CANCEL__: true,
      message: 'Duplicate request',
      cachedPromise: pendingRequests.get(requestKey)
    });
  }

  return config;
});

apiClient.interceptors.response.use(
  (response) => {
    const requestKey = generateRequestKey(response.config);
    pendingRequests.delete(requestKey);
    return response;
  },
  (error) => {
    const requestKey = generateRequestKey(error.config);
    pendingRequests.delete(requestKey);
    return Promise.reject(error);
  }
);
```

### Pattern 5: Progress Tracking for Uploads

```typescript
// src/services/repositories/DocumentRepository.ts
import { apiClient } from '@/config/apiClient';

class DocumentRepository {
  async upload(
    file: File,
    onProgress?: (progress: number) => void,
    signal?: AbortSignal
  ): Promise<Document> {
    const formData = new FormData();
    formData.append('file', file);

    const response = await apiClient.post('/api/documents/upload', formData, {
      signal,
      headers: {
        'Content-Type': 'multipart/form-data'
      },
      onUploadProgress: (progressEvent) => {
        if (progressEvent.total) {
          const progress = Math.round(
            (progressEvent.loaded * 100) / progressEvent.total
          );
          onProgress?.(progress);
        }
      }
    });

    return response.data;
  }
}

// Usage in component
export const DocumentUpload = () => {
  const [progress, setProgress] = useState(0);

  const handleUpload = async (file: File) => {
    await documentRepository.upload(file, setProgress);
  };

  return (
    <div>
      <DBoxFile onChange={(files) => handleUpload(files[0])} />
      {progress > 0 && <DProgressBar value={progress} />}
    </div>
  );
};
```

## Mocking API Clients

### Method 1: Vitest Manual Mocks

```typescript
// src/config/__mocks__/apiClient.ts
import { vi } from 'vitest';

export const apiClient = {
  get: vi.fn(),
  post: vi.fn(),
  put: vi.fn(),
  delete: vi.fn(),
  interceptors: {
    request: { use: vi.fn() },
    response: { use: vi.fn() }
  }
};
```

```typescript
// src/services/repositories/__tests__/PolicyRepository.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { apiClient } from '@/config/apiClient';
import { policyRepository } from '../PolicyRepository';

vi.mock('@/config/apiClient');

describe('PolicyRepository', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('should fetch policy by id', async () => {
    const mockPolicy = { id: '123', number: 'POL-001' };

    vi.mocked(apiClient.get).mockResolvedValue({
      data: mockPolicy
    });

    const result = await policyRepository.getById('123');

    expect(apiClient.get).toHaveBeenCalledWith('/api/policies/123', {
      signal: undefined
    });
    expect(result).toEqual(mockPolicy);
  });
});
```

### Method 2: MSW (Mock Service Worker)

```typescript
// src/mocks/handlers.ts
import { rest } from 'msw';
export const handlers = [
  rest.get('/api/policies/:id', (req, res, ctx) =>
    res(ctx.status(200), ctx.json({ id: req.params.id, number: 'POL-001', status: 'active' }))
  ),
  rest.post('/api/policies', async (req, res, ctx) =>
    res(ctx.status(201), ctx.json({ id: 'NEW-ID', ...(await req.json()) }))
  ),
];

// src/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);

// src/setupTests.ts
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
```

Override handlers in tests with `server.use(rest.get(...))` for error scenarios.

## Authentication Patterns

### Pattern 1: Token Refresh

```typescript
// src/config/apiClient.ts
let isRefreshing = false;
let failedQueue: Array<{
  resolve: (token: string) => void;
  reject: (error: unknown) => void;
}> = [];

const processQueue = (error: unknown, token: string | null = null) => {
  failedQueue.forEach((prom) => {
    if (error) {
      prom.reject(error);
    } else {
      prom.resolve(token!);
    }
  });

  failedQueue = [];
};

apiClient.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config;

    // If 401 and we haven't tried refreshing yet
    if (error.response?.status === 401 && !originalRequest._retry) {
      if (isRefreshing) {
        // Queue this request until token is refreshed
        return new Promise((resolve, reject) => {
          failedQueue.push({ resolve, reject });
        })
          .then((token) => {
            originalRequest.headers.Authorization = `Bearer ${token}`;
            return apiClient(originalRequest);
          })
          .catch((err) => Promise.reject(err));
      }

      originalRequest._retry = true;
      isRefreshing = true;

      try {
        const refreshToken = localStorage.getItem('refreshToken');
        const response = await axios.post('/api/auth/refresh', {
          refreshToken
        });

        const { accessToken } = response.data;

        localStorage.setItem('authToken', accessToken);
        apiClient.defaults.headers.common.Authorization = `Bearer ${accessToken}`;

        processQueue(null, accessToken);

        originalRequest.headers.Authorization = `Bearer ${accessToken}`;
        return apiClient(originalRequest);
      } catch (err) {
        processQueue(err, null);
        localStorage.removeItem('authToken');
        localStorage.removeItem('refreshToken');
        window.location.href = '/login';
        return Promise.reject(err);
      } finally {
        isRefreshing = false;
      }
    }

    return Promise.reject(error);
  }
);
```

### Pattern 2: OAuth2 Integration

Create an `OAuthClient` class that reads `VITE_OAUTH_CLIENT_ID`, `VITE_OAUTH_CLIENT_SECRET`, and `VITE_OAUTH_TOKEN_ENDPOINT` from env vars, then provides `getAccessToken(code)` and `refreshAccessToken(refreshToken)` methods that POST to the token endpoint with appropriate `grant_type`.

## Best Practices

1. **Use `import.meta.env.VITE_*`** for base URLs — never hardcode
2. **Set timeouts**: 30s for normal requests, 300s for uploads. Never omit timeout.
3. **Handle all error cases** in response interceptor: `error.response` (server error), `error.request` (network error), and setup errors. Always `return Promise.reject(error)`.
4. **Never hardcode tokens** — inject via request interceptor from localStorage
5. **Type responses**: `apiClient.get<Policy>('/api/policies/123')`

## Common Mistakes

### ❌ Mistake 1: Creating Multiple Instances

```typescript
// ❌ WRONG - New instance every time
export function getApiClient() {
  return axios.create({
    baseURL: import.meta.env.VITE_API_URL
  });
}

// Interceptors won't work consistently
getApiClient().interceptors.request.use(addAuth);

// ✅ CORRECT - Singleton instance
export const apiClient = axios.create({
  baseURL: import.meta.env.VITE_API_URL
});

apiClient.interceptors.request.use(addAuth);
```

### ❌ Mistake 2: Not Handling AbortSignal

```typescript
// ❌ WRONG - No cancellation support
async getById(id: string): Promise<Policy> {
  const response = await apiClient.get(`/api/policies/${id}`);
  return response.data;
}

// ✅ CORRECT - Pass signal to axios
async getById(id: string, signal?: AbortSignal): Promise<Policy> {
  const response = await apiClient.get(`/api/policies/${id}`, { signal });
  return response.data;
}
```

### ❌ Mistake 3: Catching Errors in Interceptor

```typescript
// ❌ WRONG - Swallowing errors
apiClient.interceptors.response.use(
  (response) => response,
  (error) => {
    console.error('Error:', error);
    // Returns undefined instead of rejecting!
  }
);

// ✅ CORRECT - Re-throw errors
apiClient.interceptors.response.use(
  (response) => response,
  (error) => {
    console.error('Error:', error);
    return Promise.reject(error);
  }
);
```

## File Structure

```
src/
├── config/
│   ├── apiClient.ts              # Main API client
│   ├── paymentClient.ts          # Payment service client
│   └── __mocks__/
│       └── apiClient.ts          # Vitest mock
├── mocks/
│   ├── handlers.ts               # MSW handlers
│   └── server.ts                 # MSW server setup
└── types/
    └── api.ts                    # API types and errors
```

