Last active
May 22, 2023 08:34
-
-
Save ellemedit/817d376da99574240b470e415be9c535 to your computer and use it in GitHub Desktop.
나만의 OAS Generator + Query hooks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 응답은 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; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) }); | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | |
}; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
TIL:
이건 Context가 string으로 잘 추론되지만
이건 unknown이다... 선언 순서에 의존할 줄 몰랐다.