Абстракції API

текст перекладу
pic

Створено штучним інтелектом. Абстрагування поганого API.

Не кожен API є блискучим прикладом хорошого дизайну. Часто можна натрапити на структури даних, які, якщо інтегрувати їх безпосередньо в клієнт, можуть призвести до серйозних проблем у довгостроковій перспективі. У таких випадках абстракційний шар зазвичай є необхідним, хоча часто його ігнорують. Ця стаття покаже, як захистити ваш проект від небажаного зв’язування та його наслідків.

Дозвольте мені заявити, що абстракції структур даних досить поширені в бекенді, тоді як у фронтенді часто буває ситуація, коли «їмо те, що передали по каналу». Саме тому я хочу розглянути це питання з перспективи фронтенд-розробника і представити два підходи до реалізації таких абстракцій.

Для цього прикладу використовується Angular, але концепції легко можна застосувати до інших фреймворків.

Коли абстракція корисна?

Відповідь не «завжди». Якщо API повністю знаходиться під вашим контролем і був спроектований спеціально для вашого додатку, абстракція часто є марною витратою зусиль. Абстракція, яка не дає реальної додаткової цінності, лише вводить непотрібну складність.

Це зовсім інша справа, коли інтерфейс використовується кількома клієнтами або має задовольняти вимоги різних зацікавлених сторін. Це стає ще більш критичним, коли ви працюєте з API, який виглядає так, ніби його створював стажер після довгої ночі з енергетиками та поганими рішеннями. У таких випадках абстракція не лише рекомендується — її введення є обов'язковим.

«Найгірший» API

Давайте розглянемо приклад сумнівного API. Ось кінцева точка, яка надає дані користувача:

GET /api/get_user?id=42
{  
 "id": 42,  
 "status": "SUCCESS",  
 "err": null,  
 "UsrData": {  
 "usrId": 42,  
 "active": "y",  
 "bod": "12/01/2000",  
 "img": null,  
 "Credentials": {  
 "usrName": "janeDoe123",  
 "e_mail": "[email protected]"  
 },  
 "Details": {  
 "first": "Jane",  
 "last": "Doe",  
 "addr": ["Example Street", "12", "EX12AB", "Example City"]  
 },  
 "auth": 1,  
 "Roles": {  
 "role1": "ADMIN",  
 "role2": "USER",  
 "role3": null  
 },  
 "last_login": "1703020800000"  
 }  
}

Проблеми з цією структурою даних або дизайном API, ймовірно, очевидні. Але дозвольте коротко підкреслити найбільш очевидні проблеми.

Кінцева точка

Проблеми починаються з визначення кінцевої точки:

  1. getuser_ в URL зайве. RESTful API мають бути орієнтовані на ресурси, без операцій в URL. HTTP метод GET вже вказує на операцію.
  2. Використання параметра запиту для ідентифікації ресурсу є незвичним. Параметри запиту зазвичай використовуються для фільтрів, критеріїв пошуку або опціональних параметрів. Ідентифікатор користувача слід передавати як параметр шляху.
  3. Хоча це не завжди необхідно, версіонування має бути розглянуте. Якщо планується версіонування, воно повинно бути включене в URL.

Дотримуючись найкращих практик, кінцева точка має виглядати ось так:

GET /api/v1/users/42

Відповідь

Поглянувши на відповідь, ви або заплакаєте, або відчуєте нудоту. Що не так тут?

  1. Надлишкове поле статусу — HTTP статус і так достатньо.
  2. Дубльовані ідентифікатори — id і usrId несуть ту саму інформацію.
  3. Поля з null значеннями — Це спірне питання, але я настійно рекомендую фільтрувати такі поля з відповіді.
  4. Списки як окремі поля — role1, role2 і role3 мають бути в масиві.
  5. Неналежне вкладення — Наприклад, UsrData і Credentials.
  6. Несумісні конвенції найменування — Іноді camelCase, іноді snake_case.
  7. Хаос форматів часу — Мітка часу як число замість ISO рядка.
  8. Булеві значення як рядки чи числа — active це «y», а не true.
    9.
    текст перекладу
    Адресний масив — Масив компонентів адреси менш зрозумілий, ніж об'єкт з осмисленими назвами полів.

Цих проблем вже достатньо, щоб впровадити абстракцію.

Підхід 1: Маппер

Один зі способів перетворити таку структуру даних у корисну — це використати маппер для створення абстракційного шару.

Маппінг можна реалізувати через функцію або сервіс. Я віддаю перевагу сервісу, оскільки це дозволяє використовувати ін'єкцію залежностей і простіше мокується в юніт-тестах, ніж функції.

Але почнемо з основ. Спочатку потрібно спроектувати цільову структуру, яка відповідає вашим вимогам і в яку буде перетворюватися відповідь API. В TypeScript це може виглядати так:

type UserRole = 'ADMIN' | 'USER' | 'GUEST';  

interface Address {  
 street: string;  
 housenumber: string;  
 postcode: string;  
 city: string;  
}  

interface User {  
 id: number;  
 username: string;  
 firstName: string;  
 lastName: string;  
 email: string;  
 dateOfBirth: Date;  
 userImageURL?: string;  
 address?: Address;  
 lastLogin: Date;  
 roles: UserRole[];  
 isActive: boolean;  
 isEmailConfirmed: boolean;  
}

Коли це зроблено, найскладніша частина позаду. Тепер потрібно просто перетворити відповідь на нову структуру даних. Ось приклад з використанням сервісу маппінгу, який можна впровадити:

@Injectable({  
 providedIn: 'root',  
})  
export class UserMapper {  
 private dateService = inject(DateService);  

 toModel(response: UserResponse): User {  
 return {  
 id: response.id,  
 username: response.UsrData.Credentials.usrName,  
 firstName: response.UsrData.Details.first,  
 lastName: response.UsrData.Details.last,  
 email: response.UsrData.Credentials.e_mail,  
 dateOfBirth: this.dateService.parseDe(response.UsrData.bod),  
 userImageURL: response.UsrData.img ?? undefined,  
 address: this.mapAddress(response.UsrData.Details.addr),  
 lastLogin: this.dateService.fromTime(+response.UsrData.last_login),  
 roles: this.mapRoles(response.UsrData.Roles),  
 isActive: response.UsrData.active === 'j',  
 isEmailConfirmed: response.UsrData.auth === 1,  
 };  
 }  

 private mapRoles(roles: Roles): UserRole[] {  
 return Object.values(roles);  
 }  

 private mapAddress(addr: string[]): Address | undefined {  
 const [street, housenumber, postcode, city] = addr;  
 return {  
 street,  
 housenumber,  
 postcode,  
 city,  
 };  
 }
}

Перетворення повинно відбуватися централізовано і якомога раніше — ідеально відразу після отримання даних. В Angular це може відбуватися в сервісі, який використовує HttpClient для взаємодії з бекендом:

@Injectable({   
 providedIn: 'root'   
})  
export class UserApiService {  
 private http = inject(HttpClient);  
 private mapper = inject(UserMapper);  

 public getUser(userId: string): Observable {  
 const params = { id: userId };  
 return this.http  
 .get('/api/get_user', params)  
 .pipe(map((res) => this.mapper.toModel(res)))  
 }  
}

Підхід 2: Декоратор

Альтернативно, ви можете реалізувати клас «декоратор», який інкапсулює структуру відповіді і надає чистий інтерфейс через геттери:

export class User {  
 constructor(private data: UserResponse) {}  

 get id(): number {  
 return this.data.id;  
 }  
 get username(): string {  
 return this.data.UsrData.Credentials.usrName;  
 }  

 // Додаткові геттери тут…  
}

Декоратор приховує складність внутрішньої структури даних і може бути розширений параметризованими методами. Однак цей підхід унеможливлює ін'єкцію сервісів або токенів, що є важливим фактором при виборі правильного підходу.

Висновок

Вибір підходу завжди залежить від контексту. Ви навіть можете поєднати обидва методи: спочатку перетворити відповідь API в корисну структуру, а потім обгорнути її в декоратор для додаткової функціональності.

Важливо одне: спочатку потрібно впровадити абстракційний шар.
текст перекладу
Зрештою, це не лише технічне, а й стратегічне рішення. Наступний розробник подякує вам, коли інтерфейс зміниться — йому, ймовірно, доведеться лише налаштувати абстракційний шар, уникнувши необхідності рефакторингу по всьому коду.

Перекладено з: API Abstractions

Leave a Reply

Your email address will not be published. Required fields are marked *