Jump to content

tRPC

tRPC pozwala nam pisanie API będących w pełni typesafe bez żadnego generowania kodu czy też zaśmiecania runtime’u. Korzysta on ze świetnego type inference od Typecripta aby przekazywać definicje routerów oraz pozwala Ci na korzystanie z procedur API na frontendzie z pełnym tyepsafety i autouzupełnianiem. Jeśli korzystasz z tRPC, twój frontend i backend będą sprawiały wrażenie bycia bardziej połączonymi niż kiedykolwiek, pozwalając na niespotykany DX (developer experience).

Zbudowałem tRPC aby umożliwić każdemu szybsze robienie postępów, usuwając przy tym potrzebę korzystania z tradycyjnej wartswy API oraz zachowując pewność, iż nasze aplikacje nie zepsują się nadążając za własnym rozwojem.
Oryginał: I built tRPC to allow people to move faster by removing the need of a traditional API-layer, while still having confidence that our apps won't break as we rapidly iterate.

Avatar of @alexdotjs
Alex - twórca tRPC @alexdotjs

Jak korzystać z tRPC?

Kontrybutor tRPC trashh_dev zrobił znakomity występ na Next.js Conf właśnie o tRPC. Jeżeli jeszcze się z nim nie zapoznałeś, bardzo polecamy Ci to zrobić.

Z tRPC, piszesz funkcje w TypeScript’cie na backendzie a następnie wywołujesz je z frontendu. Prosta procedura tRPC wyglądać może tak:

server/api/routers/user.ts
const userRouter = createTRPCRouter({
  getById: publicProcedure.input(z.string()).query(({ ctx, input }) => {
    return ctx.prisma.user.findFirst({
      where: {
        id: input,
      },
    });
  }),
});

Jest to procedura (odpowiednik handlera route’a w tradycyjnym API), która najpierw waliduje wejście/input korzystając z biblioteki Zod (jest to ta sama biblioteka, z której korzystamy podczas sprawdzania zmiennych środowiskowych) - w tym przypadku zapewnia ona, iż dane przesłane do API są w formie tekstu (stringa). Jeżeli jednak nie jest to prawda, API wyśle informatywny błąd.

Po sprawdzeniu wejścia, dołączamy funkcję, która może być albo query, albo mutacją, albo subscrypcją. W naszym przykładzie, funkcja ta (zwana “resolverem”) wysyła zapytanie do bazy danych korzystając z naszego klienta prisma i zwraca użytkownika z pasującym do wysłanego id.

Swoje procedury definiujesz w folderze routers, który reprezentuje kolekcję pasujących procedur ze wspólnej przestrzeni. Możesz mieć router users, router posts i router messages. Routery te mogą zostać następnie połączone w jeden, scentralizowany appRouter:

server/api/root.ts
const appRouter = createTRPCRouter({
  users: userRouter,
  posts: postRouter,
  messages: messageRouter,
});

export type AppRouter = typeof appRouter;

Zwróć uwagę na to, iż musimy eksportować jedynie definicje typów tego routera - oznacza to, iż nigdy nie importujemy kodu serwera po stronie klienta.

Wywołajmy teraz procedurę na naszym frontendzie. tRPC dostarcza nam wrapper dla paczki @tanstack/react-query, który pozwala ci wykorzystać pełną moc hooków. Dodatkowo, zapytania API dostajesz w pełni “otypowane”. Zapytanie do naszych procedur możemy wykonać w następujący sposób:

pages/users/[id].tsx
import { useRouter } from "next/router";
import { api } from "../../utils/api";

const UserPage = () => {
  const { query } = useRouter();
  const userQuery = api.users.getById.useQuery(query.id);

  return (
    <div>
      <h1>{userQuery.data?.name}</h1>
    </div>
  );
};

Natychmiast zauważysz, jak dobrze działa type-safety i autouzupełnianie. Jak tylko napiszesz trpc., twoje routery automatycznie pojawią się w opcjach autopodpowiedzi a kiedy tylko wybierzesz router, również znajdą się tam jego procedury. Otrzymasz także błąd TypeScripta, jeżeli wejście (input) nie będzie zgadzać się z tym, podanym do systemu walidacji na backendzie.

Korzystanie z błędów biblioteki Zod

Domyślnie create-t3-app konfiguruje error formatter, który pozwala pobierać błędy z biblioteki Zod, jeśli na backendzie wystąpią błędy walidacji.

Przykładowe użycie:

function MyComponent() {
  const { mutate, error } = api.post.create.useMutation();

  return (
    <form onSubmit={(e) => {
      e.preventDefault();
      const formData = new FormData(e.currentTarget);
      mutate({ title: formData.get('title') });
    }}>
      <input name="title" />
      {error?.data?.zodError?.fieldErrors.title && (
        {/** `mutate` returned with an error on the `title` */}
        <span className="mb-8 text-red-500">
          {error.data.zodError.fieldErrors.title}
        </span>
      )}

      ...
    </form>
  );
}

Pliki

tRPC wymaga dużo boilerplate’u, który create-t3-app przygotowuje za Ciebie. Przejdźmy więc po kolei po plikach, które są generowane:

📄 pages/api/trpc/[trpc].ts

Jest to właściwy punkt początkowy dla twojego API - to on ujawnia dla reszty aplikacji twój router od tRPC. Prawdopodobnie nie będziesz musiał edytować tego pliku, ale jeżeli zajdzie taka potrzeba (np. do włączenia CORSa), warto wiedzieć o tym, iż eksportowany createNextApiHandler to Next.js API handler, który pobiera obiekt zapytania i odpowiedzi serwera. Oznacza to, iż możesz zawrzeć createNextApiHandler w middleware, w jakim tylko chcesz. Poniżej znajdziesz przykładowy kod, dzięki któremu dodasz CORS.

📄 server/api/trpc.ts

Plik ten podzielony jest na dwie części - tworzenie kontekstu oraz inicjalizacji tRPC:

  1. Definiujemy kontekst przesyłany do procedur tRPC. Kontekst, to dane do których dostęp mają wszystkie twoje procedury tRPC. Jest to doskonałe miejsce do umieszczenia rzeczy, takich jak połączenia z bazą danych, informacje o uwierzytelnianiu, itp. W Create T3 App korzystamy z dwóch funkcji, aby umożliwić korzystanie z części kontekstu bez dostępu do obiektu zapytania.
  • createInnerTRPCContext: Tutaj definiujesz kontekst, który nie zależy od obiektu zapytania, np. połączenie z bazą danych. Możesz wykorzystać tą funkcję do testów integracji oraz funkcji pomocniczych SSG, gdzie nie posiadasz obiektu zapytania.

  • createTRPCContext: Tutaj definiujesz kontekst, który zależny jest od zapytania, np. sesja użytkownika. Otrzymujesz sesję korzystając z obiektu opts.req, a następnie posyłasz ją do funkcji createInnerTRPCContext w celu utworzenia finalnego kontekstu.

  1. Inicjalizujemy tRPC i definiujemy procedury oraz middleware. Umownie, nie powinieneś eksportować całego obiektu t a jedynie poszczególne procedury i middleware.

Zwróć uwagę, iż korzystamy z paczki superjson jako transformera danych. Umożliwia on na zachowanie typów danych, które otrzymuje klient - przykładowo, posyłając obiekt Date, klient również otrzyma obiekt Date - a nie tekst, w przeciwieństwie do wielu innych API.

📄 server/api/routers/*.ts

Tutaj definiujesz routery i procedury swojego API. Umownie, powinieneś tworzyć osobne routery dla odpowiadających im procedur.

📄 server/api/root.ts

Tutaj łączymy wszystkie “sub-routery” zdefiniowane w folderze routers/** w jeden router aplikacji.

📄 utils/api.ts

Jest to punkt startowy tRPC po stronie frontendu. To tutaj importować będziesz wszystkie definicje typów i tworzyć będziesz swój client tRPC razem z hookami od react-query. Ponieważ korzystamy z paczki superjson jako transformera danych na backendzie, musimy go uruchomić również na frontendzie. Dzieje się tak, ponieważ dane serializowane w API muszą być dekodowane właśnie na frontendzie.

Zdefiniujesz tu także linki tRPC, które decydują o całym flow zapytania - od klienta do serwera. My korzystamy z “domyślnego” linku httpBatchLink, który umożliwia “request batching”. Korzystamy też z linku loggerLink, pozwalającego na wyświetlanie przydatnych podczas pisania aplikacji logów.

Na koniec eksportujemy pomocniczy typ, którego użyć możesz do dziedziczenia typów na frontendzie.

Jak wykonać zewnętrzne zapytania do mojego API?

Korzystając z regularnego API, zapytania takie możesz wykonać korzystając z klientów HTTP takich jak curl, Postman, fetch, czy tez bezpośrednio z przeglądarki. Z tRPC sprawa wygląda jednak inaczej. Jeżeli chcesz wykonać takie zapytania bez klienta tRPC, możesz skorzystać z jedngo z dwóch polecanych na to sposobów:

Ujawnianie zewnętrznie pojedynczej procedury tRPC

Jeżeli chcesz ujawnić zewnętrznie pojedynczą procedurę, powinieneś skorzystać z zapytań po stronie serwera. Pozwoli Ci to na wykonanie standardowego endpointa Next.js, ale użyje części “resolvera” twojej procedury tRPC.

pages/api/users/[id].ts
import { type NextApiRequest, type NextApiResponse } from "next";
import { appRouter, createCaller } from "../../../server/api/root";
import { createTRPCContext } from "../../../server/api/trpc";

const userByIdHandler = async (req: NextApiRequest, res: NextApiResponse) => {
  // Create context and caller
  const ctx = await createTRPCContext({ req, res });
  const caller = createCaller(ctx);
  try {
    const { id } = req.query;
    const user = await caller.user.getById(id);
    res.status(200).json(user);
  } catch (cause) {
    if (cause instanceof TRPCError) {
      // An error from tRPC occurred
      const httpCode = getHTTPStatusCodeFromError(cause);
      return res.status(httpCode).json(cause);
    }
    // Another error occurred
    console.error(cause);
    res.status(500).json({ message: "Internal server error" });
  }
};

export default userByIdHandler;

Ujawnianie wszystkich procedur tRPC jako endpointów REST

Jeżeli chcesz ujawnić zewnętrznie wszystkie procedury tRPC, sprawdź rozszerzenie stworzone przez społeczność - trpc-openapi. Dostarczając dodatkowych metadanych do twoich procedur, wygenerować możesz REST API zgodne z OpenAPI ze swoich routerów tRPC.

To tylko zapytania HTTP

tRPC komunikuje się za pomocą HTTP, więc masz także możliwość wykonywania zapytań do swoich procedur korzystając właśnie z “regularnych” zapytań HTTP. Składnia może wydawać się jednak nieporęczna z powodu wykorzystywanego przez tRPC protokołu RPC. Jeżeli jesteś ciekawy jak on działa, możesz zobaczyć jak wyglądają zapytania tRPC w zakładce “sieć” w swojej przeglądarce - polecamy robić to jednak tylko w celach edukacyjnych i skorzystać z jednego z rozwiązań przedstawionych powyżej.

Porównanie do endpointu API Next.js

Porównajmy endpoint API Next.js z procedurą tRPC. Powiedzmy, że chcemy pobrać ubiekt użytkownika z naszej bazy danych i zwrócić go na frontend. Endpoint API Next.js napisać moglibyśmy w następujący sposób>+:

pages/api/users/[id].ts
import { type NextApiRequest, type NextApiResponse } from "next";
import { prisma } from "../../../server/db";

const userByIdHandler = async (req: NextApiRequest, res: NextApiResponse) => {
  if (req.method !== "GET") {
    return res.status(405).end();
  }

  const { id } = req.query;

  if (!id || typeof id !== "string") {
    return res.status(400).json({ error: "Invalid id" });
  }

  const examples = await prisma.example.findFirst({
    where: {
      id,
    },
  });

  res.status(200).json(examples);
};

export default userByIdHandler;
pages/users/[id].tsx
import { useState, useEffect } from "react";
import { useRouter } from "next/router";

const UserPage = () => {
  const router = useRouter();
  const { id } = router.query;

  const [user, setUser] = useState(null);
  useEffect(() => {
    fetch(`/api/user/${id}`)
      .then((res) => res.json())
      .then((data) => setUser(data));
  }, [id]);
};

Porównaj to do powyższego przykładu z tRPC - zobaczysz zalety korzystanie właśnie z tego sposobu:

  • Zamiast precyzować url dla każdego route’a (co może stać się uciążliwe do debugowania, jeśli coś przeniesiesz), twój cały router jest obiektem z autouzupełnianiem.
  • Nie musisz walidować użytej metody HTTP.
  • Nie musisz walidować zawartości zapytania pod kątem pooprawności zawartych danych - zajmuje się tym Zod.
  • Zamiast tworzyć obiekt “response”, możesz wyrzucać błędy i zwracać wartości lub obiekty tak, jak robiłbyś to w zwykłej funkcji TypeScripta.
  • Wywoływanie procedury na frontendzie dostarcza Ci autouzupełniania i type-safety.

Przydatne fragmenty

Znajdziesz tutaj fragmenty kodu, które mogą Ci się przydać.

Aktywacja CORS

Jeżeli chcesz korzystać z API z różnych domen, np. w monorepo zawierającym aplikację React Native, możesz chcieć włączyć CORS:

pages/api/trpc/[trpc].ts
import { type NextApiRequest, type NextApiResponse } from "next";
import { createNextApiHandler } from "@trpc/server/adapters/next";
import { appRouter } from "~/server/api/root";
import { createTRPCContext } from "~/server/api/trpc";
import cors from "nextjs-cors";

const handler = async (req: NextApiRequest, res: NextApiResponse) => {
  // Włącz cors
  await cors(req, res);

  // Stwórz i wywołaj handler tRPC
  return createNextApiHandler({
    router: appRouter,
    createContext: createTRPCContext,
  })(req, res);
};

export default handler;

“Optimistic updates”

Aktualizacje danych zwane “Optimistic updates” zachodzą wtedy, kiedy aktualizujemy UI, zanim zapytanie API zostanie ukończone. Dostarcza to lepsze doświadczenie użytkownika, ponieważ nie musi on czekać na ukończenie zapytania API, aby zobaczyć odzwierciedlenie zmian w interfejsie aplikacji. Pamiętaj jednak, że aplikacje, które cenią sobie poprawność danych, powinny za wszelką cenę unikać aktualizacji “optimisic updates” - nie są one “poprawną” reprezentacją stanu backendu. Więcej na ich temat możesz poczytać w dokumentacji React Query.

const MyComponent = () => {
  const listPostQuery = api.post.list.useQuery();

  const utils = api.useContext();
  const postCreate = api.post.create.useMutation({
    async onMutate(newPost) {
      // Anuluj wychodzące zapytania (aby nie nadpisały one "optimistic update'u")
      await utils.post.list.cancel();

      // Otrzymaj dane z queryCache
      const prevData = utils.post.list.getData();

      // Zaktualizuj dane z naszego nowego postu
      utils.post.list.setData(undefined, (old) => [...old, newPost]);

      // Zwróć poprzednie dane, aby w razie błędu można było z nich przywrócić stan aplikacji
      return { prevData };
    },
    onError(err, newPost, ctx) {
      // Jeżeli mutacja wyrzuci błąd, skorzystaj z wartości kontekstu z onMutate
      utils.post.list.setData(undefined, ctx.prevData);
    },
    onSettled() {
      // Zsynchronizuj z serwerem po ukończonej mutacji
      utils.post.list.invalidate();
    },
  });
};

Przykładowy Test Integracji

Tu znajdziesz przykładowy test integracji korzystający z paczki Vitest, aby sprawdzić, czy router tRPC działa poprawnie, czy parser danych wejściowych dziedziczy odpowiedni typ, oraz czy zwracane dane pasują do oczekiwanego outputu.

import { type inferProcedureInput } from "@trpc/server";
import { expect, test } from "vitest";

import { appRouter, type AppRouter } from "~/server/api/root";
import { createInnerTRPCContext } from "~/server/api/trpc";

test("example router", async () => {
  const ctx = await createInnerTRPCContext({ session: null });
  const caller = appRouter.createCaller(ctx);

  type Input = inferProcedureInput<AppRouter["example"]["hello"]>;
  const input: Input = {
    text: "test",
  };

  const example = await caller.example.hello(input);

  expect(example).toMatchObject({ greeting: "Hello test" });
});

Jeżeli twoja procedura jest chroniona, możesz przesłać stworzony obiekt session tworząc kontekst:

test("protected example router", async () => {
  const ctx = await createInnerTRPCContext({
    session: {
      user: { id: "123", name: "John Doe" },
      expires: "1",
    },
  });
  const caller = appRouter.createCaller(ctx);
  // ...
});

Przydatne Zasoby

ZasóbLink
Dokumentacja tRPChttps://www.trpc.io
Parę przykładów z tRPChttps://github.com/trpc/trpc/tree/next/examples
Dokumentacja React Queryhttps://tanstack.com/query/v4/docs/adapters/react-query