クイックスタート
TypeScript用RxJSベースのタイプセーフHTTPクライアント + (オプション) Next.js/RSocketアダプター。
主な特徴
- Swagger/OpenAPI / AsyncAPI派生スキーマ / カスタム契約などから得たOpenAPIスタイルのPathsタイプ(慣例的にpaths)を注入してルート(リクエスト)タイプの安定性を確保
- すべてのAPIはRxJS Observableを返す
- coreはフレームワークに依存しない(Next.js依存なし)
- Next.js専用機能は@byeolnaerim/typed-rx-http/nextエントリーポイントに分離 → /nextをインポートする時のみNext.jsが必要
- RSocket専用機能は@byeolnaerim/typed-rx-http/rsocketエントリーポイントに分離 → /rsocketをインポートする時のみRSocketパッケージが必要
インストール
npm
npm i @byeolnaerim/typed-rx-http rxjsエントリーポイント
Core(フレームワーク非依存)
import {
createHttpClient,
createHeaderStore,
createCsrCache,
createSessionAuth,
HttpResponseError,
isHttpResponseError,
type ServiceArguments,
type CacheForService,
} from "@byeolnaerim/typed-rx-http";Next.jsアダプター(オプション)
import {
callApiSsrCache,
redirectToUnauthorizedOnServer401,
} from "@byeolnaerim/typed-rx-http/next";Next.jsを使用しないプロジェクトでは/nextをインポートしないでください。
RSocketアダプター(オプション)
RSocketを使用するプロジェクトでのみpeerパッケージをインストールしてください。
npm i rsocket-core rsocket-types rsocket-websocket-clientimport { createRSocketApi } from "@byeolnaerim/typed-rx-http/rsocket";
const api = createRSocketApi("ws://localhost:7000/rsocket", jwt);
api.mono<User>("user.findById", { id: 1 }).subscribe((user) => {
console.log(user);
});
api.stream<Notification>("notification.stream").subscribe((notification) => {
console.log(notification);
});RSocketを使用しないプロジェクトでは/rsocketをインポートしないでください。
Coreの使用法
1) Pathsタイプの準備(通常はOpenAPI paths)
createHttpClient<Paths>()のPathsは「リクエスト仕様(ルート)」を表すタイプです。ドキュメントでは慣例的にpathsと呼ばれますが、必ずしもOpenAPI/Swaggerである必要はなく、名前がpathsである必要もありません。
ただし、coreは内部的にOpenApiPathsLike制約を使用するため、Pathsは以下のようにOpenAPI pathsに類似した形である必要があります。
- 最上位キー: URLパス文字列(例: "/users/{id}")
- 下位キー: HTTPメソッド(get/post/put/delete/patch …)
- 各メソッド内にparameters.query/path/header/cookie、requestBody、responsesなどのフィールドが存在(またはnever)
coreは上記構造から主に以下のフィールドを参照してServiceArgumentsのタイプを構成します。
- url: keyof Paths
- method: keyof Paths[url]
- queryString: parameters.query
- pathVariable: parameters.path
- body: requestBody
例: openapi-typescriptの出力は通常以下のような形式です(若干の省略あり)。
export interface paths {
"/test/get-hello-world": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
content: {
"application/json": components["schemas"]["TestResponse"];
};
};
};
};
};
}// aliasing: OpenAPI `paths` -> `Paths`
import type { paths as Paths } from "./@types/ApiTypes";2) HeaderStoreの作成
HeaderStoreはCSRで基本ヘッダーを管理するためのシンプルなインメモリストアです。
import { createHeaderStore } from "@byeolnaerim/typed-rx-http";
export const headerStore = createHeaderStore({
"Content-Type": "application/json",
});3) HTTPクライアントの作成
- headerStoreは任意ですが、CSRで基本ヘッダー/セッション認証を使用するには追加することを推奨します。
- headersProviderはSSR/マルチテナントのようにリクエストごとにヘッダー計算が必要な場合に使用します。
import { createHttpClient } from "@byeolnaerim/typed-rx-http";
import type { paths as Paths } from "./@types/ApiTypes";
const apiUrl = process.env.NEXT_PUBLIC_API_URL!;
export const client = createHttpClient<Paths>({
baseUrl: apiUrl,
headerStore,
});4) API呼び出し(タイプセーフ)
リクエストタイプ(url/method/pathVariable/queryString/body)は、ユーザーがcreateHttpClient<Paths>()に注入したタイプ(慣例的にOpenAPI paths)から決定されます。応答タイプはcallApi<R>()で呼び出し元がジェネリックRで選択します(コアはresponsesから自動推論しません)。
export const getAdminList = () => {
return client.callApi<{ items: any[]; total: number }>({
url: "/api/account/search/admin/get-list",
method: "get",
queryString: { pageNumber: 1, pageSize: 20 },
});
};応答ラッピング(ResponseWrapper) — 任意
このライブラリは応答ラッピングを強制しません。APIごとにジェネリックで応答形式を選択できます。
ラッピングされた応答
type ResponseWrapper<T> = { data: T; message?: string; resultType?: string };
export const bidCreate = (body: unknown) => {
return client.callApi<ResponseWrapper<{ requestId: string }>>({
url: "/api/bid/create/bid/request",
method: "post",
body,
});
};ラッピングなしの応答
export const bidCreate = (body: unknown) => {
return client.callApi<{ requestId: string }>({
url: "/api/bid/create/bid/request",
method: "post",
body,
});
};ストリーミング(NDJSON)
サーバーがNDJSON(1行にJSON1つ)を返すときはcallApiStreamを使用します。Acceptヘッダーがない場合はデフォルトでapplication/x-ndjsonが設定されます。
export const streamEvents = () => {
return client.callApiStream<{ eventName: string; content: unknown }>({
url: "/api/event/stream",
method: "get",
// headers: { Accept: "application/x-ndjson" }, // 선택
});
};CSRキャッシュ(クライアントキャッシュ)
createCsrCache<CacheName>()が提供する機能:
callApiCsrCache(callApiFn, serviceArgs, cacheOptions)- removeCsrCache(cacheName) — タイプキャッシュ名 + 文字列すべてをサポート
import { createCsrCache } from "@byeolnaerim/typed-rx-http";
import type { paths as Paths } from "./@types/ApiTypes";
type CacheNames = "adminList" | "profile";
const csrCache = createCsrCache<CacheNames>();
export const callApiClientCache = <R>(
args: ServiceArguments<Paths, any, any, R>,
cache: { cacheName: CacheNames; cacheTime: number; cacheSize?: number },
) => csrCache.callApiCsrCache(client.callApi, args as any, cache as any);
// 무효화
csrCache.removeCsrCache("adminList"); // 타입
csrCache.removeCsrCache("any-prefix"); // 문자열セッションベースの認証プラグイン(任意)
createSessionAuthはセッション認証ロジックをコアから分離し、オプションとして付け外しできる方式です。
動作:
- AuthorizationをheaderStoreに保持
- ensureToken$()でトークンを同期(/api/auth/token)
- 401が発生した場合、refreshを1回試行(/api/auth/token/refresh)した後、元のリクエストを再試行
- refreshに失敗した場合、logout(/api/auth/logout)を行いエラーを伝達
- ログイン状態の変更はonLoginChangeコールバックで外部から処理
import { createSessionAuth } from "@byeolnaerim/typed-rx-http";
const auth = createSessionAuth({
headerStore,
onLoginChange: (loggedIn) => {
// 예: setLogin(loggedIn) 같은 외부 상태 업데이트(선택)
},
// tokenUrl / refreshUrl / logoutUrl 커스터마이징 가능
});
export const secureCall = () => {
return client
.callApi<{ ok: true }>({ url: "/api/secure", method: "get" })
.pipe(auth.withSessionAuth());
};refresh/retryなしでトークンの同期のみが必要な場合:
return client.callApi(...).pipe(auth.withEnsureToken());エラー処理
2xxでない場合、HttpResponseErrorをスローします(status, response, args, dataを含む)。
レガシー互換: エラーボディが{ resultType: ... }形式の場合、そのオブジェクトをそのままスローします。
import { isHttpResponseError } from "@byeolnaerim/typed-rx-http";
client.callApi(...).subscribe({
error: (e) => {
if (isHttpResponseError(e)) {
console.log(e.status, e.data);
}
},
});Next.jsアダプター(/next)
redirectToUnauthorizedOnServer401
redirectToUnauthorizedOnServer401はNext.js(App Router)SSR環境で401が発生した際にリダイレクトを行う基本実装(便利関数)です。
import { redirectToUnauthorizedOnServer401 } from "@byeolnaerim/typed-rx-http/next";動作ルール(固定):
- リダイレクト先: /unauthorized
- queryString: redirect_uri=<現在のページ> + logout=true
- 現在のページはx-page-urlヘッダーから読み取ります(存在しない場合は/)
つまり、上記のパス/クエリルールがプロジェクトと一致する場合のみそのまま使用してください。パスが異なる場合やクエリルールが異なる場合は、以下のように直接onServer401を実装して注入すればよいです。
import { headers } from "next/headers";
import { redirect } from "next/navigation";
export const onServer401 = async () => {
const h = await headers();
const pageUrl = h.get("x-page-url") || "/";
redirect(`/login?next=${encodeURIComponent(pageUrl)}`);
};callApiSsrCache
Nextのnext/cache(unstable_cache)に基づくSSRキャッシュヘルパーです。
- GET + cacheTime > 0 → force-cache + revalidate
- それ以外 → no-store
- headersProviderでリクエストごとのCookie / Authorizationを注入
- 401が発生した場合、onServer401があれば実行されます(通常はredirect())。
import type { paths as Paths } from "./@types/ApiTypes";
import { firstValueFrom } from "rxjs";
import {
callApiSsrCache,
redirectToUnauthorizedOnServer401,
} from "@byeolnaerim/typed-rx-http/next";
const apiUrl = process.env.NEXT_PUBLIC_API_URL!;
const headersProvider = async () => {
const { cookies } = await import("next/headers");
const cookieStore = await cookies();
const cookieHeader = cookieStore
.getAll()
.map((c) => `${c.name}=${c.value}`)
.join("; ");
const accessToken = cookieStore.get("accessToken")?.value;
return {
...(cookieHeader ? { Cookie: cookieHeader } : {}),
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
};
};
const obs$ = callApiSsrCache<Paths, { ok: true }, "adminList">({
baseUrl: apiUrl,
serviceArguments: { url: "/api/secure", method: "get" },
cacheForService: { cacheName: "adminList", cacheTime: 10_000 },
headersProvider,
onServer401: redirectToUnauthorizedOnServer401,
});
const res = await firstValueFrom(obs$);Next.js統合例:プロジェクトrxjsHttpService/commonServiceの全コード
以下のrxjsHttpService.tsは、後のgenerator例のcommonServiceFileが指すプロジェクト共通HTTPアダプターです。ライブラリが生成するファイルではなく、プロジェクトが直接管理し、core client + session auth + CSR cache + SSR cache helperを一箇所でexportします。以下は全コードです。
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;
};
});
};APIリファレンス (core)
createHttpClient<Paths>(options)
返却:
callApi<R>(args): Observable<R>callApiStream<RChunk>(args): Observable<RChunk>uploadFile({ file, url, ifNoneMatch?, headers? }): Observable<Response>createSSEObservable<R>(args): Observable<R>
オプション:
baseUrl: stringheaderStore?: HeaderStoreheadersProvider?: () => Record<string, string> | Promise<Record<string, string>>- dropAuthWhenCacheControl?: boolean (デフォルト: true)
onServer401?: () => void | Promise<void>
createHeaderStore(initial?)
get(), set(), merge(), remove(), clear()
createCsrCache<CacheName>()
callApiCsrCache(callApiFn, serviceArgs, cacheForService)- removeCsrCache(cacheName) (タイプ + 文字列)
createSessionAuth(options)
withSessionAuth(), withEnsureToken()ensureToken$(), refreshToken$(), logout$()
ランタイム要件
- fetch / Response API使用(rxjs/fetch)
- ストリーミング(NDJSON)にはReadableStream + TextDecoderが必要です
- SSEにはEventSourceが必要です
ほとんどの最新ブラウザとNext.jsランタイムでは標準で提供されています。カスタムNodeランタイムではポリフィルが必要な場合があります。
Auto Node Script: OpenAPI/Swaggerコード生成(オプション)
このパッケージは、ランタイムHTTPクライアントとは別に、OpenAPI/Swagger JSONからタイプ/サービスコードを生成するNodeスクリプトを提供します。このスクリプトはオプション機能です。
一般的な@byeolnaerim/typed-rx-http, /next, /rsocketユーザーはこのスクリプトを実行する必要はなく、openapi-typescriptをインストールする必要もありません。
import {
connectToSwaggerEventStream,
generateSwaggerFromHttp,
generateSwaggerFromFile,
} from "@byeolnaerim/typed-rx-http/auto-node-script/openapi";依存関係の分離
OpenAPIタイプ生成にはopenapi-typescript CLIが必要です。しかし、このパッケージはopenapi-typescriptを一般的な依存関係に含めていません。
@byeolnaerim/typed-rx-httpライブラリ自体はdevDependencies.typescriptでTypeScript 6.0.3を使用しています。typed-rx-http, /next, /rsocketエントリーポイントとライブラリビルドはこのTypeScript 6.0.3基準を維持します。
ただし、openapi-typescriptはまだ特定のTypeScript 5.xバージョンを要求する可能性があるため、OpenAPI auto node scriptのみが別のnpx一時実行環境でopenapi-typescriptと[email protected]を一緒に実行します。この一時実行環境はライブラリのdevDependencies.typescript 6.0.3を変更せず、ユーザーのプロジェクトにインストールされたtypescriptやopenapi-typescriptバージョンも使用しません。
デフォルトはauto script実行時にのみ以下のコマンドを生成して実行することです。
npx -y -p openapi-typescript@latest -p [email protected] openapi-typescript swagger.json --output ./src/handler/service/@types/ApiTypes.d.tsしたがって、使用法自体は変わりません。従来通りauto node scriptを呼び出すことができ、OpenAPIタイプ生成段階でのみ分離されたTypeScript 5.9.3環境が使用されます。auto scriptを使用しないユーザーはopenapi-typescriptやTypeScript 5.9.3に全く依存しません。
必要に応じて 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",
});または、基本コマンドを構成するパッケージのバージョンを変更することもできます。
generateSwaggerFromFile({
inputFile: "./swagger.json",
openApiTypescriptPackage: "[email protected]",
openApiTypescriptTypescriptPackage: "[email protected]",
});生成されるファイル
基本設定は以下のファイルを生成します。
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 スキーマの enum だけでなく、query、path、header、cookie パラメータの enum も定数配列として生成します。配列の query パラメータの items.enum も処理します。
EventStream 監視
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",
});HTTP 1 回のリクエスト
await generateSwaggerFromHttp({
hostname: "localhost",
port: 8788,
path: "/oauth2/for-local/get-swagger",
});ローカルファイルから生成
generateSwaggerFromFile({
inputFile: "./swagger.json",
});既存プロジェクト統合例: WebFlux + Swagger 自動生成
ここからはバックエンド Swagger と自動生成サービスを併用するプロジェクト統合例です。上記の Core の使用法と選択機能は typed-rx-http 自体だけで使用でき、以下のフローは Swagger ベースのサービス自動生成を使用するプロジェクトで追加で適用されます。
1. バックエンドにRESTエンドポイントを作成
バックエンドコードは普段通りに記述します。この例では、パス変数で名前を、クエリパラメータでメッセージを受け取ります。 Monoと応答します。
TypedRxHttpExampleRouter.java
java@Bean
public RouterFunction<ServerResponse> typedRxHttpExample(
TypedRxHttpExampleHandler handler
) {
return route()
.GET("/typed-rx-http/example/{name}", handler::getExample)
.build();
}2. Swaggerでフロントサービスを生成
バックエンドの swagger.jsonを受け取り、タイプとサービスファイルを生成します。この作業は開発サーバーを実行する際に一度呼び出すか、watchスクリプトで接続できます。
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",
commonServiceFile:
"./src/handler/service/rxjsHttpService.ts",
});3. 生成された関数でリクエストを送信
以下で フロントコードと バックエンドコードを変更できます。 結果を押すと右側に実行画面が開きます。値を変更した後、リクエストボタンを押してみてください。
TypedRxHttpRequestExample.tsx
tsximport { byName } from "@handler/service/auto/TypedRxHttpExampleService";
import { firstValueFrom } from "rxjs";
const response = await firstValueFrom(
byName({
path: {
name: name.trim(),
},
params: {
message: message.trim() || undefined,
},
}),
);
setResponse(response);