OpenAPI & サービスジェネレーター

Core HTTP clientとは別に提供されるオプションのNodeスクリプトです。OpenAPI/Swagger JSONからTypeScriptタイプとserviceファイルを生成する時のみ使用します。


オプション機能: Core使用に必要なし

一般的な@byeolnaerim/typed-rx-http、/nextまたは/rsocketのユーザーはこのスクリプトを実行しなくてもよく、openapi-typescriptをプロジェクトにインストールする必要もありません。

ts
import {
  connectToSwaggerEventStream,
  generateSwaggerFromHttp,
  generateSwaggerFromFile,
} from "@byeolnaerim/typed-rx-http/auto-node-script/openapi";

openapi-typescript依存性の隔離

OpenAPIタイプ生成にはopenapi-typescript CLIが必要ですが、パッケージはこれを一般依存関係に入れません。typed-rx-http自体のdevDependencies.typescriptはTypeScript 6.0.3基準を維持し、OpenAPI auto nodeスクリプトでのみ別のnpx一時環境を使用します。

基本OpenAPI生成段階ではopenapi-typescriptと[email protected]を同時に実行するため、ユーザーのプロジェクトにインストールされたTypeScript/openapi-typescriptのバージョンを変更したり使用しません。

bash
npx -y -p openapi-typescript@latest -p [email protected] openapi-typescript swagger.json --output ./src/handler/service/@types/ApiTypes.d.ts

必要に応じて全コマンドをopenApiTypescriptCommandに固定できます。

ts
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だけを変更することもできます。

ts
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します。キャッシュやセッション認証が必要ない場合はこの構造から始められます。

rxjsHttpService.ts
ts
import {
	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;
セッション/CSR/SSRキャッシュを含む全体コード

generated serviceがcallApiClientCacheまたはcallApiServerCacheを使用したり、Next.jsでリクエストごとのヘッダーとセッション認証を共通処理するには、次の全体形で拡張します。

rxjsHttpService.ts
ts
import {
	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;
		};
	});
};

実行方式

ts
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",
});

生成されるファイル

基本設定は以下のファイルを生成します。

text
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.ts

apiUnionArrays.tsはOpenAPIスキーマenumだけでなく、query、path、header、cookieパラメータenumのreadonly定数配列も生成し、配列queryパラメータのitems.enumも処理します。


オプション: バックエンドSwaggerとプロジェクトcommonService接続

ここからはgeneratorを実際のプロジェクトに接続する例です。WebFlux/webflux-fe-dev-assistantはSwaggerを提供する一つの方法に過ぎず、必須依存性ではありません。

Swagger文書準備

SwaggerはSpringdoc、他のOpenAPIツール、または手動で作成したファイルでも構いません。WebFluxファンクショナルエンドポイントを使用する場合は webflux-fe-dev-assistantでSwagger文書を提供する方法も使用できます。

プロジェクトでオプションを明示して生成する例

バックエンドエンドポイント、保存するswagger.json、タイプ/service出力パスとcommonServiceFileをプロジェクト構造に合わせて明示できます。

generateSwagger.cjs
js
const {
  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とキャッシュラッパーをimportするプロジェクト所有ファイルのパスです。generatorがこのファイルを生成したり上書きするオプションではありません。

ApiBusinessService.ts
ts
import { callApi } from "../rxjsHttpService";

export const workplacesSearch = ({ params }) => {
  return callApi<SearchWorkplacesResponse>({
    url: "/api/business/workplaces/search",
    method: "get",
    queryString: params,
  });
};

プロジェクトで生成結果が配置される例

text
src/handler/service/
├─ rxjsHttpService.ts        # 사용자 소유, 생성하지 않음
├─ apiUnionArrays.ts
├─ auto/
│  ├─ ApiBusinessService.ts
│  ├─ ApiOrdersService.ts
│  └─ Oauth2LoginService.ts
└─ @types/
   ├─ ApiTypes.d.ts
   └─ auto/
      ├─ ApiBusinessTypes.ts
      ├─ ApiOrdersTypes.ts
      └─ Oauth2LoginTypes.ts

rxjsHttpService.tsのようにcommonServiceFileで指定した共用ファイルはプロジェクトが直接管理します。autoと@types/autoの下の結果は文書が変更されると再生成されます。

ファイル名と関数名の規則

サービスファイル名はURLの最初の2つのセグメントで、関数名は3つ目以降のセグメントで決まります。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パラメータkeyはparams、path variableはpath、request bodyはbodyです。生成関数内部でそれぞれqueryString、pathVariable、bodyのServiceArgumentsに変換されます。

© 2026 Byeolnaerim. 無断転載を禁じます。紹介プライバシーポリシー