Psaní typů pro data z API je otrava. Proč je to vůbec potřeba? A proč to aspoň negenerovat? No a když už teda generujeme typy, co toho generovat více? Pomocí knihovny Orval si ukážeme jak na to.
NOTE: O tomhle a dalších tématech jste nás mohli slyšet mluvit na Ackee meets – záznam talků najdete na Youtube. Pokud byste se chtěli na další meetup kouknout naživo a upít nám trochu piva, tak nás sledujte na sockách.
Proč potřebujeme typy pro komunikaci s API?
Většina aplikací, které přímo interagují s uživatelem, dnes svá data neukládá lokálně, ale na serveru “někde v mráčku”. Server, který data spravuje, přesně ví, jak tato data vypadají, v jakém formátu musí přijít a v jakém formátu je zase umí posílat zpátky. Tyto formáty musíme dodržet, když se serverem z aplikace komunikujeme, jinak nám správu dat nedovolí.
TypeScript kód s těmito typy a voláním API může vypadat například takto:
export interface GetPeopleParams {
search?: string;
}
export interface Person {
birth_year: string;
name: string;
url: string;
}
export interface PeopleResponse {
count: number;
results: Person[];
}
async function getPeople(params: GetPeopleParams): Promise<PeopleResponse> {
const searchParams = new URLSearchParams();
searchParams.set('search', params.search ?? '');
const response = await fetch(`https:/swapi.dev/api/people?${searchParams}`);
if (response.ok) {
return (await response.json()) as PeopleResponse;
}
throw new ApiError('Http Response not ok', response);
}
Nejprve máme definované typy pro data požadavku, resp. odpovědi. Poté je využíváme při komunikaci s API. Všimněte si, že pokud zavoláme funkci “getPeople”, vždycky víme, jaké jí máme předat data a jaké nám vrátí.
Pozor: Ne vždy je potřeba typy v aplikaci řešit. Například při použití technologií jako GRPC, TRPC nebo GraphQL už jsou typy součástí komunikačního kontraktu. My se ale v tomto článku budeme soustředit na klasické REST API, kde to “tak jednoduché” není.
Jak můžeme typy generovat?
Než se si toto zodpovíme, nejprve si ujasněme, kde jsme tyto typy zjistili my. Jako programátoři máme pár možností:
- Server jsme psali nebo máme přístup k jeho kódu, takže jsme si typy jen opsali.
- Zkusili jsme server volat a postupně jsme (možná pomocí errorových hlášek) zjistili, co od nás chce a co nám vrátí. Na jen trochu větší aplikaci to asi nebude to pravé.
- Server má OpenAPI specifikaci. Ta popisuje adresy endpointů (jednotlivých bodů, se kterými můžeme komunikovat), jaká data požadují a jaké vrací – ideální.
V této OpenAPI specifikaci jsou vlastně všechny informace, které potřebujeme. Je to proto ideální zdroj dat pro generování kódu. A pro generování můžeme využít například právě nástroj Orval.
Generování kódu podle dokumentace
Orval je knihovna, která umí generovat nejen typy, ale i funkce pro komunikaci s API (viz naše “getPeople”) a mnoho dalšího. Její konfigurace může být komplexní, nicméně její prvotní použití vůbec není komplikované. Vytvoříme si soubor orval.config.ts (místo TypeScriptu lze použít i JavaScript) a v něm exportujeme objekt s těmito informacemi:
- Název, pod kterým si nastavení ukládáme. API by mohlo být více a tímto názvem bychom je v souboru odlišili.
- Pro každé API pak potřebujeme upřesnit, odkud má OpenAPI dokumentaci získat a co s ní má dělat. Nastavíme, kam se mají soubory generovat, případně můžeme například nastavit URL adresu, která se přidá před každý endpoint, nebo třeba to, zda se má vygenerovaný kód zarovnat pomocí nástroje Prettier.
Pro náš projekt pro komunikaci se Star Wars API by mohla vypadat například takto:
export default {
demo: {
input: './api-docs.json',
output: {
target: './src/modules/api/orval',
baseUrl: 'https://swapi.dev/api',
prettier: true,
},
},
};
Poznámka: V rámci konfigurace lze i pro lepší typování využít typy přímo z Orvalu, případně lze využít její funkci “defineConfig”.
Generování kódu můžeme spustit například pomocí příkazu npx orval v dané složce. Příkaz nás upozorní, pokud by se něco nepodařilo, ale pokud máme konfiguraci i specifikaci v pořádku, kód se vygeneruje do specifikované složky.
export type GetPeopleParams = {
search?: string;
page?: number;
};
export interface Person {
birth_year: string;
name: string;
url: string;
}
export interface PeopleResponse {
count: number;
results: Person[];
}
/**
* @summary Get all people in all movies
*/
export const getPeople = <TData = AxiosResponse<PeopleResponse>>(
params?: GetPeopleParams,
options?: AxiosRequestConfig,
): Promise<TData> => {
return axios.get(`https://swapi.dev/api/people`, {
...options,
params: { ...params, ...options?.params },
});
};
Vidíme, že vygenerovaný kód je velmi podobný tomu našemu, výhodou je, že jsme ho nemuseli psát a při jakékoliv změně specifikace stačí kód přegenerovat a hned máme změny i v naší aplikaci. Orval ve výchozím nastavení současné verze generuje kód využívající knihovnu axios, která byla historicky pro komunikaci standardní. Pokud by se nám její využití nelíbilo, lze její použití pomocí konfigurace v generovaném kódu nahradit například moderním Fetch API.
V Ackee používáme pro orchestraci asynchronních dat populární knihovnu Tanstack Query. Ta za nás v Reactu řeší nejen různé stavy požadavku (zda se načítá, nastala chyba nebo máme data), ale i cachování a opětovné provedení požadavku, pokud nastala chyba nebo jen uběhla nějaká doba od posledních dat. Použití spolu s touto knihovnou by mohlo vypadat například takto:
export default function People() {
const query = useSearchParams().get(SEARCH_QUERY_KEY) || undefined;
const {
data: response,
isPending,
error,
} = useQuery({
queryKey: ['people', query],
queryFn: () => getPeople({ search: query }),
});
const data = response?.data;
// TODO render people
}
Co ještě lze generovat s Orvalem?
Již při úvodu do Orvalu jsme viděli, že předčil naše očekávání a umí generovat víc než jen typy, generoval nám totiž přímo funkce, které prováděly komunikaci. Kromě těchto věcí umí generovat například i React hooky pro využití zmiňované React Query.
To nastavíme v orval.config souboru tím, že do části “output” přidáme “client: 'react-query'”.
export default {
demo: {
input: './api-docs.json',
output: {
target: './src/modules/api/orval',
baseUrl: 'https://swapi.dev/api',
prettier: true,
client: 'react-query',
},
},
};
Pokud opět spustíme generování kódu, uvidíme, že nám kromě typů a komunikačních funkcí přibyli i zmiňované hooky a jiné pomocné funkce. Zjednodušeně můžou vygenerované hooky vypadat například takto:
/**
* @summary Get all people in all movies
*/
export const useGetPeople = (params?: GetPeopleParams, options?: ...) => {
const { query: queryOptions, axios: axiosOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetPeopleQueryKey(params);
const queryFn = ({ signal }) => getPeople(params, { signal, ...axiosOptions });
const query = useQuery({ queryFn, queryKey, ...queryOptions });
query.queryKey = queryKey;
return query;
};
Přestože kód možná zjednodušeně nevypadá, tak dělá to, co jsme psali my, jen zase o trochu lépe.
- Připraví queryKey – klíč pro cachování výsledků požadavků.
- Připraví funkci pro získání dat pro React Query. Oproti naší implementaci navíc využije signál, pomocí kterého může React Query zastavit provádění požadavku, pokud už data například nejsou potřeba.
- Zavolá hook useQuery z React Query a jeho výsledek nám vrátí.
- Kromě těchto bodů navíc umožňuje kroky customizovat při volání hooku, můžeme tak například zvolit jiný queryKey, měnit nastavení cachování, nebo do axiosu přidat nějaké parametry. Použití je pak ještě jednodušší než předtím, v naší React komponentě stačí zavolat vygenerovanou funkci a máme přístup ke stejným datům jako předtím.
export default function People() {
const query = useSearchParams().get(SEARCH_QUERY_KEY) || undefined;
const { data: response, isPending, error } = useGetPeople({ search: query });
const data = response?.data;
// TODO render people
}
Alternativy, aneb co Orval zatím neumí
Kromě Orvalu jsme samozřejmě koukali i na jiná řešení. Jedním z nich byl Zodios, který na řešení téhle problematiky jde z jiné strany. V Zodiosu se specifikují ke každému endpointu rovnou validační schémata z knihovny zod, ty se pak používají k inferování typů. Díky tomu lze při získání dat ze serveru data validovat, to nás může upozornit na chyby v dokumentaci a zabránit hůře objevitelným bugům, například kdyby nějaká hodnota chyběla nebo měla jiný typ.
Nakonec jsme ale Zodios nezvolili, jeho závislost na inferenci může znamenat horší napovídání v IDE (true story), vyžaduje runtime knihovnu pro běh jeho konvence kolem React Query hooků nejsou moc přívětivé a hůře se s ní debuguje díky schované logice zpracování odpovědí někde v knihovně. Validaci příchozích dat bereme jako bonus, bez kterého se dá přežít. Kdo ví, třeba se v Orvalu někdy objeví taky.
Shrnutí: Generování kódu s Orvalem
Ukázali jsme si, proč je typování komunikace potřeba a jak si ho ulehčit pomocí knihovny Orval. Prvotní konfigurace nebyla vůbec komplikovaná a stačila nám k tomu, abychom generovali více, než jsme měli původně v plánu – ať už to byly funkce pro komunikaci s API nebo hooky pro React Query.
Orval v Ackee používáme už delší dobu a nemůžeme si ho vynachválit. Umožňuje komplexní customizaci a kromě axiosu nepotřebuje v základu žádnou knihovnu při běhu aplikace. Co používáte vy? Líbí se vám Orval, nebo byste volili jiné řešení? Dejte nám vědět třeba na Twitteru nebo si o tom přijďte pokecat na další Ackee meets.
Zdroje: Dokumentace Orval Dokumentace Zodios Dokumentace React Query Star Wars API Ukázka využití Záznam talku