OpenAPI & Service Generator
Core HTTP client와 별도로 제공되는 선택적 Node script입니다. OpenAPI/Swagger JSON에서 TypeScript 타입과 service 파일을 생성할 때만 사용합니다.
선택 기능: Core 사용에 필요하지 않음
일반적인 @byeolnaerim/typed-rx-http, /next 또는 /rsocket 사용자는 이 script를 실행하지 않아도 되고 openapi-typescript를 프로젝트에 설치할 필요도 없습니다.
import {
connectToSwaggerEventStream,
generateSwaggerFromHttp,
generateSwaggerFromFile,
} from "@byeolnaerim/typed-rx-http/auto-node-script/openapi";openapi-typescript 의존성 격리
OpenAPI 타입 생성에는 openapi-typescript CLI가 필요하지만, 패키지는 이를 일반 dependencies에 넣지 않습니다. typed-rx-http 자체의 devDependencies.typescript는 TypeScript 6.0.3 기준을 유지하고, OpenAPI auto node script에서만 별도의 npx 임시 환경을 사용합니다.
기본 OpenAPI 생성 단계에서는 openapi-typescript와 [email protected]을 함께 실행하므로 사용자의 프로젝트에 설치된 TypeScript/openapi-typescript 버전을 바꾸거나 사용하지 않습니다.
npx -y -p openapi-typescript@latest -p [email protected] openapi-typescript swagger.json --output ./src/handler/service/@types/ApiTypes.d.ts필요하면 전체 명령을 openApiTypescriptCommand로 고정할 수 있습니다.
generateSwaggerFromFile({
inputFile: "./swagger.json",
openApiTypescriptCommand:
"npx -y -p [email protected] -p [email protected] openapi-typescript ./swagger.json --output ./src/handler/service/@types/ApiTypes.d.ts",
});또는 기본 명령을 구성하는 package version만 바꿀 수도 있습니다.
generateSwaggerFromFile({
inputFile: "./swagger.json",
openApiTypescriptPackage: "[email protected]",
openApiTypescriptTypescriptPackage: "[email protected]",
});먼저: commonServiceFile이 가리킬 프로젝트 파일 준비
generator 예제에 commonServiceFile이 등장하기 전에 이 파일부터 이해해야 합니다. commonServiceFile은 파일을 만들어 주는 옵션이 아니라 generated service가 공통 HTTP 함수들을 import할 프로젝트 소유 파일의 경로입니다. 파일명은 rxjsHttpService.ts, commonService.ts 등 자유롭게 정할 수 있습니다.
최소 완성형 전체 코드
아래 파일은 createHttpClient를 프로젝트에서 한 번 생성하고 callApi와 callApiStream을 generated service가 재사용하도록 export합니다. cache나 session auth가 필요하지 않다면 이 구조부터 시작할 수 있습니다.
rxjsHttpService.ts
tsimport {
createHeaderStore,
createHttpClient,
type ServiceArguments,
} from "@byeolnaerim/typed-rx-http";
import type { Observable } from "rxjs";
import type { paths } from "./@types/ApiTypes";
export const headerStore = createHeaderStore({
"Content-Type": "application/json",
});
export const service = createHttpClient<paths>({
baseUrl: process.env.NEXT_PUBLIC_API_URL || "",
headerStore,
});
export const callApi = <
R,
TPath extends keyof paths = keyof paths,
TMethod extends keyof paths[TPath] & string = keyof paths[TPath] & string,
>(
serviceArguments: ServiceArguments<paths, TPath, TMethod, R>,
): Observable<R> => {
return service.callApi<R, TPath, TMethod>(serviceArguments);
};
export const callApiStream = <
R,
TPath extends keyof paths = keyof paths,
TMethod extends keyof paths[TPath] & string = keyof paths[TPath] & string,
>(
serviceArguments: ServiceArguments<paths, TPath, TMethod, R>,
): Observable<R> => {
return service.callApiStream<R, TPath, TMethod>(serviceArguments);
};
export const uploadFile = service.uploadFile;
export const createSSEObservable = service.createSSEObservable;Session/CSR/SSR cache까지 포함한 전체 코드
generated service가 callApiClientCache 또는 callApiServerCache까지 사용하거나 Next.js에서 요청별 header와 세션 인증을 공통 처리하려면 다음 전체 형태로 확장합니다.
rxjsHttpService.ts
tsimport {
createCsrCache,
createHeaderStore,
createHttpClient,
createSessionAuth,
} from "@byeolnaerim/typed-rx-http";
import type {
CacheForService,
ServiceArguments,
} from "@byeolnaerim/typed-rx-http";
import { defer, Observable } from "rxjs";
import { setLogin } from "@ui/handler/hooks/useAccounts";
import type { paths } from "./@types/ApiTypes";
import type { CacheNames } from "./@types/CacheNames";
export const redirectToUnauthorizedOnServer401 = async () => {
const { headers } = await import("next/headers");
const { redirect } = await import("next/navigation");
const h = await headers();
const pageUrl = h.get("x-page-url") || "/";
const redirectUri = encodeURIComponent(pageUrl);
redirect(`/unauthorized?redirect_uri=${redirectUri}&logout=true`);
};
export const headerStore = createHeaderStore({
"Content-Type": "application/json",
});
const headersProvider = async () => {
if (typeof window !== "undefined") {
/**
* 라이브러리쪽 코드가
* if (headersProvider) return headersProvider();
* if (headerStore) return headerStore.get();
* 이렇게 되어있음 같이 못씀, 방향자체는 맞음. 라이브러리쪽에서 marge해버리면 csr/ssr 구분못함
*/
return headerStore.get();
}
const { cookies } = await import("next/headers");
const cookieStore = await cookies();
const cookieHeader = cookieStore
.getAll()
.map((c) => `${c.name}=${c.value}`)
.join("; ");
const authorization = cookieStore.get("Authorization")?.value;
const accessToken = cookieStore.get("accessToken")?.value;
return {
...(cookieHeader ? { Cookie: cookieHeader } : {}),
...(authorization
? {
Authorization: authorization.startsWith("Bearer ")
? authorization
: `Bearer ${authorization}`,
}
: {}),
...(!authorization && accessToken
? {
Authorization: accessToken.startsWith("Bearer ")
? accessToken
: `Bearer ${accessToken}`,
}
: {}),
};
};
export const service = createHttpClient<paths>({
baseUrl: process.env.NEXT_PUBLIC_API_URL || "",
onServer401: redirectToUnauthorizedOnServer401,
defaultErrorMessage: () => {
return "알 수 없는 오류가 발생했습니다.";
},
headerStore,
headersProvider,
dropAuthWhenCacheControl: true,
});
const sessionAuth = createSessionAuth({
headerStore,
tokenUrl: "/api/auth/token",
refreshUrl: "/api/auth/token/refresh",
logoutUrl: "/api/auth/logout",
onLoginChange: (loggedIn) => {
if (typeof window !== "undefined") {
setLogin(loggedIn);
}
},
});
export const callApi = <
R,
TPath extends keyof paths = keyof paths,
TMethod extends keyof paths[TPath] & string = keyof paths[TPath] & string,
>(
serviceArguments: ServiceArguments<paths, TPath, TMethod, R>,
): Observable<R> => {
return defer(() =>
service.callApi<R, TPath, TMethod>(serviceArguments),
).pipe(sessionAuth.withSessionAuth());
};
export const callApiStream = <
R,
TPath extends keyof paths = keyof paths,
TMethod extends keyof paths[TPath] & string = keyof paths[TPath] & string,
>(
serviceArguments: ServiceArguments<paths, TPath, TMethod, R>,
): Observable<R> => {
return defer(() =>
service.callApiStream<R, TPath, TMethod>(serviceArguments),
).pipe(sessionAuth.withSessionAuth());
};
// CSR cache
const csrCache = createCsrCache<CacheNames>();
export const callApiClientCache = <
R,
TPath extends keyof paths = keyof paths,
TMethod extends keyof paths[TPath] & string = keyof paths[TPath] & string,
>(
serviceArguments: ServiceArguments<paths, TPath, TMethod, R>,
cacheForService: CacheForService<CacheNames>,
): Observable<R> => {
return csrCache.callApiCsrCache(callApi, serviceArguments, cacheForService);
};
export const removeCsrCache = csrCache.removeCsrCache;
export const callApiServerCache = <
R,
TPath extends keyof paths = keyof paths,
TMethod extends keyof paths[TPath] & string = keyof paths[TPath] & string,
>(
serviceArguments: ServiceArguments<paths, TPath, TMethod, R>,
cacheForService: CacheForService<CacheNames>,
): Observable<R> => {
if (typeof window !== "undefined") {
return callApiClientCache(serviceArguments, cacheForService);
}
return new Observable<R>((subscriber) => {
let closed = false;
import("@byeolnaerim/typed-rx-http/next")
.then(({ callApiSsrCache }) => {
if (closed) {
return;
}
const subscription = callApiSsrCache<paths, R, CacheNames>({
baseUrl: process.env.NEXT_PUBLIC_API_URL || "",
serviceArguments: serviceArguments as ServiceArguments<
paths,
keyof paths,
keyof paths[keyof paths] & string,
R
>,
cacheForService,
headersProvider,
onServer401: redirectToUnauthorizedOnServer401,
}).subscribe(subscriber);
subscriber.add(() => {
closed = true;
subscription.unsubscribe();
});
})
.catch((error) => {
if (!closed) {
subscriber.error(error);
}
});
return () => {
closed = true;
};
});
};실행 방식
connectToSwaggerEventStream({
hostname: "localhost",
port: 8788,
path: "/oauth2/for-local/get-swagger",
serviceDir: "./src/handler/service",
typesDir: "./src/handler/service/@types",
commonServiceFile: "./src/handler/service/rxjsHttpService.ts",
});생성되는 파일
기본 설정은 아래 파일들을 생성합니다.
swagger.json
src/handler/service/@types/ApiTypes.d.ts
src/handler/service/auto/*Service.ts
src/handler/service/@types/auto/*Types.ts
src/handler/service/apiUnionArrays.tsapiUnionArrays.ts는 OpenAPI schema enum뿐 아니라 query, path, header, cookie parameter enum의 readonly 상수 배열도 생성하고, 배열 query parameter의 items.enum도 처리합니다.
선택: 백엔드 Swagger와 프로젝트 commonService 연결
여기부터는 generator를 실제 프로젝트에 연결하는 예제입니다. WebFlux/webflux-fe-dev-assistant는 Swagger를 제공하는 한 가지 방법일 뿐 필수 의존성이 아닙니다.
Swagger 문서 준비
Swagger는 Springdoc, 다른 OpenAPI 도구 또는 직접 작성한 파일이어도 됩니다. WebFlux functional endpoint를 사용한다면 webflux-fe-dev-assistant로 Swagger 문서를 제공하는 방식도 사용할 수 있습니다.
프로젝트에서 옵션을 명시해 생성하는 예
백엔드 endpoint, 저장할 swagger.json, 타입/service 출력 경로와 commonServiceFile을 프로젝트 구조에 맞게 명시할 수 있습니다.
generateSwagger.cjs
jsconst {
generateSwaggerFromHttp,
} = require("@byeolnaerim/typed-rx-http/auto-node-script/openapi");
generateSwaggerFromHttp({
hostname: "localhost",
port: 8123,
path: "/for-local/get-swagger",
swaggerFile: "./swagger.json",
serviceDir: "./src/handler/service",
typesDir: "./src/handler/service/@types",
typesFile: "./src/handler/service/@types/ApiTypes.d.ts",
unionArraysFile: "./src/handler/service/apiUnionArrays.ts",
commonServiceFile:
"./src/handler/service/rxjsHttpService.ts",
});HTTP로 문서를 받을 수 없는 환경에서는 같은 출력 옵션과 함께 generateSwaggerFromFile을 사용할 수 있습니다.
commonServiceFile은 import 대상 경로
commonServiceFile은 생성된 service가 callApi, callApiStream과 cache wrapper를 import할 프로젝트 소유 파일의 경로입니다. generator가 이 파일을 생성하거나 덮어쓰는 옵션이 아닙니다.
ApiBusinessService.ts
tsimport { callApi } from "../rxjsHttpService";
export const workplacesSearch = ({ params }) => {
return callApi<SearchWorkplacesResponse>({
url: "/api/business/workplaces/search",
method: "get",
queryString: params,
});
};프로젝트에서 생성 결과가 배치되는 예
src/handler/service/
├─ rxjsHttpService.ts # 사용자 소유, 생성하지 않음
├─ apiUnionArrays.ts
├─ auto/
│ ├─ ApiBusinessService.ts
│ ├─ ApiOrdersService.ts
│ └─ Oauth2LoginService.ts
└─ @types/
├─ ApiTypes.d.ts
└─ auto/
├─ ApiBusinessTypes.ts
├─ ApiOrdersTypes.ts
└─ Oauth2LoginTypes.tsrxjsHttpService.ts처럼 commonServiceFile로 지정한 공용 파일은 프로젝트가 직접 관리합니다. auto와 @types/auto 아래 결과는 문서가 바뀌면 다시 생성됩니다.
파일명과 함수명 규칙
서비스 파일명은 URL의 앞 두 segment로, 함수명은 세 번째 이후 segment로 결정됩니다. path variable은 By + PascalCase 형태로 함수명에 포함됩니다.
GET /api/business/workplaces/search
ApiBusinessService.ts → workplacesSearch({ params })
GET /api/business/workplaces/{id}
ApiBusinessService.ts → workplacesById({ path })
GET /api/orders/history/search
ApiOrdersService.ts → historySearch({ params })
POST /oauth2/login
Oauth2LoginService.ts → post({ body })
생성 함수의 호출 인자
GeneratedServiceUsage.tsx
tsx// path variable
workplacesById({
path: { id: "workplace-1" },
});
// query parameter
workplacesSearch({
params: { q: "auction", size: 20 },
});
// JSON request body
post({
body: { username, password },
});생성된 service의 query parameter key는 params, path variable은 path, request body는 body입니다. 생성 함수 내부에서 각각 queryString, pathVariable, body의 ServiceArguments로 변환됩니다.