@compas/code-gen
Version:
Generate various boring parts of your server
742 lines (651 loc) • 20.4 kB
JavaScript
import { upperCaseFirst } from "../../utils.js";
import { fileBlockEnd, fileBlockStart } from "../file/block.js";
import {
fileContextAddLinePrefix,
fileContextCreateGeneric,
fileContextGetOptional,
fileContextRemoveLinePrefix,
fileContextSetIndent,
} from "../file/context.js";
import { fileFormatInlineComment } from "../file/format.js";
import { fileWrite, fileWriteInline } from "../file/write.js";
import { structureResolveReference } from "../processors/structure.js";
import { JavascriptImportCollector } from "../target/javascript.js";
import { typesOptionalityIsOptional } from "../types/optionality.js";
import { apiClientDistilledTargetInfo } from "./generator.js";
/**
* Write wrapper context to the common directory
*
* @param {import("../generate").GenerateContext} generateContext
*/
export function reactQueryGenerateCommonFile(generateContext) {
const distilledTargetInfo = apiClientDistilledTargetInfo(generateContext);
if (distilledTargetInfo.useGlobalClients) {
return;
}
const file = fileContextCreateGeneric(
generateContext,
`common/api-client-wrapper.tsx`,
{
importCollector: new JavascriptImportCollector(),
},
);
file.contents = `"use client";\n\n${file.contents}`;
const importCollector = JavascriptImportCollector.getImportCollector(file);
importCollector.raw(`import React from "react";`);
importCollector.destructure("react", "createContext");
importCollector.destructure("react", "PropsWithChildren");
importCollector.destructure("react", "useContext");
if (distilledTargetInfo.isAxios) {
importCollector.destructure("axios", "AxiosInstance");
fileWrite(
file,
`
const ApiContext = createContext<AxiosInstance | undefined>(undefined);
export function ApiProvider({
instance, children,
}: PropsWithChildren<{
instance: AxiosInstance;
}>) {
return <ApiContext.Provider value={instance}>{children}</ApiContext.Provider>;
}
export const useApi = () => {
const context = useContext(ApiContext);
if (!context) {
throw Error("Be sure to wrap your application with <ApiProvider>.");
}
return context;
};`,
);
} else if (distilledTargetInfo.isFetch) {
importCollector.destructure("./api-client", "FetchFn");
fileWrite(
file,
`
const ApiContext = createContext<FetchFn | undefined>(undefined);
export function ApiProvider({
fetchFn, children,
}: PropsWithChildren<{
fetchFn: FetchFn;
}>) {
return <ApiContext.Provider value={fetchFn}>{children}</ApiContext.Provider>;
}
export const useApi = () => {
const context = useContext(ApiContext);
if (!context) {
throw Error("Be sure to wrap your application with <ApiProvider>.");
}
return context;
};`,
);
}
}
/**
* Get the api client file
*
* @param {import("../generate").GenerateContext} generateContext
* @param {import("../generated/common/types").ExperimentalRouteDefinition} route
* @returns {import("../file/context").GenerateFile}
*/
export function reactQueryGetApiClientFile(generateContext, route) {
let file = fileContextGetOptional(
generateContext,
`${route.group}/reactQueries.tsx`,
);
if (file) {
return file;
}
file = fileContextCreateGeneric(
generateContext,
`${route.group}/reactQueries.tsx`,
{
importCollector: new JavascriptImportCollector(),
},
);
const distilledTargetInfo = apiClientDistilledTargetInfo(generateContext);
const importCollector = JavascriptImportCollector.getImportCollector(file);
if (distilledTargetInfo.useGlobalClients) {
// Import the global clients, this has affect on a bunch of the generated api's where we don't have to accept these arguments
if (distilledTargetInfo.isAxios) {
importCollector.destructure(`../common/api-client`, "axiosInstance");
} else if (distilledTargetInfo.isFetch) {
importCollector.destructure(`../common/api-client`, "fetchFn");
}
importCollector.destructure("../common/api-client", "queryClient");
} else {
// Import ways to infer or accept the clients
importCollector.destructure("../common/api-client-wrapper", "useApi");
importCollector.destructure("@tanstack/react-query", "useQueryClient");
if (distilledTargetInfo.isAxios) {
importCollector.destructure("axios", "AxiosInstance");
} else {
importCollector.destructure("../common/api-client", "FetchFn");
}
}
// Error handling
importCollector.destructure("../common/api-client", "AppErrorResponse");
if (distilledTargetInfo.isAxios) {
importCollector.destructure("axios", "AxiosError");
importCollector.destructure("axios", "AxiosRequestConfig");
}
// @tanstack/react-query imports
importCollector.destructure("@tanstack/react-query", "QueryKey");
importCollector.destructure("@tanstack/react-query", "UseMutationOptions");
importCollector.destructure("@tanstack/react-query", "UseMutationResult");
importCollector.destructure("@tanstack/react-query", "UseQueryOptions");
importCollector.destructure("@tanstack/react-query", "UseQueryResult");
importCollector.destructure("@tanstack/react-query", "useMutation");
importCollector.destructure("@tanstack/react-query", "useQuery");
importCollector.destructure("@tanstack/react-query", "QueryClient");
return file;
}
/**
* Generate the api client hooks
*
* @param {import("../generate").GenerateContext} generateContext
* @param {import("../file/context").GenerateFile} file
* @param {import("../types").NamedType<import("../generated/common/types").ExperimentalRouteDefinition>} route
* @param {Record<string, string>} contextNames
*/
export function reactQueryGenerateFunction(
generateContext,
file,
route,
contextNames,
) {
const distilledTargetInfo = apiClientDistilledTargetInfo(generateContext);
const hookName = `use${upperCaseFirst(route.group)}${upperCaseFirst(
route.name,
)}`;
const apiName = `api${upperCaseFirst(route.group)}${upperCaseFirst(
route.name,
)}`;
if (route.body) {
const body = structureResolveReference(
generateContext.structure,
route.body,
);
if (body.type !== "object") {
fileWrite(file, "\n\n");
fileWrite(
file,
fileFormatInlineComment(
file,
`Skipped generation of '${hookName}' since a custom body type is used.`,
),
);
fileWrite(file, "\n\n");
return;
}
}
if (route.query) {
const query = structureResolveReference(
generateContext.structure,
route.query,
);
if (query.type !== "object") {
fileWrite(file, "\n\n");
fileWrite(
file,
fileFormatInlineComment(
file,
`Skipped generation of '${hookName}' since a custom query type is used.`,
),
);
fileWrite(file, "\n\n");
return;
}
}
if (route.files) {
const files = structureResolveReference(
generateContext.structure,
route.files,
);
if (files.type !== "object") {
fileWrite(file, "\n\n");
fileWrite(
file,
fileFormatInlineComment(
file,
`Skipped generation of '${hookName}' since a custom files type is used.`,
),
);
fileWrite(file, "\n\n");
return;
}
}
// Import the corresponding api client function.
const importCollector = JavascriptImportCollector.getImportCollector(file);
importCollector.destructure("./apiClient", `${apiName}`);
// Write the doc block
if (route.docString || route.tags.length > 0) {
fileWrite(file, `/**`);
fileContextAddLinePrefix(file, " * ");
if (route.docString) {
fileWrite(file, `${route.docString}\n`);
}
if (route.tags.length) {
fileWrite(file, `Tags: ${JSON.stringify(route.tags)}\n`);
}
fileContextRemoveLinePrefix(file, 3);
fileWrite(file, ` */`);
}
// Helper variables for reusable patterns
const joinedArgumentType = ({ withRequestConfig, withQueryOptions }) => {
const list = [
contextNames.paramsTypeName,
contextNames.queryTypeName,
contextNames.bodyTypeName,
contextNames.filesTypeName,
withRequestConfig
? `{ requestConfig?: ${
distilledTargetInfo.isAxios
? `AxiosRequestConfig`
: distilledTargetInfo.isFetch
? `RequestInit`
: "unknown"
}${
route.response && !distilledTargetInfo.skipResponseValidation
? `, skipResponseValidation?: boolean`
: ""
} }`
: undefined,
withQueryOptions
? `{ queryOptions?: UseQueryOptions<${
contextNames.responseTypeName ?? "unknown"
}, AppErrorResponse, TData> }`
: undefined,
].filter((it) => !!it);
if (list.length === 0) {
list.push(`{}`);
}
return list.join(` & `);
};
const parameterListWithExtraction = ({ prefix, withRequestConfig }) => {
let result = "";
if (route.params) {
/** @type {import("../generated/common/types.js").ExperimentalObjectDefinition} */
// @ts-expect-error
const params = structureResolveReference(
generateContext.structure,
route.params,
);
result += `{ ${Object.keys(params.keys)
.map((it) => `"${it}": ${prefix}["${it}"]`)
.join(", ")} }, `;
}
if (route.query) {
/** @type {import("../generated/common/types.js").ExperimentalObjectDefinition} */
// @ts-expect-error
const query = structureResolveReference(
generateContext.structure,
route.query,
);
result += `{ ${Object.keys(query.keys)
.map((it) => `"${it}": ${prefix}["${it}"]`)
.join(", ")} }, `;
}
if (route.body) {
/** @type {import("../generated/common/types.js").ExperimentalObjectDefinition} */
// @ts-expect-error
const body = structureResolveReference(
generateContext.structure,
route.body,
);
result += `{ ${Object.keys(body.keys)
.map((it) => `"${it}": ${prefix}["${it}"]`)
.join(", ")} }, `;
}
if (route.files) {
/** @type {import("../generated/common/types.js").ExperimentalObjectDefinition} */
// @ts-expect-error
const files = structureResolveReference(
generateContext.structure,
route.files,
);
result += `{ ${Object.keys(files.keys)
.map((it) => `"${it}": ${prefix}["${it}"]`)
.join(", ")} }, `;
}
if (withRequestConfig) {
result += `${prefix}?.requestConfig`;
}
return result;
};
const apiInstanceArgument = distilledTargetInfo.useGlobalClients
? ""
: distilledTargetInfo.isAxios
? `axiosInstance: AxiosInstance,`
: distilledTargetInfo.isFetch
? "fetchFn: FetchFn,"
: "";
const apiInstanceParameter = distilledTargetInfo.useGlobalClients
? ""
: distilledTargetInfo.isAxios
? "axiosInstance,"
: distilledTargetInfo.isFetch
? "fetchFn,"
: "";
const queryClientArgument = distilledTargetInfo.useGlobalClients
? ""
: `queryClient: QueryClient,`;
if (route.method === "GET" || route.idempotent) {
fileWriteInline(
file,
`export function ${hookName}<TData = ${contextNames.responseTypeName}>(`,
);
// When no arguments are required, the whole opts object is optional
const routeHasMandatoryInputs =
route.params || route.query || route.body || route.files;
fileWrite(file, `opts: `);
fileWrite(
file,
joinedArgumentType({
withRequestConfig: true,
withQueryOptions: true,
}),
);
// When no arguments are required, the whole opts object is optional
if (routeHasMandatoryInputs) {
fileBlockStart(file, `)`);
} else {
fileBlockStart(file, ` = {})`);
}
if (!distilledTargetInfo.useGlobalClients) {
// Get the api client from the React context
if (distilledTargetInfo.isAxios) {
fileWrite(file, `const axiosInstance = useApi();`);
}
if (distilledTargetInfo.isFetch) {
fileWrite(file, `const fetchFn = useApi();`);
}
}
fileWrite(file, `const options = opts?.queryOptions ?? {};`);
reactQueryWriteIsEnabled(generateContext, file, route);
fileWriteInline(
file,
`return useQuery(${hookName}.queryKey(${
routeHasMandatoryInputs ? "opts" : ""
}),`,
);
fileWriteInline(
file,
`({ signal }) => {
opts.requestConfig ??= {};
opts.requestConfig.signal = signal;
return ${apiName}(${apiInstanceParameter}
${parameterListWithExtraction({
prefix: "opts",
withRequestConfig: true,
})}
);
}, options);`,
);
fileBlockEnd(file);
fileWrite(
file,
`/**
* Base key used by ${hookName}.queryKey()
*/
${hookName}.baseKey = (): QueryKey => ["${route.group}", "${route.name}"];
/**
* Query key used by ${hookName}
*/
${hookName}.queryKey = (
${
routeHasMandatoryInputs
? `opts: ${joinedArgumentType({
withQueryOptions: false,
withRequestConfig: false,
})},`
: ""
}
): QueryKey => [
...${hookName}.baseKey(),
${parameterListWithExtraction({ prefix: "opts", withRequestConfig: false })}
];
/**
* Fetch ${hookName} via the queryClient and return the result
*/
${hookName}.fetch = (
${queryClientArgument}
${apiInstanceArgument}
opts${routeHasMandatoryInputs ? "" : "?"}: ${joinedArgumentType({
withQueryOptions: false,
withRequestConfig: true,
})}
) => {
return queryClient.fetchQuery(
${hookName}.queryKey(${routeHasMandatoryInputs ? "opts" : ""}),
() => ${apiName}(
${apiInstanceParameter}
${parameterListWithExtraction({
prefix: "opts",
withRequestConfig: true,
})}
));
}
/**
* Prefetch ${hookName} via the queryClient
*/
${hookName}.prefetch = (
${queryClientArgument}
${apiInstanceArgument}
opts${routeHasMandatoryInputs ? "" : "?"}: ${joinedArgumentType({
withQueryOptions: false,
withRequestConfig: true,
})},
) => {
return queryClient.prefetchQuery(
${hookName}.queryKey(${routeHasMandatoryInputs ? "opts" : ""}),
() => ${apiName}(
${apiInstanceParameter}
${parameterListWithExtraction({
prefix: "opts",
withRequestConfig: true,
})}
));
}
/**
* Invalidate ${hookName} via the queryClient
*/
${hookName}.invalidate = (
${queryClientArgument}
${
routeHasMandatoryInputs
? `opts: ${joinedArgumentType({
withQueryOptions: false,
withRequestConfig: false,
})},`
: ""
}
) => queryClient.invalidateQueries(${hookName}.queryKey(${
routeHasMandatoryInputs ? "opts" : ""
}));
/**
* Set query data for ${hookName} via the queryClient
*/
${hookName}.setQueryData = (
${queryClientArgument}
${
routeHasMandatoryInputs
? `opts: ${joinedArgumentType({
withQueryOptions: false,
withRequestConfig: false,
})},`
: ""
}
data: ${contextNames.responseTypeName ?? "unknown"},
) => queryClient.setQueryData(${hookName}.queryKey(${
routeHasMandatoryInputs ? "opts" : ""
}), data);
`,
);
} else {
// Write the props type
fileWrite(
file,
`type ${upperCaseFirst(hookName)}Props = ${joinedArgumentType({
withRequestConfig: true,
withQueryOptions: false,
})}`,
);
fileWriteInline(
file,
`export function ${hookName}(options: UseMutationOptions<${
contextNames.responseTypeName
}, AppErrorResponse, ${upperCaseFirst(hookName)}Props> = {},`,
);
if (route.invalidations.length > 0) {
// Accept an option to invalidate the queries.
fileWriteInline(
file,
`hookOptions: { invalidateQueries?: boolean } = {},`,
);
}
fileWrite(
file,
`): UseMutationResult<${
contextNames.responseTypeName ?? "Response"
}, AppErrorResponse, ${upperCaseFirst(hookName)}Props, unknown> {`,
);
if (!distilledTargetInfo.useGlobalClients) {
// Get the api client from the React context
if (distilledTargetInfo.isAxios) {
fileWrite(file, `const axiosInstance = useApi();`);
}
if (distilledTargetInfo.isFetch) {
fileWrite(file, `const fetchFn = useApi();`);
}
}
if (route.invalidations) {
if (!distilledTargetInfo.useGlobalClients) {
fileWrite(file, `const queryClient = useQueryClient();`);
}
}
if (route.invalidations.length > 0) {
// Write out the invalidatiosn
fileBlockStart(file, `if (hookOptions.invalidateQueries)`);
reactQueryWriteInvalidations(file, route);
fileBlockEnd(file);
}
fileWrite(
file,
`return useMutation((variables) => ${apiName}(
${apiInstanceParameter}
${parameterListWithExtraction({
prefix: "variables",
withRequestConfig: true,
})}
), options);
`,
);
fileBlockEnd(file);
}
fileWrite(file, "\n");
}
/**
* Generate the api client hooks
*
* @param {import("../generate").GenerateContext} generateContext
* @param {import("../file/context").GenerateFile} file
* @param {import("../types").NamedType<import("../generated/common/types").ExperimentalRouteDefinition>} route
*/
function reactQueryWriteIsEnabled(generateContext, file, route) {
const keysAffectingEnabled = [];
for (const key of ["params", "query", "body"]) {
if (!route[key]) {
continue;
}
/** @type {import("../generated/common/types.js").ExperimentalObjectDefinition} */
// @ts-expect-error
const type = structureResolveReference(
generateContext.structure,
route[key],
);
for (const [subKey, field] of Object.entries(type.keys)) {
const isOptional = typesOptionalityIsOptional(generateContext, field, {
validatorState: "input",
});
if (!isOptional) {
keysAffectingEnabled.push(`opts.${subKey}`);
}
}
}
if (keysAffectingEnabled.length > 0) {
fileWrite(file, `options.enabled = (`);
fileContextSetIndent(file, 1);
fileWrite(
file,
`options.enabled === true || (options.enabled !== false &&`,
);
fileWrite(
file,
keysAffectingEnabled
.map((it) => `${it} !== undefined && ${it} !== null`)
.join("&&\n"),
);
fileContextSetIndent(file, -1);
fileWrite(file, `));`);
}
}
/**
* Write the invalidations in the mutation hook
*
* @param {import("../file/context").GenerateFile} file
* @param {import("../types").NamedType<import("../generated/common/types").ExperimentalRouteDefinition>} route
*/
function reactQueryWriteInvalidations(file, route) {
fileWrite(file, `const originalOnSuccess = options.onSuccess;`);
fileBlockStart(
file,
`options.onSuccess = async (data, variables, context) => `,
);
for (const invalidation of route.invalidations) {
let params = "";
let query = "";
if (
invalidation.target.name &&
Object.keys(invalidation.properties.specification?.params ?? {}).length >
0
) {
params += `{`;
for (const [key, value] of Object.entries(
invalidation.properties?.specification?.params ?? {},
)) {
params += `${key}: variables.${value.slice(1).join(".")},\n`;
}
params += `},`;
}
if (
invalidation.target.name &&
Object.keys(invalidation.properties.specification?.query ?? {}).length > 0
) {
query += `{`;
for (const [key, value] of Object.entries(
invalidation.properties?.specification?.query ?? {},
)) {
query += `${key}: variables.${value.slice(1).join(".")},\n`;
}
query += `},`;
}
fileWriteInline(
file,
`queryClient.invalidateQueries(["${invalidation.target.group}",`,
);
if (invalidation.target.name) {
fileWriteInline(file, `"${invalidation.target.name}",`);
}
if (params.length) {
fileWriteInline(file, params);
}
if (query.length) {
fileWriteInline(file, query);
}
fileWrite(file, `]);`);
}
fileBlockStart(file, `if (typeof originalOnSuccess === "function")`);
fileWrite(file, `return await originalOnSuccess(data, variables, context);`);
fileBlockEnd(file);
fileBlockEnd(file);
}