Skip to main content

Snippets JavaScript / TypeScript

Client fetch minimal

const BASE = 'https://api.wethehivers.com/v1/api';

export async function api<T = unknown>(
  path: string,
  init: RequestInit & { token?: string } = {},
): Promise<T> {
  const headers = new Headers(init.headers);
  headers.set('Content-Type', 'application/json');
  if (init.token) headers.set('Authorization', `Bearer ${init.token}`);

  const r = await fetch(`${BASE}${path}`, { ...init, headers });
  if (!r.ok) {
    const err = await r.json().catch(() => ({ message: r.statusText }));
    throw Object.assign(new Error(err.message), { status: r.status, body: err });
  }
  return r.status === 204 ? (undefined as T) : r.json();
}

Login + stockage

const { accessToken, refreshToken } = await api<{
  accessToken: string;
  refreshToken: string;
}>('/auth/login', {
  method: 'POST',
  body: JSON.stringify({ email, password }),
});
localStorage.setItem('refresh', refreshToken);

Rechercher des offres

type OffreSearchResult = {
  content: Array<{ id: number; titre: string; slug: string }>;
  totalElements: number;
  totalPages: number;
};

const data = await api<OffreSearchResult>(
  '/offres/search?q=java&typeContrat=CDI&page=0&size=20',
);

Upload CV (FormData)

async function uploadCV(file: File, token: string) {
  const form = new FormData();
  form.append('file', file);
  const r = await fetch(`${BASE}/candidats/me/cv`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${token}` },
    body: form,
  });
  if (!r.ok) throw new Error(`Upload ${r.status}`);
  return r.json();
}

Upload avec progression

function uploadCVWithProgress(
  file: File,
  token: string,
  onProgress: (pct: number) => void,
): Promise<{ cvId: number }> {
  return new Promise((resolve, reject) => {
    const form = new FormData();
    form.append('file', file);
    const xhr = new XMLHttpRequest();
    xhr.open('POST', `${BASE}/candidats/me/cv`);
    xhr.setRequestHeader('Authorization', `Bearer ${token}`);
    xhr.upload.onprogress = (e) => {
      if (e.lengthComputable) onProgress(e.loaded / e.total);
    };
    xhr.onload = () => (xhr.status < 400 ? resolve(JSON.parse(xhr.response)) : reject(xhr));
    xhr.onerror = () => reject(new Error('Network error'));
    xhr.send(form);
  });
}

Token manager avec auto-refresh

class TokenManager {
  private access: string | null = null;
  private refresh = localStorage.getItem('refresh');
  private pending: Promise<string> | null = null;

  async getValidAccess(): Promise<string> {
    if (this.access && !this.isExpired(this.access)) return this.access;
    if (!this.pending) this.pending = this.doRefresh();
    return this.pending;
  }

  private async doRefresh(): Promise<string> {
    if (!this.refresh) throw new Error('No refresh');
    const res = await fetch(`${BASE}/auth/refresh-token`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ refreshToken: this.refresh }),
    });
    if (!res.ok) {
      this.logout();
      throw new Error('Refresh failed');
    }
    const { accessToken, refreshToken } = await res.json();
    this.access = accessToken;
    this.refresh = refreshToken;
    localStorage.setItem('refresh', refreshToken);
    this.pending = null;
    return accessToken;
  }

  private isExpired(token: string): boolean {
    const { exp } = JSON.parse(atob(token.split('.')[1]));
    return Date.now() >= (exp - 30) * 1000;
  }

  logout() {
    this.access = null;
    this.refresh = null;
    localStorage.removeItem('refresh');
  }
}

Retry avec backoff (rate limit)

async function fetchWithBackoff(path: string, init: RequestInit, retries = 3) {
  for (let i = 0; i <= retries; i++) {
    const r = await fetch(`${BASE}${path}`, init);
    if (r.status !== 429) return r;
    const wait = parseInt(r.headers.get('Retry-After') || '2', 10);
    await new Promise((res) => setTimeout(res, (wait + 2 ** i) * 1000));
  }
  throw new Error('Rate limit persistent');
}

Axios (alternative)

import axios from 'axios';

const api = axios.create({
  baseURL: 'https://api.wethehivers.com/v1/api',
});

api.interceptors.request.use(async (config) => {
  const token = await tokens.getValidAccess();
  config.headers.Authorization = `Bearer ${token}`;
  return config;
});

api.interceptors.response.use(
  (r) => r,
  async (err) => {
    if (err.response?.status === 401) {
      await tokens.doRefresh();
      return api.request(err.config);
    }
    throw err;
  },
);

Types type-safe depuis OpenAPI

npm install -D openapi-typescript
npx openapi-typescript https://api.wethehivers.com/v3/api-docs -o src/api-types.ts
import type { paths } from './api-types';

type LoginBody = paths['/auth/login']['post']['requestBody']['content']['application/json'];
type LoginResponse =
  paths['/auth/login']['post']['responses']['200']['content']['application/json'];

const login = async (body: LoginBody): Promise<LoginResponse> =>
  api<LoginResponse>('/auth/login', { method: 'POST', body: JSON.stringify(body) });

React Hook : useOffres

import { useQuery } from '@tanstack/react-query';

export function useOffres(query: string, filters: Record<string, string>) {
  return useQuery({
    queryKey: ['offres', query, filters],
    queryFn: () => {
      const params = new URLSearchParams({ q: query, ...filters });
      return api(`/offres/search?${params}`);
    },
    staleTime: 30_000,
  });
}

Flow complet

Voir aussi