Especialização em Front-End com Vue • Demonstrações Práticas Compiláveis


💻 Código de Exemplo 15 — Camada HTTP Desacoplada, Interceptors de JWT e Cancelamento com AbortController

Este exemplo demonstra uma arquitetura corporativa para consumo de APIs REST no Vue 3 utilizando uma instância Axios com interceptores para injeção de token Bearer, renovação automática de refresh token em respostas HTTP 401 e cancelamento de requisições pendentes com AbortController.

1. Cliente HTTP Corporativo: src/services/apiClient.ts

import axios, { type AxiosInstance, type InternalAxiosRequestConfig } from 'axios';

export const apiClient: AxiosInstance = axios.create({
  baseURL: 'https://api.empresa.com.br/v1',
  timeout: 10000,
  headers: {
    'Content-Type': 'application/json',
    Accept: 'application/json'
  }
});

// Interceptor de Requisição: Injeta Token JWT
apiClient.interceptors.request.use(
  (config: InternalAxiosRequestConfig) => {
    const token = localStorage.getItem('auth_access_token');
    if (token && config.headers) {
      config.headers.Authorization = `Bearer ${token}`;
    }
    return config;
  },
  (error) => Promise.reject(error)
);

// Interceptor de Resposta: Trata Erros e Renovação 401
let isRefreshing = false;
let failedQueue: Array<{ resolve: (token: string) => void; reject: (err: any) => void }> = [];

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

    if (error.response?.status === 401 && !originalRequest._retry) {
      if (isRefreshing) {
        return new Promise((resolve, reject) => {
          failedQueue.push({ resolve, reject });
        }).then((token) => {
          originalRequest.headers.Authorization = `Bearer ${token}`;
          return apiClient(originalRequest);
        });
      }

      originalRequest._retry = true;
      isRefreshing = true;

      try {
        const refreshToken = localStorage.getItem('auth_refresh_token');
        const res = await axios.post('https://api.empresa.com.br/v1/auth/refresh', { refreshToken });
        const newAccessToken = res.data.accessToken;

        localStorage.setItem('auth_access_token', newAccessToken);
        originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;

        failedQueue.forEach((prom) => prom.resolve(newAccessToken));
        failedQueue = [];
        return apiClient(originalRequest);
      } catch (refreshErr) {
        failedQueue.forEach((prom) => prom.reject(refreshErr));
        failedQueue = [];
        localStorage.removeItem('auth_access_token');
        window.location.href = '/login';
        return Promise.reject(refreshErr);
      } finally {
        isRefreshing = false;
      }
    }

    return Promise.reject(error);
  }
);

2. Composable de Consumo com Cancelamento: src/composables/useApiQuery.ts

import { ref, onScopeDispose, type Ref } from 'vue';
import { apiClient } from '../services/apiClient';

interface QueryResult<T> {
  data: Ref<T | null>;
  isLoading: Ref<boolean>;
  error: Ref<string | null>;
  execute: () => Promise<void>;
  cancel: () => void;
}

export function useApiQuery<T>(url: string): QueryResult<T> {
  const data = ref<T | null>(null) as Ref<T | null>;
  const isLoading = ref<boolean>(false);
  const error = ref<string | null>(null);

  let abortController: AbortController | null = null;

  const execute = async (): Promise<void> => {
    // Cancela requisição em andamento anterior se houver
    if (abortController) {
      abortController.abort();
    }
    abortController = new AbortController();

    isLoading.value = true;
    error.value = null;

    try {
      const response = await apiClient.get<T>(url, {
        signal: abortController.signal
      });
      data.value = response.data;
    } catch (err: any) {
      if (err.name === 'CanceledError' || err.code === 'ERR_CANCELED') {
        console.log(`[useApiQuery] Requisição para "${url}" foi cancelada pelo usuário.`);
      } else {
        error.value = err.response?.data?.message || err.message || 'Falha de comunicação.';
      }
    } finally {
      isLoading.value = false;
    }
  };

  const cancel = (): void => {
    if (abortController) {
      abortController.abort();
    }
  };

  onScopeDispose(() => {
    cancel();
  });

  return { data, isLoading, error, execute, cancel };
}

3. Componente de Visualização: OrderSearchCatalog.vue

<!-- src/components/OrderSearchCatalog.vue -->
<script setup lang="ts">
import { onMounted } from 'vue';
import { useApiQuery } from '../composables/useApiQuery';

interface OrderItem {
  id: string;
  customer: string;
  total: number;
  status: 'PENDING' | 'CONFIRMED' | 'SHIPPED';
}

const { data: orders, isLoading, error, execute, cancel } = useApiQuery<OrderItem[]>('/orders');

onMounted(() => {
  execute();
});
</script>

<template>
  <div class="max-w-xl mx-auto p-6 bg-white border rounded-2xl shadow-sm space-y-4">
    <div class="flex items-center justify-between border-b pb-3">
      <h3 class="font-bold text-slate-800">Ordens de Serviço (REST API)</h3>
      <div class="flex gap-2">
        <button
          v-if="isLoading"
          @click="cancel"
          class="text-xs px-3 py-1.5 bg-red-50 text-red-600 rounded-lg font-semibold"
        >
          Cancelar Pedido HTTP
        </button>
        <button
          :disabled="isLoading"
          @click="execute"
          class="text-xs px-3 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg font-semibold disabled:opacity-50"
        >
          {{ isLoading ? 'Buscando...' : 'Atualizar Dados' }}
        </button>
      </div>
    </div>

    <div v-if="isLoading" class="p-8 text-center text-xs text-slate-500 space-y-2">
      <div class="w-6 h-6 border-2 border-indigo-600 border-t-transparent rounded-full animate-spin mx-auto"></div>
      <p>Consultando gateway corporativo com interceptor JWT...</p>
    </div>

    <div v-else-if="error" class="p-4 bg-red-50 border border-red-200 text-red-700 text-xs rounded-xl">
      ⚠️ Erro retornado pela API: {{ error }}
    </div>

    <div v-else-if="orders && orders.length > 0" class="divide-y border rounded-xl overflow-hidden text-xs">
      <div v-for="order in orders" :key="order.id" class="p-3 flex justify-between items-center hover:bg-slate-50">
        <div>
          <span class="font-bold text-slate-800">{{ order.customer }}</span>
          <p class="text-slate-400 font-mono text-[11px]">{{ order.id }}</p>
        </div>
        <div class="text-right">
          <div class="font-bold text-slate-900">R$ {{ order.total.toFixed(2) }}</div>
          <span class="px-2 py-0.5 rounded text-[10px] font-semibold bg-slate-100 text-slate-700">
            {{ order.status }}
          </span>
        </div>
      </div>
    </div>
  </div>
</template>

🖥️ Saída / Comportamento Esperado

Ciclo de Execução:
1. onMounted dispara execute():
   - Interceptor injeta cabeçalho "Authorization: Bearer <jwt_access_token>".
   - Enquanto aguarda resposta, exibe spinner animado.
   
2. Cenário de Token Expirado (HTTP 401):
   - Response interceptor captura status 401.
   - Enfileira requisições simultâneas em 'failedQueue'.
   - Dispara chamada atômica para '/auth/refresh'.
   - Atualiza token no localStorage e reexecuta todas as requisições originais de forma transparente ao usuário.

3. Cancelamento pelo Usuário:
   - Se o usuário clicar em "Cancelar Pedido HTTP" antes do timeout:
   - abortController.abort() é disparado.
   - Axios rejeita com ERR_CANCELED sem poluir o estado de 'error'.

🔍 Destaques da Implementação

  1. Fila de Concorrência para Refresh Token: Impede rajada de requisições de renovação simultâneas quando múltiplos componentes disparam queries ao mesmo tempo.
  2. AbortController Integrado: Cancela requisições ativas se o componente for desmontado antes do retorno (onScopeDispose).
  3. Desacoplamento em Camadas: O componente Vue apenas consome os dados e estados (isLoading, error) sem conhecimento de URLs ou headers.