OpenAPI & Service Generator

สคริปต์ Node ที่เลือกได้ซึ่งจัดเตรียมแยกจาก Core HTTP client ใช้เฉพาะเมื่อสร้าง TypeScript type และ service file จาก OpenAPI/Swagger JSON เท่านั้น


ฟังก์ชันเลือก: ไม่จำเป็นต้องใช้กับ 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 type ต้องการ openapi-typescript CLI แต่แพ็คเกจจะไม่ใส่ไว้ใน dependencies ทั่วไป typed-rx-http เองมี devDependencies.typescript ที่รักษามาตรฐาน TypeScript 6.0.3 และใช้สภาพแวดล้อม npx ชั่วคราวเฉพาะใน OpenAPI auto node script เท่านั้น

ในขั้นตอนการสร้าง 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",
});

หรือสามารถเปลี่ยนเฉพาะเวอร์ชันแพ็คเกจที่กำหนดคำสั่งพื้นฐานได้

ts
generateSwaggerFromFile({
  inputFile: "./swagger.json",
  openApiTypescriptPackage: "[email protected]",
  openApiTypescriptTypescriptPackage: "[email protected]",
});

ก่อนอื่น: เตรียมไฟล์โปรเจกต์ที่ commonServiceFile จะชี้ไปยัง

ต้องเข้าใจก่อนว่า commonServiceFile จะปรากฏในตัวอย่าง generator ไฟล์นี้ไม่ใช่ตัวเลือกในการสร้างไฟล์ แต่เป็นเส้นทางของไฟล์ที่เจ้าของโปรเจกต์นำเข้า HTTP ฟังก์ชันทั่วไป ไฟล์สามารถตั้งชื่อได้ตามต้องการ เช่น rxjsHttpService.ts, commonService.ts เป็นต้น

โค้ดทั้งหมดในรูปแบบที่เสร็จสมบูรณ์ขั้นต่ำ

ไฟล์ด้านล่างสร้าง createHttpClient ขึ้นมาในโปรเจกต์เพียงครั้งเดียวและ export ให้ generated service ใช้ callApi และ callApiStream หากไม่ต้องการ cache หรือ session auth สามารถเริ่มต้นจากโครงสร้างนี้ได้

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;
โค้ดทั้งหมดรวมถึง Session/CSR/SSR cache

หาก generated service ใช้ callApiClientCache หรือ callApiServerCache หรือหากต้องการจัดการ header และการรับรองความถูกต้องของเซสชันใน 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สร้าง readonly คอนสแตนต์อาร์เรย์สำหรับ enum ของ OpenAPI schema ไม่เพียงแต่ยังรวมถึง query, path, header, cookie parameter enum และจัดการ items.enum ของอาร์เรย์ query parameter ด้วย


เลือก: เชื่อมต่อ Swagger ของ backend กับ commonService ของโปรเจกต์

ตั้งแต่ที่นี่เป็นตัวอย่างการเชื่อมต่อ generator กับโปรเจกต์จริง WebFlux/webflux-fe-dev-assistant เป็นเพียงวิธีหนึ่งในการให้ Swagger ไม่ใช่การพึ่งพาที่จำเป็น

เตรียมเอกสาร Swagger

Swagger สามารถเป็น Springdoc, เครื่องมือ OpenAPI อื่น ๆ หรือไฟล์ที่เขียนขึ้นเอง หากใช้ WebFlux functional endpoint webflux-fe-dev-assistantสามารถใช้วิธีการให้เอกสาร Swagger ได้เช่นกัน

ตัวอย่างการสร้างที่ระบุทางเลือกในโปรเจกต์

สามารถระบุ endpoint ของ backend, swagger.json ที่จะเก็บ, เส้นทางการส่งออก type/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 คือเส้นทางที่นำเข้า

commonServiceFile.เป็นเส้นทางของไฟล์ที่เจ้าของโปรเจกต์นำเข้า callApi, callApiStream และ cache wrapper ไฟล์นี้ไม่ใช่ตัวเลือกในการสร้างหรือเขียนทับโดย 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

ไฟล์สาธารณะที่กำหนดโดย commonServiceFile เช่น rxjsHttpService.ts จะถูกจัดการโดยโปรเจกต์โดยตรง ผลลัพธ์ภายใต้ auto และ @types/auto จะถูกสร้างใหม่เมื่อเอกสารเปลี่ยนแปลง

กฎการตั้งชื่อไฟล์และฟังก์ชัน

ชื่อไฟล์บริการจะถูกกำหนดจากสอง segment แรกของ URL และชื่อฟังก์ชันจะถูกกำหนดจาก segment ที่สามขึ้นไป ตัวแปร path จะรวมอยู่ในชื่อฟังก์ชันในรูปแบบ 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 },
});

คีย์พารามิเตอร์ query ของ service ที่สร้างขึ้นคือ params, ตัวแปร path คือ path, request body คือ body จะถูกแปลงเป็น ServiceArguments ของ queryString, pathVariable, body ภายในฟังก์ชันสร้าง