Skip to content

Instantly share code, notes, and snippets.

@ellemedit
Last active May 22, 2023 08:34
Show Gist options
  • Save ellemedit/817d376da99574240b470e415be9c535 to your computer and use it in GitHub Desktop.
Save ellemedit/817d376da99574240b470e415be9c535 to your computer and use it in GitHub Desktop.
나만의 OAS Generator + Query hooks
// 응답은 OAS 스키마에 { data?: any; error?: any, paginationMeta?: any } 꼴로 표현된다
// 이 꼴은 data가 존재할 때, error, msg가 같이 존재할 수 있는 암시적인 의미를 지닌다.
// 이 타입은 error일땐 data가 확실히 없고
// data가 있을 땐 error가 확실히 없는 tagged union을 표현하기 위한 타입이다.
// 이를 통해 success인지 error인지 result state를 확실하게 특정할 수 있다.
export type ResolveApiResponse<ResponseBody> = ResponseBody extends {
data?: infer D;
paginationMeta?: infer PM;
error?: infer E;
}
?
| ApiResponseSuccessWithBody<D, PM>
| ApiResponseFailedWithBody<E>
: ResponseBody extends void
? ApiResponseSuccessWithoutBody | ApiResponseFailedWithoutBody
: ResponseBody;
export type ApiResponseSuccessWithBody<D, PM> = {
type: "success";
data: NonNullable<D>;
paginationMeta: PM;
headers: Headers;
};
export type ApiResponseFailedWithBody<E> = {
type: "error";
error: NonNullable<E>;
headers: Headers;
};
export type ApiResponseSuccessWithoutBody = {
type: "success";
headers: Headers;
};
export type ApiResponseFailedWithoutBody = {
type: "error";
status: number;
headers: Headers;
};
export type ResolveSuccess<T> = T extends { type: "success" } ? T : never;
export type ResolveError<T> = T extends { type: "error" } ? T : never;
export type ResolveApiResponseBody<T> = T extends (
...args: any[]
) => Promise<infer P>
? P
: never;
export type ResolveApiResponseBodyInSuccess<T> = ResolveSuccess<
ResolveApiResponseBody<T>
>;
export type ResolveApiResponseBodyInError<T> = ResolveError<
ResolveApiResponseBody<T>
>;
export type ResolveApiSuccessData<
T extends ResolveApiResponse<unknown> = unknown
> = ResolveSuccess<ResolveApiResponseBody<T>>["data"];
export type ResolveApiErrorData<
T extends ResolveApiResponse<unknown> = unknown
> = ResolveError<ResolveApiResponseBody<T>>["error"];
declare function FetchClient<T>(options: {
url: string;
method: "get" | "post" | "put" | "delete" | "patch";
params?: any;
data?: any;
responseType?: string;
headers?: HeadersInit;
}): Promise<ResolveApiResponse<T>>;
export default FetchClient;
import {
QueryClient,
useQueryClient,
useMutation as useTanstackMutation,
useQuery as useTanstackQuery,
} from "@tanstack/react-query";
import { useMemo } from "react";
let NEED_CACHE_KEY_CONSISTENCY = true;
function getCacheKey<T extends (...params: any[]) => Promise<any>>(
operation: T,
...params: Parameters<T>
) {
if (process.env.NODE_ENV === "development") {
if (NEED_CACHE_KEY_CONSISTENCY) {
if (!("operationName" in operation)) {
console.error(
`operation ${operation} can causes a bug in production, give operationName property explicitly`,
new Error().stack
);
}
}
}
return [
"operationName" in operation ? operation.operationName : operation.name,
...params,
];
}
export function setOperationName<T extends (...params: any[]) => Promise<any>>(
operation: T,
name: string
) {
Object.defineProperties(operation, {
operationName: {
configurable: false,
enumerable: false,
value: name,
writable: false,
},
});
}
type ResolveHenesisQueryOperationResponse<Body> = Body extends {
type: "success";
data: infer D;
paginationMeta: infer PM;
}
? { data: D; paginationMeta: PM }
: Body extends {
type: "error";
error: any;
}
? never
: Body;
export function useQuery<T extends (...params: any[]) => Promise<any>>(
operation: T,
...params: Parameters<T>
): [
// data: Awaited<ReturnType<T>>,
data: ResolveHenesisQueryOperationResponse<Awaited<ReturnType<T>>>,
isRefetching: boolean,
refetch: () => void
] {
const query = useTanstackQuery({
queryKey: getCacheKey(operation, ...params),
queryFn: () =>
operation(...params).then((result) => {
if ("type" in result) {
if (result.type === "error") {
throw result;
}
if (result.type === "success") {
return result;
}
}
return result;
}),
suspense: true,
throwErrors: true,
});
return [query.data as any, query.isRefetching, query.refetch];
}
export function useMutation<
T extends (...params: any[]) => Promise<any>,
Context = unknown
>(
operation: T,
options: {
onCommit?: (params: Parameters<T>) => Context;
onSuccess?: (
data: Awaited<ReturnType<T>>,
params: Parameters<T>,
context: Context
) => void;
onError?: (error: unknown, params: Parameters<T>, context: Context) => void;
onSettled?: (
data: Awaited<ReturnType<T>> | undefined,
error: unknown | undefined,
params: Parameters<T>,
context: Context
) => void;
}
): [
commit: (...params: Parameters<T>) => void,
isInFlight: boolean,
lastMutation: {
error?: unknown;
reset: () => void;
}
] {
NEED_CACHE_KEY_CONSISTENCY = false;
const { onCommit, onSuccess, onError, onSettled } = options;
const mutation = useTanstackMutation({
mutationFn: (params: Parameters<T>) => operation(...(params as any[])),
onMutate: onCommit,
onSuccess: onSuccess as (
data: Awaited<ReturnType<T>>,
params: Parameters<T>,
context: Context | void
) => void,
onError: onError as (
error: unknown,
params: Parameters<T>,
context: Context | void
) => void,
onSettled: onSettled as (
data: Awaited<ReturnType<T>> | undefined,
error: unknown | undefined,
params: Parameters<T>,
context: Context | void
) => void,
});
NEED_CACHE_KEY_CONSISTENCY = true;
return [
(...params: any[]) => mutation.mutate(params as any),
mutation.isPending,
{
error: mutation.error,
reset: () => mutation.reset(),
},
];
}
export function usePrefetchQuery() {
const queryClient = useQueryClient();
return useMemo(() => {
interface PrefetchQuery {
<T extends (...params: any[]) => Promise<any>>(
operation: T,
...params: Parameters<T>
): Promise<void>;
}
return prefetchQuery.bind(null, queryClient) as PrefetchQuery;
}, [queryClient]);
}
export function useInvalidateQuery() {
const queryClient = useQueryClient();
return useMemo(() => {
interface InvalidateQuery {
<T extends (...params: any[]) => Promise<any>>(
operation: T,
...params: Parameters<T>
): Promise<void>;
}
return invalidateQuery.bind(null, queryClient) as InvalidateQuery;
}, [queryClient]);
}
export function useInvalidateAllQueries() {
const queryClient = useQueryClient();
return useMemo(() => {
interface InvalidateAllQueries {
<T extends (...params: any[]) => Promise<any>>(
operation: T
): Promise<void>;
}
return invalidateAllQueries.bind(null, queryClient) as InvalidateAllQueries;
}, [queryClient]);
}
export function useDispatchQueryLocalState() {
const queryClient = useQueryClient();
return useMemo(() => {
interface DispatchQueryLocalState {
<T extends (...params: any[]) => Promise<any>>(
operation: T,
...params: Parameters<T>
): (
updater: (
previousState?: Awaited<ReturnType<T>>
) => Awaited<ReturnType<T>>
) => void;
}
return dispatchQueryLocalState.bind(
null,
queryClient
) as DispatchQueryLocalState;
}, [queryClient]);
}
export function useCancelQuery() {
const queryClient = useQueryClient();
return useMemo(() => {
interface CancelQuery {
<T extends (...params: any[]) => Promise<any>>(
operation: T,
...params: Parameters<T>
): void;
}
return cancelQuery.bind(null, queryClient) as CancelQuery;
}, [queryClient]);
}
export function prefetchQuery<T extends (...params: any[]) => Promise<any>>(
queryClient: QueryClient,
operation: T,
...params: Parameters<T>
) {
return queryClient.prefetchQuery({
queryKey: getCacheKey(operation, ...params),
queryFn: () => operation(...params),
});
}
export function invalidateQuery<T extends (...params: any[]) => Promise<any>>(
queryClient: QueryClient,
operation: T,
...params: Parameters<T>
) {
queryClient.removeQueries({ queryKey: getCacheKey(operation, ...params) });
}
export function invalidateAllQueries<
T extends (...params: any[]) => Promise<any>
>(queryClient: QueryClient, operation: T) {
queryClient.removeQueries({ queryKey: [operation.name] });
}
export function dispatchQueryLocalState<
T extends (...params: any[]) => Promise<any>
>(
queryClient: QueryClient,
operation: T,
...params: Parameters<T>
): (
updater: (previousState?: Awaited<ReturnType<T>>) => Awaited<ReturnType<T>>
) => void {
return function dispatchLocalState(updater) {
queryClient.setQueryData(getCacheKey(operation, ...params), updater);
};
}
export function cancelQuery<T extends (...params: any[]) => Promise<any>>(
queryClient: QueryClient,
operation: T,
...params: Parameters<T>
) {
queryClient.cancelQueries({ queryKey: getCacheKey(operation, ...params) });
}
import {
ClientBuilder,
ClientDependenciesBuilder,
ClientFooterBuilder,
ClientGeneratorsBuilder,
ClientHeaderBuilder,
ClientTitleBuilder,
generateFormDataAndUrlEncodedFunction,
generateMutatorConfig,
generateMutatorRequestOptions,
generateOptions,
generateVerbImports,
GeneratorDependency,
GeneratorOptions,
GeneratorVerbOptions,
isSyntheticDefaultImportsAllow,
pascal,
sanitize,
toObjectString,
VERBS_WITH_BODY,
} from "@orval/core";
const DEPENDENCIES: GeneratorDependency[] = [
{
exports: [
{
name: "operationFetcher",
default: true,
values: true,
syntheticDefaultImport: true,
},
{ name: "OperationFetcherRequestConfig" },
{ name: "OperationFetcherResponse" },
],
dependency: "operationFetcher",
},
];
const returnTypesToWrite: Map<string, (title?: string) => string> = new Map();
export const getOperationFetcherDependencies: ClientDependenciesBuilder = (
hasGlobalMutator
) => [...(!hasGlobalMutator ? DEPENDENCIES : [])];
const operationFetcherImplementation = (
{
headers,
queryParams,
operationName,
response,
mutator,
body,
props,
verb,
override,
formData,
formUrlEncoded,
}: GeneratorVerbOptions,
{ route, context }: GeneratorOptions
) => {
const isRequestOptions = override?.requestOptions !== false;
const isFormData = override?.formData !== false;
const isFormUrlEncoded = override?.formUrlEncoded !== false;
const isExactOptionalPropertyTypes =
!!context.tsconfig?.compilerOptions?.exactOptionalPropertyTypes;
const isSyntheticDefaultImportsAllowed = isSyntheticDefaultImportsAllow(
context.tsconfig
);
const bodyForm = generateFormDataAndUrlEncodedFunction({
formData,
formUrlEncoded,
body,
isFormData,
isFormUrlEncoded,
});
const isBodyVerb = VERBS_WITH_BODY.includes(verb);
if (mutator) {
const mutatorConfig = generateMutatorConfig({
route,
body,
headers,
queryParams,
response,
verb,
isFormData,
isFormUrlEncoded,
isBodyVerb,
hasSignal: false,
isExactOptionalPropertyTypes,
});
const requestOptions = isRequestOptions
? generateMutatorRequestOptions(
override?.requestOptions,
mutator.hasSecondArg
)
: "";
returnTypesToWrite.set(
operationName,
(title?: string) =>
`export type ${pascal(
operationName
)}Result = NonNullable<Awaited<ReturnType<${
title
? `ReturnType<typeof ${title}>['${operationName}']`
: `typeof ${operationName}`
}>>>`
);
const propsImplementation =
mutator.bodyTypeName && body.definition
? toObjectString(props, "implementation").replace(
new RegExp(`(\\w*):\\s?${body.definition}`),
`$1: ${mutator.bodyTypeName}<${body.definition}>`
)
: toObjectString(props, "implementation");
return `const ${operationName} = (\n ${propsImplementation}\n ${
isRequestOptions && mutator.hasSecondArg
? `options?: SecondParameter<typeof ${mutator.name}>,`
: ""
}) => {${bodyForm}
return ${mutator.name}<${response.definition.success || "unknown"}>(
${mutatorConfig},
${requestOptions});
}
${operationName}.name = "${operationName}";
`;
}
const options = generateOptions({
route,
body,
headers,
queryParams,
response,
verb,
requestOptions: override?.requestOptions,
isFormData,
isFormUrlEncoded,
isExactOptionalPropertyTypes,
hasSignal: false,
});
returnTypesToWrite.set(
operationName,
() =>
`export type ${pascal(operationName)}Result = OperationFetcherResponse<${
response.definition.success || "unknown"
}>`
);
return `const ${operationName} = <TData = OperationFetcherResponse<${
response.definition.success || "unknown"
}>>(\n ${toObjectString(props, "implementation")} ${
isRequestOptions ? `options?: OperationFetcherRequestConfig\n` : ""
} ): Promise<TData> => {${bodyForm}
return operationFetcher${
!isSyntheticDefaultImportsAllowed ? ".default" : ""
}.${verb}(${options});
}
`;
};
export const operationFetcherTitle: ClientTitleBuilder = (title) => {
const sanTitle = sanitize(title);
return `get${pascal(sanTitle)}`;
};
export const operationFetcherHeader: ClientHeaderBuilder = ({
title,
isRequestOptions,
isMutator,
noFunction,
}) => `
${
isRequestOptions && isMutator
? `// eslint-disable-next-line
type SecondParameter<T extends (...args: any) => any> = T extends (
config: any,
args: infer P,
) => any
? P
: never;\n\n`
: ""
}
${!noFunction ? `export const ${title} = () => {\n` : ""}`;
export const operationFetcherFooter: ClientFooterBuilder = ({
operationNames,
title,
noFunction,
hasMutator,
hasAwaitedType,
}) => {
let footer = "";
if (!noFunction) {
footer += `return {${operationNames.join(",")}}};\n`;
}
if (hasMutator && !hasAwaitedType) {
footer += `\ntype AwaitedInput<T> = PromiseLike<T> | T;\n
type Awaited<O> = O extends AwaitedInput<infer T> ? T : never;
\n`;
}
operationNames.forEach((operationName) => {
if (returnTypesToWrite.has(operationName)) {
const func = returnTypesToWrite.get(operationName)!;
footer += func(!noFunction ? title : undefined) + "\n";
}
});
return footer;
};
export const operationFetcher = (
verbOptions: GeneratorVerbOptions,
options: GeneratorOptions
) => {
const imports = generateVerbImports(verbOptions);
const implementation = operationFetcherImplementation(verbOptions, options);
return { implementation, imports };
};
export const operationFetcherFunctions: ClientBuilder = async (
verbOptions,
options
) => {
const { implementation, imports } = operationFetcher(verbOptions, options);
return {
implementation: "export " + implementation,
imports,
};
};
export const OperationFetcherBuilder: ClientGeneratorsBuilder = {
client: operationFetcherFunctions,
header: (options) => operationFetcherHeader({ ...options, noFunction: true }),
dependencies: getOperationFetcherDependencies,
footer: (options) => operationFetcherFooter({ ...options, noFunction: true }),
title: operationFetcherTitle,
};
import { OperationFetcherBuilder } from "./OperationFetcherBuilder";
const config = {
"API": {
input: {
target: "./schema/api.yaml",
},
output: {
target: "./src/api/v2.ts",
client: () => OperationFetcherBuilder,
prettier: true,
override: {
mutator: {
path: "./src/fetchClient.ts",
name: "fetchClient",
},
},
},
},
};
export default config;
@ellemedit
Copy link
Author

ellemedit commented Apr 19, 2023

TIL:

  const [commit, isInFlight] = useMutation(signTransaction, {
    ...mutationListener,
    onMutate() {
      return "test";
    },
    onSuccess(data, params, context) {
      context;
    },
  });

이건 Context가 string으로 잘 추론되지만

  const [commit, isInFlight] = useMutation(signTransaction, {
    ...mutationListener,
    onSuccess(data, params, context) {
      context;
    },
    onMutate() {
      return "test";
    },
  });

이건 unknown이다... 선언 순서에 의존할 줄 몰랐다.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment