Skip to content

Instantly share code, notes, and snippets.

@seonghyeonkimm
Last active August 19, 2024 18:14
Show Gist options
  • Save seonghyeonkimm/977b58387f9f4e11afeee8c7685c2e89 to your computer and use it in GitHub Desktop.
Save seonghyeonkimm/977b58387f9f4e11afeee8c7685c2e89 to your computer and use it in GitHub Desktop.
OpenApi 스펙을 활용해서 type-safe하게 react-query 사용하기

OpenApi 스펙을 활용해서 type-safe하게 ReactQuery 사용하기

 graphql 백엔드와 통신하면서 프론트 작업을 할 때에는 graphql-codegen 사용해서 graphql schema를 기준으로 types을 generate해서 사용했었습니다. 그래서 따로 parameters, body, 그리고 response에 대해서 type을 따로 신경쓸 필요 없었고 type-safe하게 작업할 수 있었습니다. 그런데 이번에 시작한 프로젝트에서는 graphql가 아닌 rest api로 구성되어있는 백엔드와 통신해야하는 상황이 생겼고, 직접 type들을 타이핑을 해야한다고 생각하니 그렇게 하기가 싫었습니다. (물론, Paste JSON as Code을 사용하면 조금 더 편하게 할 수 있긴 하지만 모든 문제를 해결해주는 것은 아니기 때문에)

 예전에 최태건님이 FEConf에서 발표했던 OpenAPI Specification으로 타입-세이프하게 API 개발하기: 희망편 VS 절망편 영상을 보면서 백엔드에서 OpenApi 스펙을 제공해준다면 이 정보를 이용해서 typing을 만들어 낼 수 있다는 사실을 배웠던 것이 생각이 났고 새로운 프로젝트를 시작하는 김에 OpenApi를 사용해서 type을 generate하고 type-safe하게 react-query hook을 사용하고 싶었습니다. 그래서 개인적으로 어떻게 OpenApi 스펙과 몇 가지 라이브러리들을 활용해서 어떻게 react-query hook을 사용하고 있는지 정리해보려고합니다. 바로 그냥 코드만 보고 싶으신 분들은 아래 링크를 클릭하셔서 코드만 확인하실 수도 있습니다.

 저는 다음 라이브러리들을 이용했습니다.

  • react-query: server state를 요청하고 관리하기 위해서 사용합니다.
  • openapi-typescript: 간단한 커맨드를 사용해서 OpenApi 스펙을 기준으로 typing을 생성해줍니다.
  • ts-toolbelt: openapi-typescript 생성해주는 type들을 편하게 사용하기 위해서 typescript utils들을 제공해주는 라이브러리를 사용했습니다.

 위의 라이브러리들을 설치를 하고나서 우선 처음 해야하는 작업은 OpenApi 스펙을 input으로 주고 openapi-typescript 라이브러리를 활용해서 typing을 생성해야 합니다. 우선 이 내용을 진행하기 위해서는 백엔드 서버에서 OpenApi 스펙이 정의된 JSON을 받을 수 있어야 합니다. 모든 분들이 이 정보를 가지고 있지 않으실 수도 있기 때문에 설명을 위해서 인터넷에 공개되어있는 openapi-generator OpenApi Specs를 사용해서 type을 만들고 react-query로 요청을 해본다고 가정해보겠습니다.

 우선 type을 생성해봅시다. 아래 커맨드를 실행하면 따로 첨부한 types.ts 파일의 내용같은 type들이 generate됩니다.

# output parameter를 사용하면 어디에 type을 생성할지 결정할 수 있습니다.
npx openapi-typescript https://api.openapi-generator.tech/api-docs --output ./src/types/index.ts

 결과물을 보면 몇 가지 interface들이 생성됩니다. 우선 각각의 interface가 어떤 값들을 의미하는 지에 대해서 이해가 필요합니다. paths와 operations를 제외하고 다른 type들도 생성이 되지만 저는 주로 paths와 operations만 참고해서 사용했습니다.

* paths interface
  - 요청 api endpoint(ex. /api/gen/clients)를 키로하고 어떤 method들을 지원하고 각 method는 어떤 response를 return하는지에 대한 interface입니다.
* operations interface
  - paths에서 각 method들의 parameters(path, query)와 response에 대한 type들을 모아두고 있는 interface입니다.

 다음으로 react-query의 useQuery의 key값을 매번 직접 지정해주기 귀찮기 때문에 queryKey로 paths의 key로 사용하고, 그 값을 기준으로 params, body, response type, error type 등을 추론하도록 만들기 위해서는 util성의 타입들을 먼저 작업해야 했습니다. 이 utils성 타입들은 openapi-typescript가 생성해준 type들에서 필요한 key들만을 select하거나 parameters 혹은 response의 type를 추론하기 위해서 다음과 같이 작업했습니다.

import { O } from 'ts-toolbelt';

import { paths } from './types';

// 생성된 paths interface의 모든 키들의 type
export type OAIPathKeys = keyof paths;
// 주로 사용되는 api method들에 대한 type
export type OAIMethods = 'get' | 'put' | 'post' | 'delete' | 'patch';
// 특정 method가 존재하는 paths key들만 뽑기 위한 type
export type OAIMethodPathKeys<TMethod extends OAIMethods> = O.SelectKeys<
  paths,
  Record<TMethod, unknown>
>;

// 특정 paths, method에 해당되는 path parameters type (api params에 대한 type)
export type OAIPathParameters<
  TPath extends OAIPathKeys,
  TMethod extends OAIMethods,
> = O.Path<paths, [TPath, TMethod, 'parameters', 'path']>;
// 특정 paths, method에 해당되는 query parameters type (api querystring에 대한 type)
export type OAIQueryParameters<
  TPath extends OAIPathKeys,
  TMethod extends OAIMethods,
> = O.Path<paths, [TPath, TMethod, 'parameters', 'query']>;
// react-query의 variables로 params, querystring을 통합해서 받아서 사용하기 위한 type
export type OAIParameters<
  TPath extends OAIPathKeys,
  TMethod extends OAIMethods,
> = OAIPathParameters<TPath, TMethod> extends Record<string, unknown>
  ? OAIQueryParameters<TPath, TMethod> extends Record<string, unknown>
    ? O.Merge<
        OAIPathParameters<TPath, TMethod>,
        OAIQueryParameters<TPath, TMethod>
      >
    : OAIPathParameters<TPath, TMethod>
  : OAIQueryParameters<TPath, TMethod> extends Record<string, unknown>
  ? OAIQueryParameters<TPath, TMethod>
  : undefined;

// 특정 path, method의 statusCode 200에 대한 response type
export type OAIResponse<
  TPath extends keyof paths,
  TMethod extends OAIMethods,
> = O.Path<
  paths,
  // 아래의 path는 주어진 OpenApi Specs에 따라서 달라질 수 있습니다.
  [TPath, TMethod, 'responses', 200, 'schema']
>;

 다음으로, 위에 작성한 utils성 type들을 활용해서 react-query의 useQuery의 type을 확장해주는 useOAIQuery라는 hook을 만들었습니다.

import type { UseQueryOptions } from 'react-query';
import { useQuery } from 'react-query';

// BASE_URL은 요청해야하는 서버의 주소로 변경 필요
const BASE_URL = 'https://api.openapi-generator.tech';

// useQuery에 전달한 queryKey, variables를 requestUrl로 만들기 위한 utils
export const getRequestUrl = (
  queryKey: string,
  variables?: Record<string, unknown>,
) => {
  let url = `${BASE_URL}${queryKey}`;

  const paramKeys = (queryKey.match(/{[a-zA-z-]+}/g) ?? []).map((param) =>
    param.replace(/[{}]/g, ''),
  );
  paramKeys.forEach((param) => {
    url = url.replace(`{${param}}`, variables?.[param] as string);
  });

  const qs = new URLSearchParams(
    Object.entries(variables ?? {}).reduce((current, [key, value]) => {
      if (paramKeys.includes(key)) {
        return current;
      }

      return {
        ...current,
        [key]: value,
      };
    }, {}),
  ).toString();

  if (qs) {
    url = `${url}?${qs}`;
  }

  return url;
};

// 위에 생성한 types, utils을 활용하여 useQuery를 wrapping하고 있는 useOAIQuery
export function useOAIQuery<
  TQueryKey extends OAIMethodPathKeys<'get'>,
  TVariables extends OAIParameters<TQueryKey, 'get'>,
  TData extends OAIResponse<TQueryKey, 'get'>,
>({
  queryKey,
  queryOptions,
  variables,
}: {
  queryKey: TQueryKey;
  queryOptions?: Omit<
    UseQueryOptions<
      TData,
      unknown,
      TData,
      (Record<string, unknown> | TQueryKey | undefined)[]
    >,
    'queryKey' | 'queryFn'
  >;
} & (TVariables extends undefined
  ? {
      variables?: undefined;
    }
  : O.RequiredKeys<NonNullable<TVariables>> extends never
  ? {
      variables?: TVariables;
    }
  : {
      variables: TVariables;
    })) {
  return useQuery(
    [queryKey, variables],
    async ({ signal }) => {
      const response = await fetch(getRequestUrl(queryKey, variables), {
        signal,
      });
     
      if (!response.ok) {
        throw new Error('Response error');
      }

      const json = (await response.json()) as TData;
      return json;
    },
    queryOptions,
  );
}

 위와 같이 util types, util 함수, useOAIQuery 등을 만들어 두고 실제로 사용한다면 매번 react-query의 key를 어떻게 지정해야할 지 고민하지 않아도 되고, type을 정확하게 사용할 수 있습니다. 완전한 코드 스니펫을 보고 싶으신 분은 아래 링크를 확인해주세요.

 이 글에서 예시로 적은 코드들은 실제로 사용하고 있는 코드와는 조금 차이가 있습니다. 우선, 최대한 제가 전달하고자 하는 내용들을 설명하기에 부족함이 없고 작동하는 코드를 보여드리기 위해서 최대한 간결하게 정리하여서 공유했습니다 (여전히 제 실력이 부족하여 복잡하긴 합니다.). 제가 공유한 코드 스니펫들이 바로 사용할 수 있는 수준까지는 많은 보완이 필요할 수 있겠지만 rest api와 통신하는 작업을 진행하실 때에 type safe하게 server state를 관리하고 싶고 react-query나 swc를 사용할 때에 key를 매번 직접 적어주는 불편함을 느끼고 계신 분들에게 하나의 아이디어나 도움이 조금이라도 될 수 있으면 좋겠습니다.

import type { UseQueryOptions } from 'react-query';
import { useQuery } from 'react-query';
import type { O } from 'ts-toolbelt';
import type { paths } from '~/types/specs';
// // 생성된 paths interface의 모든 키들의 type
export type OAIPathKeys = keyof paths; // 주로 사용되는 api method들에 대한 type
export type OAIMethods = 'get' | 'put' | 'post' | 'delete' | 'patch';
// 특정 method가 존재하는 paths key들만 뽑기 위한 type
export type OAIMethodPathKeys<TMethod extends OAIMethods> = O.SelectKeys<
paths,
Record<TMethod, unknown>
>;
// 특정 paths의 key, method의 parameters path type (api params에 대한 type)
export type OAIPathParameters<
TPath extends OAIPathKeys,
TMethod extends OAIMethods,
> = O.Path<paths, [TPath, TMethod, 'parameters', 'path']>;
// 특정 paths의 key, method의 parameters query type (api querystring에 대한 type)
export type OAIQueryParameters<
TPath extends OAIPathKeys,
TMethod extends OAIMethods,
> = O.Path<paths, [TPath, TMethod, 'parameters', 'query']>;
// react-query에서 variables에서 params, querystring을 통합해서 받아서 사용하기 위한 type
export type OAIParameters<
TPath extends OAIPathKeys,
TMethod extends OAIMethods,
> = OAIPathParameters<TPath, TMethod> extends Record<string, unknown>
? OAIQueryParameters<TPath, TMethod> extends Record<string, unknown>
? O.Merge<
OAIPathParameters<TPath, TMethod>,
OAIQueryParameters<TPath, TMethod>
>
: OAIPathParameters<TPath, TMethod>
: OAIQueryParameters<TPath, TMethod> extends Record<string, unknown>
? OAIQueryParameters<TPath, TMethod>
: undefined;
// 특정 path, method의 statusCode 200에 대한 response type
export type OAIResponse<
TPath extends keyof paths,
TMethod extends OAIMethods,
> = O.Path<paths, [TPath, TMethod, 'responses', 200, 'schema']>;
const BASE_URL = 'https://api.openapi-generator.tech';
export const getRequestUrl = (
queryKey: string,
variables?: Record<string, unknown>,
) => {
let url = `${BASE_URL}${queryKey}`;
const paramKeys = (queryKey.match(/{[a-zA-z-]+}/g) ?? []).map((param) =>
param.replace(/[{}]/g, ''),
);
paramKeys.forEach((param) => {
url = url.replace(`{${param}}`, variables?.[param] as string);
});
const qs = new URLSearchParams(
Object.entries(variables ?? {}).reduce((current, [key, value]) => {
if (paramKeys.includes(key)) {
return current;
}
return {
...current,
[key]: value,
};
}, {}),
).toString();
if (qs) {
url = `${url}?${qs}`;
}
return url;
};
export function useOAIQuery<
TQueryKey extends OAIMethodPathKeys<'get'>,
TVariables extends OAIParameters<TQueryKey, 'get'>,
TData extends OAIResponse<TQueryKey, 'get'>,
>({
queryKey,
queryOptions,
variables,
}: {
queryKey: TQueryKey;
queryOptions?: Omit<
UseQueryOptions<
TData,
unknown,
TData,
(Record<string, unknown> | TQueryKey | undefined)[]
>,
'queryKey' | 'queryFn'
>;
} & (TVariables extends undefined
? {
variables?: undefined;
}
: O.RequiredKeys<NonNullable<TVariables>> extends never
? {
variables?: TVariables;
}
: {
variables: TVariables;
})) {
return useQuery(
[queryKey, variables],
async ({ signal }) => {
const response = await fetch(getRequestUrl(queryKey, variables), {
signal,
});
if (!response.ok) {
throw new Error('Response error');
}
const json = (await response.json()) as TData;
return json;
},
queryOptions,
);
}
// 실제로 useOAIQuery hook을 사용하는 컴포넌트 예시
const MyComponent = () => {
const { data } = useOAIQuery({
queryKey: '/api/gen/clients/{language}',
variables: {
language: 'ko',
},
});
return null;
};
/**
* This file was auto-generated by openapi-typescript.
* Do not make direct changes to the file.
*/
export interface paths {
"/api/gen/clients": {
get: operations["clientOptions"];
};
"/api/gen/clients/{language}": {
get: operations["getClientOptions"];
/** Accepts a `GeneratorInput` options map for spec location and generation options */
post: operations["generateClient"];
};
"/api/gen/download/{fileId}": {
/** A valid `fileId` is generated by the `/clients/{language}` or `/servers/{language}` POST operations. The fileId code can be used just once, after which a new `fileId` will need to be requested. */
get: operations["downloadFile"];
};
"/api/gen/servers": {
get: operations["serverOptions"];
};
"/api/gen/servers/{framework}": {
get: operations["getServerOptions"];
/** Accepts a `GeneratorInput` options map for spec location and generation options. */
post: operations["generateServerForLanguage"];
};
}
export interface definitions {
/** AuthorizationValue */
AuthorizationValue: {
keyName?: string;
type?: string;
urlMatcher?: definitions["PredicateOfURL"];
value?: string;
};
/** CliOption */
CliOption: {
default?: string;
description?: string;
enum?: { [key: string]: string };
opt?: string;
optValue?: string;
type?: string;
};
/** GeneratorInput */
GeneratorInput: {
authorizationValue?: definitions["AuthorizationValue"];
/** @example https://raw.githubusercontent.com/OpenAPITools/openapi-generator/master/modules/openapi-generator/src/test/resources/2_0/petstore.yaml */
openAPIUrl?: string;
options?: { [key: string]: string };
spec?: { [key: string]: unknown };
};
/** PredicateOfURL */
PredicateOfURL: { [key: string]: unknown };
/** ResponseCode */
ResponseCode: {
/**
* @description File download code
* @example d40029be-eda6-4d62-b1ef-d05e2e91a72a
*/
code?: string;
/**
* @description URL for fetching the generated client
* @example http://localhost:8080/api/gen/download/d40029be-eda6-4d62-b1ef-d05e2e91a72a
*/
link?: string;
};
/** URLStreamHandler */
URLStreamHandler: { [key: string]: unknown };
}
export interface operations {
clientOptions: {
responses: {
/** successful operation */
200: {
schema: string[];
};
/** Unauthorized */
401: unknown;
/** Forbidden */
403: unknown;
/** Not Found */
404: unknown;
};
};
getClientOptions: {
parameters: {
path: {
/** The target language for the client library */
language: string;
};
};
responses: {
/** successful operation */
200: {
schema: { [key: string]: definitions["CliOption"] };
};
/** Unauthorized */
401: unknown;
/** Forbidden */
403: unknown;
/** Not Found */
404: unknown;
};
};
/** Accepts a `GeneratorInput` options map for spec location and generation options */
generateClient: {
parameters: {
body: {
/** Configuration for building the client library */
generatorInput: definitions["GeneratorInput"];
};
path: {
/** The target language for the client library */
language: string;
};
};
responses: {
/** successful operation */
200: {
schema: definitions["ResponseCode"];
};
/** Created */
201: unknown;
/** Unauthorized */
401: unknown;
/** Forbidden */
403: unknown;
/** Not Found */
404: unknown;
};
};
/** A valid `fileId` is generated by the `/clients/{language}` or `/servers/{language}` POST operations. The fileId code can be used just once, after which a new `fileId` will need to be requested. */
downloadFile: {
parameters: {
path: {
/** fileId */
fileId: string;
};
};
responses: {
/** successful operation */
200: {
schema: unknown;
};
/** Unauthorized */
401: unknown;
/** Forbidden */
403: unknown;
/** Not Found */
404: unknown;
};
};
serverOptions: {
responses: {
/** successful operation */
200: {
schema: string[];
};
/** Unauthorized */
401: unknown;
/** Forbidden */
403: unknown;
/** Not Found */
404: unknown;
};
};
getServerOptions: {
parameters: {
path: {
/** The target language for the server framework */
framework: string;
};
};
responses: {
/** successful operation */
200: {
schema: { [key: string]: definitions["CliOption"] };
};
/** Unauthorized */
401: unknown;
/** Forbidden */
403: unknown;
/** Not Found */
404: unknown;
};
};
/** Accepts a `GeneratorInput` options map for spec location and generation options. */
generateServerForLanguage: {
parameters: {
path: {
/** framework */
framework: string;
};
body: {
/** parameters */
generatorInput: definitions["GeneratorInput"];
};
};
responses: {
/** successful operation */
200: {
schema: definitions["ResponseCode"];
};
/** Created */
201: unknown;
/** Unauthorized */
401: unknown;
/** Forbidden */
403: unknown;
/** Not Found */
404: unknown;
};
};
}
export interface external {}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment