@compas/code-gen
Version:
Generate various boring parts of your server
379 lines (319 loc) • 10.2 kB
JavaScript
import { fileBlockEnd, fileBlockStart } from "../file/block.js";
import {
fileContextAddLinePrefix,
fileContextCreateGeneric,
fileContextGetOptional,
fileContextRemoveLinePrefix,
fileContextSetIndent,
} from "../file/context.js";
import { fileWrite } from "../file/write.js";
import { referenceUtilsGetProperty } from "../processors/reference-utils.js";
import { structureResolveReference } from "../processors/structure.js";
import { JavascriptImportCollector } from "../target/javascript.js";
import { upperCaseFirst } from "../utils.js";
import { apiClientDistilledTargetInfo } from "./generator.js";
/**
* Write the global clients to the common directory
*
* @param {import("../generate.js").GenerateContext} generateContext
*/
export function tsFetchGenerateCommonFile(generateContext) {
const includeWrapper =
generateContext.options.generators.apiClient?.target.includeWrapper ===
"react-query";
const file = fileContextCreateGeneric(
generateContext,
`common/api-client.ts${includeWrapper ? "x" : ""}`,
{
importCollector: new JavascriptImportCollector(),
},
);
const importCollector = JavascriptImportCollector.getImportCollector(file);
fileWrite(
file,
`export type FetchFn = (input: URL|string, init?: RequestInit) => Promise<Response>;`,
);
if (generateContext.options.generators.apiClient?.target.globalClient) {
fileWrite(
file,
`export let fetchFn: FetchFn = fetch;
/**
* Override the global fetch function. This can be used to apply defaults to each call.
*/
export function setFetchFn(newFetchFn: FetchFn) {
fetchFn = newFetchFn;
}
`,
);
if (includeWrapper) {
importCollector.destructure("@tanstack/react-query", "QueryClient");
fileWrite(
file,
`export const queryClient = new QueryClient();
`,
);
}
}
fileWrite(
file,
`
export class AppErrorResponse extends Error {
public originalError: Error|undefined;
public request: {
input: URL|string;
init?: RequestInit;
};
public response: Response|undefined;
public body?: unknown;
constructor(originalError: Error|undefined, input: URL|string, init: RequestInit|undefined, response: Response|undefined, body?: unknown) {
super('Request failed.');
this.name = "AppErrorResponse";
this.originalError = originalError;
this.request = { input, init };
this.response = response;
this.body = body;
}
}
`,
);
fileWrite(
file,
`
/**
* Wrap the provided fetch function, adding the baseUrl to each invocation.
*/
export function fetchWithBaseUrl(originalFetch: FetchFn, baseUrl: string): FetchFn {
return function fetchWithBaseUrl(input: URL|string, init?: RequestInit) {
return originalFetch(new URL(input, baseUrl), init);
};
}
/**
* Wrap the provided fetch function, to catch errors and convert where possible to an AppError
*/
export function fetchCatchErrorAndWrapWithAppError(originalFetch: FetchFn): FetchFn {
return async function fetchCatchErrorAndWrapWithAppError(input: URL|string, init?: RequestInit) {
const response = await originalFetch(input, init);
try {
if (!response.ok) {
const body = await response.json();
throw new AppErrorResponse(undefined, input, init, response, body);
}
return response;
} catch (error: any) {
if (error instanceof AppErrorResponse) {
throw error;
}
throw new AppErrorResponse(error, input, init, response);
}
};
}
`,
);
}
/**
* Get a specific api client file.
*
* @param {import("../generate.js").GenerateContext} generateContext
* @param {import("../generated/common/types.js").StructureRouteDefinition} route
* @returns {import("../file/context.js").GenerateFile}
*/
export function tsFetchGetApiClientFile(generateContext, route) {
let file = fileContextGetOptional(
generateContext,
`${route.group}/apiClient.ts`,
);
if (file) {
return file;
}
file = fileContextCreateGeneric(
generateContext,
`${route.group}/apiClient.ts`,
{
importCollector: new JavascriptImportCollector(),
typeImportCollector: new JavascriptImportCollector(true),
},
);
const importCollector = JavascriptImportCollector.getImportCollector(file);
if (generateContext.options.generators.apiClient?.target.globalClient) {
importCollector.destructure(`../common/api-client`, "fetchFn");
} else {
importCollector.destructure(`../common/api-client`, "FetchFn");
}
importCollector.destructure("../common/api-client", "AppErrorResponse");
return file;
}
/**
* Generate the api client function
*
* @param {import("../generate.js").GenerateContext} generateContext
* @param {import("../file/context.js").GenerateFile} file
* @param {import("../../types/advanced-types.d.ts").NamedType<import("../generated/common/types.d.ts").StructureRouteDefinition>} route
* @param {Record<string, string>} contextNames
*/
export function tsFetchGenerateFunction(
generateContext,
file,
route,
contextNames,
) {
const distilledTargetInfo = apiClientDistilledTargetInfo(generateContext);
const args = [];
fileWrite(file, `/**`);
fileContextAddLinePrefix(file, " * ");
if (route.docString) {
fileWrite(file, `${route.docString}\n`);
}
fileWrite(file, `Tags: ${JSON.stringify(route.tags)}\n`);
if (!distilledTargetInfo.useGlobalClients) {
args.push("fetchFn: FetchFn");
}
if (route.params) {
args.push(`params: ${contextNames.paramsTypeName}`);
}
if (route.query) {
args.push(`query: ${contextNames.queryTypeName}`);
}
if (route.body) {
args.push(
`body: ${contextNames.bodyTypeName}${
route.metadata?.requestBodyType === "form-data" ? "|FormData" : ""
}`,
);
}
// Allow overwriting any request config
args.push(`requestConfig?: RequestInit`);
fileContextRemoveLinePrefix(file, 3);
fileWrite(file, ` */`);
fileBlockStart(
file,
`export async function api${upperCaseFirst(route.group)}${upperCaseFirst(
route.name,
)}(${args.join(", ")}): Promise<${contextNames.responseTypeName ?? "Response"}>`,
);
if (route.metadata?.requestBodyType === "form-data") {
const parameter = "body";
fileWrite(
file,
`const data = ${parameter} instanceof FormData ? ${parameter} : new FormData();`,
);
fileBlockStart(file, `if (!(${parameter} instanceof FormData))`);
/** @type {import("../generated/common/types.d.ts").StructureObjectDefinition} */
// @ts-expect-error
const type = structureResolveReference(
generateContext.structure,
// @ts-expect-error
route.body,
);
for (const key of Object.keys(type.keys)) {
const fieldType =
type.keys[key].type === "reference" ?
structureResolveReference(generateContext.structure, type.keys[key])
: type.keys[key];
const isOptional = referenceUtilsGetProperty(
generateContext,
type.keys[key],
["isOptional"],
false,
);
if (isOptional) {
fileBlockStart(file, `if (${parameter}["${key}"] !== undefined)`);
}
if (fieldType.type === "file") {
if (distilledTargetInfo.isReactNative) {
fileWrite(file, `data.append("${key}", ${parameter}["${key}"]);`);
} else {
fileWrite(
file,
`data.append("${key}", ${parameter}["${key}"].data, ${parameter}["${key}"].name);`,
);
}
} else if (fieldType.type === "number") {
fileWrite(
file,
`data.append("${key}", ${parameter}["${key}"].toString());`,
);
} else {
fileWrite(file, `data.append("${key}", ${parameter}["${key}"]);`);
}
if (isOptional) {
fileBlockEnd(file);
}
}
fileBlockEnd(file);
}
if (route.query) {
/** @type {import("../generated/common/types.d.ts").StructureObjectDefinition} */
// @ts-expect-error
const type = structureResolveReference(
generateContext.structure,
route.query,
);
if (type.type === "object") {
for (const key of Object.keys(type.keys)) {
const isOptional = referenceUtilsGetProperty(
generateContext,
type.keys[key],
["isOptional"],
false,
);
if (isOptional) {
fileWrite(
file,
`if (query["${key}"] === null || query["${key}"] === undefined) { delete query["${key}"]; }`,
);
}
}
}
}
fileWrite(file, `const response = await fetchFn(`);
fileContextSetIndent(file, 1);
if (route.query) {
fileWrite(
file,
`\`${route.path
.split("/")
.map((it) => (it.startsWith(":") ? `$\{params.${it.slice(1)}}` : it))
.join("/")}?$\{new URLSearchParams(query as any).toString()}\`,`,
);
} else {
fileWrite(
file,
`\`${route.path
.split("/")
.map((it) => (it.startsWith(":") ? `$\{params.${it.slice(1)}}` : it))
.join("/")}\`,`,
);
}
fileWrite(file, `{`);
fileContextSetIndent(file, 1);
fileWrite(file, `method: "${route.method}",`);
if (route.metadata?.requestBodyType === "form-data") {
fileWrite(file, `body: data,`);
if (distilledTargetInfo.isReactNative) {
fileWrite(file, `headers: { "Content-Type": "multipart/form-data" },`);
}
}
if (route.body && route.metadata?.requestBodyType !== "form-data") {
fileWrite(file, `body: JSON.stringify(body),`);
fileWrite(file, `headers: { "Content-Type": "application/json", },`);
}
fileWrite(file, `...requestConfig,`);
fileContextSetIndent(file, -1);
fileWrite(file, `}`);
fileContextSetIndent(file, -1);
fileWrite(file, `);`);
if (route.response) {
if (
structureResolveReference(generateContext.structure, route.response)
.type === "file"
) {
fileWrite(file, `return response.blob();`);
} else {
fileWrite(file, `return response.json();`);
}
} else {
fileWrite(file, `return response;`);
}
fileBlockEnd(file);
fileWrite(file, "\n");
}