Documentación de la API - Endpoints y Ejemplos

Integración con Vue 3

Construye formularios de dirección en Vue 3 con composables para CountryDataAPI

Composables de Vue 3 para integrar fácilmente CountryDataAPI con tu aplicación Vue. Reactivos, tipados y listos para producción.

Descripción General

Esta guía proporciona composables de Vue 3 listos para producción para:

  • useCountries - Cargar todos los países con caché
  • useStates - Cargar estados por ID de país
  • useCities - Cargar ciudades por ID de estado
  • useAddress - Composable combinado para formularios completos

Todos los composables son reactivos, incluyen manejo de errores y soportan completamente TypeScript.

Instalación

¡No se requieren paquetes adicionales! CountryDataAPI funciona con la API fetch nativa.

# Solo instala Vue 3 (si aún no lo has hecho)
npm install vue

Tipos TypeScript

Primero, define las interfaces TypeScript:

// types/address.ts

export interface Country {
  id: string;
  name: string;
}

export interface State {
  id: string;
  name: string;
}

export interface City {
  id: string;
  name: string;
}

export interface ApiResponse<T> {
  success: boolean;
  data: T[];
  error?: {
    code: string;
    message: string;
  };
}

Configuración

Crea un archivo de configuración para tus ajustes de API:

// config/api.ts

export const API_CONFIG = {
  baseUrl: 'https://api.countrydataapi.com/v1',
  apiKey: import.meta.env.VITE_COUNTRY_API_KEY || '',
};

export function buildApiUrl(
  endpoint: string,
  params: Record<string, string>
): string {
  const url = new URL(`${API_CONFIG.baseUrl}${endpoint}`);
  url.searchParams.set('apikey', API_CONFIG.apiKey);

  Object.entries(params).forEach(([key, value]) => {
    if (value) url.searchParams.set(key, value);
  });

  return url.toString();
}

Importante: Guarda tu clave API en el archivo .env como VITE_COUNTRY_API_KEY y ¡nunca la subas al control de versiones!

Crea un archivo .env:

VITE_COUNTRY_API_KEY=tu-clave-api-aqui

Composables

Composable useCountries

Carga todos los países con caché opcional:

// composables/useCountries.ts

import { ref, readonly, onMounted } from 'vue';
import type { Country, ApiResponse } from '../types/address';
import { buildApiUrl } from '../config/api';

const CACHE_KEY = 'countrydataapi_countries';
const CACHE_DURATION = 7 * 24 * 60 * 60 * 1000; // 7 días

interface CachedData {
  data: Country[];
  timestamp: number;
}

export function useCountries(lang: string = 'es', enableCache: boolean = true) {
  const countries = ref<Country[]>([]);
  const loading = ref(true);
  const error = ref<string | null>(null);

  const fetchCountries = async () => {
    loading.value = true;
    error.value = null;

    // Verificar caché primero
    if (enableCache) {
      const cached = localStorage.getItem(CACHE_KEY);
      if (cached) {
        const { data: cachedData, timestamp }: CachedData = JSON.parse(cached);
        if (Date.now() - timestamp < CACHE_DURATION) {
          countries.value = cachedData;
          loading.value = false;
          return;
        }
      }
    }

    try {
      const url = buildApiUrl('/select/countries', { lang });
      const response = await fetch(url);
      const result: ApiResponse<Country> = await response.json();

      if (!result.success) {
        throw new Error(result.error?.message || 'Error al cargar países');
      }

      countries.value = result.data;

      // Cachear el resultado
      if (enableCache) {
        const cacheData: CachedData = {
          data: result.data,
          timestamp: Date.now(),
        };
        localStorage.setItem(CACHE_KEY, JSON.stringify(cacheData));
      }
    } catch (err) {
      error.value = err instanceof Error ? err.message : 'Error de red';
    } finally {
      loading.value = false;
    }
  };

  onMounted(() => {
    fetchCountries();
  });

  return {
    countries: readonly(countries),
    loading: readonly(loading),
    error: readonly(error),
    refetch: fetchCountries,
  };
}

Composable useStates

Carga estados filtrados por país:

// composables/useStates.ts

import { ref, readonly, watch } from 'vue';
import type { Ref } from 'vue';
import type { State, ApiResponse } from '../types/address';
import { buildApiUrl } from '../config/api';

export function useStates(countryId: Ref<string | null>, lang: string = 'es') {
  const states = ref<State[]>([]);
  const loading = ref(false);
  const error = ref<string | null>(null);

  const fetchStates = async () => {
    if (!countryId.value) {
      states.value = [];
      return;
    }

    loading.value = true;
    error.value = null;

    try {
      const url = buildApiUrl('/select/states', {
        country: countryId.value,
        lang,
      });
      const response = await fetch(url);
      const result: ApiResponse<State> = await response.json();

      if (!result.success) {
        throw new Error(result.error?.message || 'Error al cargar estados');
      }

      states.value = result.data;
    } catch (err) {
      error.value = err instanceof Error ? err.message : 'Error de red';
      states.value = [];
    } finally {
      loading.value = false;
    }
  };

  watch(countryId, fetchStates, { immediate: true });

  return {
    states: readonly(states),
    loading: readonly(loading),
    error: readonly(error),
    refetch: fetchStates,
  };
}

Composable useCities

Carga ciudades filtradas por estado:

// composables/useCities.ts

import { ref, readonly, watch } from 'vue';
import type { Ref } from 'vue';
import type { City, ApiResponse } from '../types/address';
import { buildApiUrl } from '../config/api';

export function useCities(stateId: Ref<string | null>, lang: string = 'es') {
  const cities = ref<City[]>([]);
  const loading = ref(false);
  const error = ref<string | null>(null);

  const fetchCities = async () => {
    if (!stateId.value) {
      cities.value = [];
      return;
    }

    loading.value = true;
    error.value = null;

    try {
      const url = buildApiUrl('/select/cities', {
        state: stateId.value,
        lang,
      });
      const response = await fetch(url);
      const result: ApiResponse<City> = await response.json();

      if (!result.success) {
        throw new Error(result.error?.message || 'Error al cargar ciudades');
      }

      cities.value = result.data;
    } catch (err) {
      error.value = err instanceof Error ? err.message : 'Error de red';
      cities.value = [];
    } finally {
      loading.value = false;
    }
  };

  watch(stateId, fetchCities, { immediate: true });

  return {
    cities: readonly(cities),
    loading: readonly(loading),
    error: readonly(error),
    refetch: fetchCities,
  };
}

Componente AddressForm

Ejemplo completo de componente usando todos los composables:

<!-- components/AddressForm.vue -->

<script setup lang="ts">
import { ref, computed } from 'vue';
import { useCountries } from '../composables/useCountries';
import { useStates } from '../composables/useStates';
import { useCities } from '../composables/useCities';

interface AddressFormData {
  country: string;
  state: string;
  city: string;
  street: string;
  zipCode: string;
}

interface Props {
  initialData?: Partial<AddressFormData>;
  lang?: string;
}

const props = withDefaults(defineProps<Props>(), {
  lang: 'es',
});

const emit = defineEmits<{
  submit: [data: AddressFormData];
}>();

// Datos del formulario
const selectedCountry = ref<string>('');
const selectedState = ref<string>('');
const selectedCity = ref<string>('');
const street = ref<string>('');
const zipCode = ref<string>('');

// Inicializar desde props
if (props.initialData) {
  selectedCountry.value = props.initialData.country || '';
  selectedState.value = props.initialData.state || '';
  selectedCity.value = props.initialData.city || '';
  street.value = props.initialData.street || '';
  zipCode.value = props.initialData.zipCode || '';
}

// Usar composables
const {
  countries,
  loading: loadingCountries,
  error: countriesError,
} = useCountries(props.lang);

const {
  states,
  loading: loadingStates,
  error: statesError,
} = useStates(selectedCountry, props.lang);

const {
  cities,
  loading: loadingCities,
  error: citiesError,
} = useCities(selectedState, props.lang);

// Manejadores
const handleCountryChange = () => {
  selectedState.value = '';
  selectedCity.value = '';
};

const handleStateChange = () => {
  selectedCity.value = '';
};

const handleSubmit = () => {
  if (!isFormValid.value) return;

  emit('submit', {
    country: selectedCountry.value,
    state: selectedState.value,
    city: selectedCity.value,
    street: street.value,
    zipCode: zipCode.value,
  });
};

// Computado
const isFormValid = computed(() => {
  return (
    selectedCountry.value &&
    selectedState.value &&
    selectedCity.value &&
    street.value
  );
});
</script>

<template>
  <form @submit.prevent="handleSubmit" class="address-form">
    <!-- País -->
    <div class="form-group">
      <label for="country">
        País *
        <span v-if="loadingCountries" class="loading">Cargando...</span>
      </label>
      <select
        id="country"
        v-model="selectedCountry"
        @change="handleCountryChange"
        :disabled="loadingCountries"
        required
      >
        <option value="">Selecciona país...</option>
        <option
          v-for="country in countries"
          :key="country.id"
          :value="country.id"
        >
          {{ country.name }}
        </option>
      </select>
      <span v-if="countriesError" class="error">{{ countriesError }}</span>
    </div>

    <!-- Estado -->
    <div class="form-group">
      <label for="state">
        Estado/Provincia *
        <span v-if="loadingStates" class="loading">Cargando...</span>
      </label>
      <select
        id="state"
        v-model="selectedState"
        @change="handleStateChange"
        :disabled="!selectedCountry || loadingStates"
        required
      >
        <option value="">Selecciona estado...</option>
        <option v-for="state in states" :key="state.id" :value="state.id">
          {{ state.name }}
        </option>
      </select>
      <span v-if="statesError" class="error">{{ statesError }}</span>
    </div>

    <!-- Ciudad -->
    <div class="form-group">
      <label for="city">
        Ciudad *
        <span v-if="loadingCities" class="loading">Cargando...</span>
      </label>
      <select
        id="city"
        v-model="selectedCity"
        :disabled="!selectedState || loadingCities"
        required
      >
        <option value="">Selecciona ciudad...</option>
        <option v-for="city in cities" :key="city.id" :value="city.id">
          {{ city.name }}
        </option>
      </select>
      <span v-if="citiesError" class="error">{{ citiesError }}</span>
    </div>

    <!-- Dirección -->
    <div class="form-group">
      <label for="street">Dirección *</label>
      <input
        type="text"
        id="street"
        v-model="street"
        placeholder="Calle Principal 123"
        required
      />
    </div>

    <!-- Código Postal -->
    <div class="form-group">
      <label for="zipCode">Código Postal</label>
      <input
        type="text"
        id="zipCode"
        v-model="zipCode"
        placeholder="12345"
      />
    </div>

    <!-- Enviar -->
    <button type="submit" :disabled="!isFormValid">
      Enviar Dirección
    </button>
  </form>
</template>

<style scoped>
.address-form {
  max-width: 600px;
  margin: 0 auto;
}

.form-group {
  margin-bottom: 20px;
}

.form-group label {
  display: block;
  margin-bottom: 8px;
  font-weight: 600;
  color: #1f2937;
  font-size: 14px;
}

.form-group select,
.form-group input {
  width: 100%;
  padding: 10px 12px;
  border: 2px solid #e5e7eb;
  border-radius: 6px;
  font-size: 16px;
  background-color: white;
  transition: border-color 0.2s;
}

.form-group select:focus,
.form-group input:focus {
  outline: none;
  border-color: #3b82f6;
}

.form-group select:disabled,
.form-group input:disabled {
  background-color: #f9fafb;
  cursor: not-allowed;
  opacity: 0.6;
}

.loading {
  color: #6b7280;
  font-size: 12px;
  font-weight: normal;
  font-style: italic;
  margin-left: 8px;
}

.error {
  display: block;
  margin-top: 6px;
  color: #ef4444;
  font-size: 13px;
}

button[type='submit'] {
  width: 100%;
  padding: 12px 24px;
  background-color: #3b82f6;
  color: white;
  border: none;
  border-radius: 6px;
  font-size: 16px;
  font-weight: 600;
  cursor: pointer;
  transition: background-color 0.2s;
}

button[type='submit']:hover:not(:disabled) {
  background-color: #2563eb;
}

button[type='submit']:disabled {
  background-color: #cbd5e0;
  cursor: not-allowed;
}
</style>

Ejemplo de Uso

<!-- App.vue -->

<script setup lang="ts">
import AddressForm from './components/AddressForm.vue';

const handleAddressSubmit = (data: any) => {
  console.log('Dirección enviada:', data);

  // Enviar a tu backend
  // fetch('/api/save-address', {
  //   method: 'POST',
  //   headers: { 'Content-Type': 'application/json' },
  //   body: JSON.stringify(data),
  // });
};
</script>

<template>
  <div class="app">
    <h1>Dirección de Entrega</h1>
    <AddressForm @submit="handleAddressSubmit" lang="es" />
  </div>
</template>

<style>
.app {
  max-width: 800px;
  margin: 0 auto;
  padding: 40px 20px;
}

h1 {
  text-align: center;
  margin-bottom: 40px;
  color: #111827;
}
</style>

Avanzado: Composable useAddress Combinado

Para escenarios más complejos, crea un composable combinado:

// composables/useAddress.ts

import { ref, readonly } from 'vue';
import { useCountries } from './useCountries';
import { useStates } from './useStates';
import { useCities } from './useCities';

export function useAddress(lang: string = 'es') {
  const selectedCountry = ref<string | null>(null);
  const selectedState = ref<string | null>(null);
  const selectedCity = ref<string | null>(null);

  const countries = useCountries(lang);
  const states = useStates(selectedCountry, lang);
  const cities = useCities(selectedState, lang);

  const selectCountry = (countryId: string) => {
    selectedCountry.value = countryId;
    selectedState.value = null;
    selectedCity.value = null;
  };

  const selectState = (stateId: string) => {
    selectedState.value = stateId;
    selectedCity.value = null;
  };

  const selectCity = (cityId: string) => {
    selectedCity.value = cityId;
  };

  const reset = () => {
    selectedCountry.value = null;
    selectedState.value = null;
    selectedCity.value = null;
  };

  return {
    // Datos
    countries,
    states,
    cities,

    // Valores seleccionados
    selectedCountry: readonly(selectedCountry),
    selectedState: readonly(selectedState),
    selectedCity: readonly(selectedCity),

    // Acciones
    selectCountry,
    selectState,
    selectCity,
    reset,
  };
}

Usando el Composable Combinado

<script setup lang="ts">
import { useAddress } from '../composables/useAddress';

const {
  countries,
  states,
  cities,
  selectedCountry,
  selectedState,
  selectedCity,
  selectCountry,
  selectState,
  selectCity,
  reset,
} = useAddress('es');

const handleSubmit = () => {
  console.log({
    country: selectedCountry.value,
    state: selectedState.value,
    city: selectedCity.value,
  });
};
</script>

<template>
  <form @submit.prevent="handleSubmit">
    <select @change="(e) => selectCountry(e.target.value)">
      <option value="">Selecciona país...</option>
      <option
        v-for="country in countries.countries"
        :key="country.id"
        :value="country.id"
      >
        {{ country.name }}
      </option>
    </select>

    <!-- Estados y Ciudades... -->

    <button type="button" @click="reset">Resetear</button>
    <button type="submit">Enviar</button>
  </form>
</template>

Composition API con Options API

Si estás usando Options API, aún puedes usar composables:

<script lang="ts">
import { defineComponent } from 'vue';
import { useCountries } from '../composables/useCountries';

export default defineComponent({
  setup() {
    const { countries, loading, error } = useCountries('es');

    return {
      countries,
      loading,
      error,
    };
  },
});
</script>

Pruebas de Composables

Ejemplo de prueba con Vitest:

// composables/__tests__/useCountries.spec.ts

import { describe, it, expect, vi } from 'vitest';
import { useCountries } from '../useCountries';

describe('useCountries', () => {
  it('debería obtener países al montar', async () => {
    global.fetch = vi.fn(() =>
      Promise.resolve({
        json: () =>
          Promise.resolve({
            success: true,
            data: [
              { id: '1', name: 'Estados Unidos' },
              { id: '2', name: 'Canadá' },
            ],
          }),
      })
    ) as any;

    const { countries, loading } = useCountries();

    expect(loading.value).toBe(true);

    // Esperar a que complete el fetch
    await new Promise((resolve) => setTimeout(resolve, 100));

    expect(countries.value).toHaveLength(2);
    expect(loading.value).toBe(false);
  });
});

Próximos Pasos

¿Necesitas Ayuda?


Consejo Pro: Usa provide/inject para compartir la configuración de API en toda tu aplicación, facilitando el cambio entre entornos de desarrollo y producción.