HTTPクライアント
Core クライアントを直接使用する方法を最初に説明し、その後自動生成サービスが使用するプロジェクト共通 HTTP アダプターを選択的統合例として説明します。
Core クライアント直接使用
createHttpClient<Paths>() に注入する Paths がリクエスト契約です。url と method は Paths のキーから、queryString と pathVariable は parameters から、body は requestBody でタイプが決まります。
応答タイプは callApi<R>() の R を呼び出し元が指定します。Core は OpenAPI responses から応答タイプを自動推論しません。
httpClient.ts
tsimport {
createHeaderStore,
createHttpClient,
} from "@byeolnaerim/typed-rx-http";
import type { paths as Paths } from "./@types/ApiTypes";
export const headerStore = createHeaderStore({
"Content-Type": "application/json",
});
export const client = createHttpClient<Paths>({
baseUrl: process.env.NEXT_PUBLIC_API_URL!,
headerStore,
});
export const getAdminList = () => {
return client.callApi<{ items: any[]; total: number }>({
url: "/api/account/search/admin/get-list",
method: "get",
queryString: { pageNumber: 1, pageSize: 20 },
});
};応答の形状は API ごとに選択
typed-rx-http は ResponseWrapper を強制しません。サーバー応答がラッパーであればラッパータイプを R に指定し、そうでなければ実際の応答タイプをそのまま指定します。
type ResponseWrapper<T> = { data: T; message?: string; resultType?: string };
return client.callApi<ResponseWrapper<{ requestId: string }>>({
url: "/api/bid/create/bid/request",
method: "post",
body,
});選択: プロジェクト共通アダプター + 自動生成サービス
以下の commonService 構成は Core の必須構造ではなく、複数の生成サービスに認証・SSR ヘッダー・キャッシュポリシーを一度に適用しようとするプロジェクト統合パターンです。
プロジェクト commonService がすること
commonServiceは@byeolnaerim/typed-rx-httpが提供する特別なクラス名ではなく、プロジェクトで共通HTTPポリシーを一箇所に集めるために使用するユーザー所有ファイルを指します。ファイル名は自由で、以下の例では実際のプロジェクト名であるrxjsHttpService.tsを使用します。
画面と自動生成サービスごとにcreateHttpClientを繰り返さず、このファイルが生成したcallApiとcallApiStreamを共通エントリーポイントとして使用します。したがって、認証更新、SSR headerの伝達、キャッシュとエラー処理のルールを一度修正すれば、すべての生成サービスに同様に適用されます。
commonService/rxjsHttpService 全体コードから見る
1. 最小完成形
commonServiceの本質は以下のコードです。headerStoreとtyped HTTP clientを一度作成し、generated serviceが共通で使用するcallApi/callApiStreamをexportします。uploadFileとcreateSSEObservableも同じclientでそのまま公開できます。
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;2. Next.js + セッション認証 + CSR/SSRキャッシュを含む拡張型
以下は同じ共用アダプタにセッション認証、SSRヘッダー、CSR/SSRキャッシュを追加した全体プロジェクトの例です。generated serviceがキャッシュラッパーまでimportするプロジェクトであれば、この形が基準となります。そのままコピーした後、setLogin import、ApiTypesとCacheNamesの位置、認証APIパス、環境変数とunauthorizedパスを自分のプロジェクトに合わせて調整すればよいです。
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;
};
});
};コード構成別の役割
paths
createHttpClientとServiceArgumentsがURL、メソッド、パス変数、クエリ、ボディタイプを検査する際に使用するOpenAPIパスのタイプです。
headerStore
ブラウザでContent-TypeやAuthorizationなどの共通ヘッダーを保存し、セッション認証やRSocket接続と共有します。
headersProvider
CSRではheaderStoreを返し、SSRでは現在のリクエストのCookieとAuthorizationを読み取り、リクエストごとに異なるヘッダーを提供します。
service
ベースURL、共通ヘッダー、サーバー401処理と基本エラーメッセージを持つ実際の型付きHTTPクライアントです。
sessionAuth
リクエスト前にセッショントークンを同期し、401の場合はリフレッシュ後に元のObservableを一度再試行し、失敗した場合はログアウトフローを実行します。
callApi / callApiStream
自動生成された一般的なRESTサービスとNDJSONストリームサービスがインポートする共通関数です。どちらもRxJS Observableを返します。
callApiClientCache / callApiServerCache
ブラウザではCSRキャッシュを使用し、サーバーでは@byeolnaerim/typed-rx-http/nextのSSRキャッシュを動的に呼び出すキャッシュラッパーです。
自動生成サービスと接続される方法
ジェネレーターはcommonServiceFileの相対パスを計算し、各サービスにインポートを追加します。画面は共通ファイルを直接呼び出さず、生成された関数をインポートし、生成関数がパス、パラメータ、ボディをServiceArgumentsのpathVariable、queryString、bodyに変換してcallApiに渡します。
generateSwagger.cjs
jsgenerateSwaggerFromHttp({
serviceDir: "./src/handler/service",
typesDir: "./src/handler/service/@types",
commonServiceFile:
"./src/handler/service/rxjsHttpService.ts",
});一度の応答はfirstValueFrom
検索、保存、削除のように一度応答して終わるREST APIは生成されたObservableをfirstValueFromで受け取ると既存のasync関数内でも自然に使用できます。
BusinessSearchPage.tsx
tsximport { workplacesSearch } from "@handler/service/auto/ApiBusinessService";
import { firstValueFrom } from "rxjs";
const response = await firstValueFrom(
workplacesSearch({
params: {
source: "EIW",
q: keyword,
size: 20,
nextToken: nextToken || undefined,
},
}),
);画面では paramsのみ表示されますが、生成されたサービス内にはURL、HTTPメソッドと応答タイプが含まれています。クエリパラメータは常に{ params: { ... } }で渡します。
複数の応答はsubscribe
作業進捗やRSocketストリームのように複数の値が到着するリクエストはsubscribeで受け取り、コンポーネントが消えるときに購読を解除します。
BatchJobPage.tsx
tsxconst subscription = processBatchJobs({
body: targets,
}).subscribe({
next: (event) => {
setProcessedCount(event.processedCount);
},
error: (error) => {
setErrorMessage(error.message);
},
complete: () => {
setIsSubmitting(false);
},
});
return () => subscription.unsubscribe();直接callApiを使用する場合
サービスジェネレーターを使用しないプロジェクトや生成前に一時エンドポイントを確認する場合は、共通サービスのcallApiを直接呼び出すことができます。リクエストタイプはpathsで検査し、レスポンスタイプは呼び出し元がジェネリックで指定します。
TemporaryService.ts
tsreturn callApi<HealthResponse>({
url: "/health",
method: "get",
});