Práctica de Encapsulación de Solicitudes de Red en HarmonyOS NEXT: Construcción de una Clase de Herramientas Networking Universal con Soporte para Cargas

Durante el desarrollo de aplicaciones con HarmonyOS NEXT (ArkTS), la gestión de solicitudes de red es un componente fundamental en todo proyecto. El módulo nativo @ohos.net.http proporciona la funcionalidad necesaria, aunque su uso directo suele resultar en código repetitivo y frágil, especialmente al manejar tokens de autenticación, errores globales y cargas de archivos.

Para mejorar la eficiencia del desarrollo y la mantenibilidad del código, se ha diseñado una clase de utilidad NetworkService que abstrae las operaciones de red más comunes. Esta clase ofrece:

  • Encapsulación de peticiones GET y POST.
  • Inyección automática de tokens de autenticación.
  • Gestión centralizada de errores y códigos de respuesta.
  • Soporte para la carga de archivos con gestión del sandbox.
  • Retorno de respuestas tipadas mediante genéricos.

Implementación Central de la Clase NetworkService

El siguiente código muestra la implementación completa de la clase NetworkService para HarmonyOS NEXT. Se recomienda alojarlo en un archivo como /utils/NetworkService.ts.

import http from '@ohos.net.http';
import { promptAction, router } from '@kit.ArkUI';
import authManager from '../auth/AuthManager';
import fs from '@ohos.file.fs';
import buffer from '@ohos.buffer';
import util from '@ohos.util';

// Interfaz que define la estructura común de las respuestas del servidor
export interface ApiResponse<T> {
    status: number;
    success: boolean;
    result?: T;
    error?: string;
}

class NetworkService {
    private readonly apiRoot: string;

    constructor(apiEndpoint: string) {
        this.apiRoot = apiEndpoint; // Ej: 'https://midominio.com/api'
    }

    private async buildHeaders(contentType: string = 'application/json'): Promise<Record<string, string>> {
        const authToken = await authManager.retrieveToken();
        const headers: Record<string, string> = { 'Content-Type': contentType };
        if (authToken) {
            headers['Auth-Token'] = authToken;
        }
        return headers;
    }

    private evaluateResponse<T>(response: ApiResponse<T>): ApiResponse<T> {
        if (!response.success) {
            switch (response.status) {
                case 401:
                case 403:
                    promptAction.showToast({ message: 'Sesión expirada. Por favor, inicie sesión nuevamente.' });
                    router.pushUrl({ url: 'pages/auth/LoginPage' });
                    break;
                default:
                    promptAction.showToast({ message: response.error || 'Error desconocido en la solicitud.' });
            }
        }
        return response;
    }

    public async fetchGet<T>(path: string, queryParams?: Record<string, any>): Promise<ApiResponse<T>> {
        const client = http.createHttp();
        try {
            const headers = await this.buildHeaders();
            const options: http.HttpRequestOptions = {
                method: http.RequestMethod.GET,
                header: headers,
                expectDataType: http.HttpDataType.OBJECT,
                extraData: queryParams,
            };
            const response = await client.request(`${this.apiRoot}${path}`, options);
            const data = response.result as ApiResponse<T>;
            return this.evaluateResponse(data);
        } catch (exception) {
            promptAction.showToast({ message: 'No se pudo conectar al servidor.' });
            throw exception;
        } finally {
            client.destroy();
        }
    }

    public async fetchPost<T>(path: string, body: Record<string, any>): Promise<ApiResponse<T>> {
        const client = http.createHttp();
        try {
            const headers = await this.buildHeaders();
            const options: http.HttpRequestOptions = {
                method: http.RequestMethod.POST,
                header: headers,
                expectDataType: http.HttpDataType.OBJECT,
                extraData: body,
            };
            const response = await client.request(`${this.apiRoot}${path}`, options);
            const data = response.result as ApiResponse<T>;
            return this.evaluateResponse(data);
        } catch (exception) {
            promptAction.showToast({ message: 'Error al enviar la solicitud.' });
            throw exception;
        } finally {
            client.destroy();
        }
    }

    public async uploadFile<T>(path: string, fileUri: string, formFieldName: string = 'file'): Promise<ApiResponse<T>> {
        try {
            const localPath = await this.moveToAppCache(fileUri);
            const fileData = this.readBinaryFile(localPath);
            const boundary = `Boundary${Date.now()}`;
            const multipartBody = this.constructMultipartBody(boundary, formFieldName, fileUri, fileData);

            const client = http.createHttp();
            const token = await authManager.retrieveToken();
            const headers: Record<string, string> = {
                'Content-Type': `multipart/form-data; boundary=${boundary}`,
                'Content-Length': multipartBody.byteLength.toString(),
            };
            if (token) {
                headers['Auth-Token'] = token;
            }

            const options: http.HttpRequestOptions = {
                method: http.RequestMethod.POST,
                header: headers,
                extraData: multipartBody,
            };

            const response = await client.request(`${this.apiRoot}${path}`, options);
            // Asumimos que la respuesta de carga viene en texto plano y necesita parseo
            const result = JSON.parse(response.result as string) as ApiResponse<T>;
            return this.evaluateResponse(result);
        } catch (exception) {
            promptAction.showToast({ message: 'Fallo en la carga del archivo.' });
            throw exception;
        }
    }

    private constructMultipartBody(boundary: string, fieldName: string, fileName: string, data: ArrayBuffer): ArrayBuffer {
        const encoder = new util.TextEncoder();
        const headerPart = encoder.encodeInto(
            `--${boundary}\r\nContent-Disposition: form-data; name="${fieldName}"; filename="${fileName}"\r\nContent-Type: application/octet-stream\r\n\r\n`
        );
        const footerPart = encoder.encodeInto(`\r\n--${boundary}--\r\n`);
        return buffer.concat([headerPart, new Uint8Array(data), footerPart]).buffer;
    }

    private async moveToAppCache(srcUri: string): Promise<string> {
        const context = getContext(this);
        const destPath = `${context.cacheDir}/${Date.now()}_${srcUri.split('/').pop()}`;
        try {
            const srcFile = await fs.open(srcUri);
            fs.copyFileSync(srcFile.fd, destPath);
            await fs.close(srcFile);
        } catch (error) {
            console.error(`Error moviendo archivo a caché: ${error}`);
            throw error;
        }
        return destPath;
    }

    private readBinaryFile(filePath: string): ArrayBuffer {
        const file = fs.openSync(filePath, fs.OpenMode.READ_ONLY);
        const fileStats = fs.lstatSync(filePath);
        const buffer = new ArrayBuffer(fileStats.size);
        fs.readSync(file.fd, buffer);
        fs.closeSync(file);
        return buffer;
    }
}

// Exportar una instancia singleton configurada con el endpoint base
export const networkService = new NetworkService('https://api.miservidor.com/v1');

Ejemplos de Uso en la Aplicación

Obteniendo Datos de Perfil (GET)

interface UserData {
    id: string;
    name: string;
    profileImage: string;
}

const getUserProfile = async () => {
    try {
        const response = await networkService.fetchGet<UserData>('/user/profile');
        if (response.success) {
            console.log(`Nombre de usuario: ${response.result?.name}`);
        }
    } catch (error) {
        console.error('Error obteniendo perfil:', error);
    }
};

Iniciando Sesión (POST)

interface LoginPayload {
    email: string;
    password: string;
}

interface AuthTokens {
    accessToken: string;
    refreshToken: string;
}

const performLogin = async (credentials: LoginPayload) => {
    const response = await networkService.fetchPost<AuthTokens>('/auth/login', credentials);
    if (response.result) {
        await authManager.saveTokens(response.result.accessToken, response.result.refreshToken);
    }
};

Subiendo una Imagen (Upload)

interface MediaUploadResult {
    mediaId: string;
    publicUrl: string;
}

const uploadProfilePicture = async (imageUri: string) => {
    const response = await networkService.uploadFile<MediaUploadResult>('/media/profile-photo', imageUri, 'avatar');
    if (response.success) {
        console.log(`URL de la imagen: ${response.result?.publicUrl}`);
    }
};

Consideraciones de Seguridad y Mejores Prácticas

  • El token de autenticación se debe gestionar de forma segura (por ejemplo, usando el módulo de keychain de HarmonyOS).
  • Implementar un mecanismo para refrescar el token de acceso cuando expire, usando el token de refresco.
  • Para las cargas de archivos grandes, considerar una subida por partes (chunked upload) para mejorar la resiliencia.
  • Configurar timeouts adecuados en las opciones de la solicitud HTTP para evitar esperas indefinidas.
  • Evitar registrar (log) información sensible como tokens o contraseñas en consola en producción.

Etiquetas: HarmonyOS Next ArkTS red HTTP Networking

Publicado el 7-15 19:01