HTTP Client
อธิบายวิธีการใช้ Core client โดยตรงก่อน จากนั้นอธิบายการรวมที่เลือกของ HTTP adapter ที่ใช้ร่วมกันในโปรเจกต์ที่บริการสร้างขึ้นอัตโนมัติใช้
การใช้ Core client โดยตรง
Paths ที่ฉีดเข้าไปใน createHttpClient<Paths>() คือสัญญาการร้องขอ url และ method มาจาก key ของ Paths ส่วน queryString และ pathVariable มาจาก parameters และ body จะถูกกำหนดประเภทจาก requestBody
ประเภทการตอบกลับจะถูกกำหนดโดย R ของ callApi<R>() โดยผู้เรียก
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 หากการตอบกลับจากเซิร์ฟเวอร์เป็น wrapper ให้กำหนดประเภท wrapper เป็น 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,
});ตัวเลือก: adapter ที่ใช้ร่วมกันในโปรเจกต์ + บริการสร้างอัตโนมัติ
การกำหนด commonService ด้านล่างนี้ไม่ใช่โครงสร้างที่จำเป็นของ Core แต่เป็นรูปแบบการรวมโปรเจกต์ที่ต้องการใช้การรับรอง, SSR header, นโยบาย cache ในหลายบริการสร้างอัตโนมัติพร้อมกัน
สิ่งที่ commonService ในโปรเจกต์ทำ
commonService ไม่ใช่ชื่อคลาสพิเศษที่จัดเตรียมโดย @byeolnaerim/typed-rx-http แต่เป็นไฟล์ที่เจ้าของโครงการใช้เพื่อรวบรวมนโยบาย HTTP ทั่วไปไว้ในที่เดียว. ชื่อไฟล์สามารถกำหนดได้อย่างอิสระและในตัวอย่างด้านล่างใช้ชื่อโครงการจริงคือ rxjsHttpService.ts.
ไม่ต้องทำการสร้าง createHttpClient ซ้ำในหน้าจอและ service ที่สร้างโดยอัตโนมัติ ใช้ callApi และ callApiStream ที่สร้างจากไฟล์นี้เป็นจุดเข้าถึงทั่วไป. ดังนั้นหากแก้ไขกฎการต่ออายุการรับรอง, การส่งต่อ header SSR, cache และการจัดการข้อผิดพลาดเพียงครั้งเดียว จะนำไปใช้กับทุก service ที่สร้าง.
เริ่มดูโค้ดทั้งหมดของ commonService/rxjsHttpService
1. รูปแบบที่เสร็จสมบูรณ์ขั้นต่ำ
รหัสด้านล่างคือแกนหลักของ commonService สร้าง headerStore และ typed HTTP client ขึ้นมาเพียงครั้งเดียว และ export callApi/callApiStream ที่ generated service ใช้ร่วมกันได้ 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 + session auth + รวม CSR/SSR cache เป็นรูปแบบขยายได้
ด้านล่างคือตัวอย่างโปรเจกต์ทั้งหมดที่เพิ่ม session auth, SSR header และ CSR/SSR cache ลงใน adapter สาธารณะเดียวกัน หากโปรเจกต์ที่ generated service นำเข้า cache wrapper นี่จะเป็นรูปแบบมาตรฐาน สามารถคัดลอกได้โดยตรงแล้วปรับ 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 เป็นประเภท OpenAPI paths ที่ใช้ตรวจสอบ URL, method, path variable, query และ body type.
headerStore
จัดเก็บ header ทั่วไป เช่น Content-Type และ Authorization ในเบราว์เซอร์ และแชร์กับการรับรองความถูกต้องของเซสชันและการเชื่อมต่อ RSocket.
headersProvider
ใน CSR จะส่งคืน headerStore และใน SSR จะอ่าน Cookie และ Authorization ของคำขอปัจจุบันเพื่อให้ header แตกต่างกันในแต่ละคำขอ.
service
HTTP client ที่มีประเภทที่แท้จริงพร้อม base URL, header ทั่วไป, การจัดการ 401 ของเซิร์ฟเวอร์ และข้อความแสดงข้อผิดพลาดพื้นฐาน.
sessionAuth
ซิงโครไนซ์ session token ก่อนคำขอ และถ้าเป็น 401 จะทำการรีเฟรชและลอง Observable เดิมอีกครั้ง หากล้มเหลวจะดำเนินการลบออก.
callApi / callApiStream
ฟังก์ชันทั่วไปที่บริการ REST อัตโนมัติและบริการ NDJSON stream นำเข้า ทั้งสองจะส่งคืน RxJS Observable.
callApiClientCache / callApiServerCache
ในเบราว์เซอร์จะใช้ CSR cache และในเซิร์ฟเวอร์จะเป็น cache wrapper ที่โหลด SSR cache ของ @byeolnaerim/typed-rx-http/next แบบไดนามิก.
วิธีการเชื่อมต่อกับบริการที่สร้างขึ้นอัตโนมัติ.
ตัวสร้างจะคำนวณเส้นทางสัมพัทธ์ของ commonServiceFile และใส่การนำเข้าสำหรับแต่ละบริการ หน้าจอจะนำเข้าฟังก์ชันที่สร้างขึ้นโดยตรงแทนที่จะเรียกไฟล์สาธารณะ และฟังก์ชันที่สร้างขึ้นจะเปลี่ยน path, params, body เป็น pathVariable, queryString, body ของ ServiceArguments และส่งต่อไปยัง callApi.
generateSwagger.cjs
jsgenerateSwaggerFromHttp({
serviceDir: "./src/handler/service",
typesDir: "./src/handler/service/@types",
commonServiceFile:
"./src/handler/service/rxjsHttpService.ts",
});การตอบกลับครั้งเดียวคือ firstValueFrom
REST API ที่ตอบกลับครั้งเดียวและจบ เช่น ค้นหา บันทึก และลบ สามารถใช้ Observable ที่สร้างขึ้นได้อย่างเป็นธรรมชาติในฟังก์ชัน async ที่มีอยู่firstValueFromในหน้าจอจะเห็นเพียง
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,
},
}),
);แต่ในบริการที่สร้างขึ้นจะมี URL, HTTP method และประเภทการตอบกลับอยู่เสมอ พารามิเตอร์ query จะถูกส่งไปเสมอเป็น return value{ params: { ... } }{ params: { ... } }ส่งไปยัง
หลายการตอบสนองคือ subscribe
คำขอที่มีค่าหลายค่ามาถึง เช่น ความก้าวหน้าของงานหรือ RSocket streamsubscribeจะได้รับ และยกเลิกการสมัครเมื่อคอมโพเนนต์หายไป
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 โดยตรง
ในโครงการที่ไม่ใช้บริการสร้างหรือเมื่อต้องการตรวจสอบ endpoint ชั่วคราวก่อนการสร้าง สามารถเรียก callApi ของบริการสาธารณะได้โดยตรง ประเภทคำขอจะถูกตรวจสอบใน paths และประเภทการตอบกลับจะถูกกำหนดโดยผู้เรียก.
TemporaryService.ts
tsreturn callApi<HealthResponse>({
url: "/health",
method: "get",
});