RustRover cargo-generate template: Axum + Vue 3 + TypeScript + Tailwind + sqlx
Nie możesz wybrać więcej, niż 25 tematów Tematy muszą się zaczynać od litery lub cyfry, mogą zawierać myślniki ('-') i mogą mieć do 35 znaków.
 
 
 
 
 
 

231 wiersze
7.6 KiB

  1. #!/usr/bin/env node
  2. /**
  3. * Generate a typed fetch client from packages/contracts/openapi.json.
  4. *
  5. * The spec is produced by `cargo run -p server --bin export-openapi`, which
  6. * walks the same utoipa-axum router the server serves. Adding a handler with
  7. * `#[utoipa::path]` + `.routes(routes!(...))` is enough for it to show up here
  8. * after `just generate-api`.
  9. */
  10. import fs from "node:fs";
  11. import path from "node:path";
  12. import { fileURLToPath } from "node:url";
  13. const workspaceRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
  14. const specPath = path.join(workspaceRoot, "packages/contracts/openapi.json");
  15. const outPath = path.join(workspaceRoot, "apps/web/src/api/generated.ts");
  16. const HTTP_METHODS = ["get", "put", "post", "delete", "options", "head", "patch", "trace"];
  17. function die(message) {
  18. console.error(message);
  19. process.exit(1);
  20. }
  21. function pascal(value) {
  22. return String(value)
  23. .replace(/[^A-Za-z0-9]+/g, " ")
  24. .split(" ")
  25. .filter(Boolean)
  26. .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
  27. .join("");
  28. }
  29. function camel(value) {
  30. const p = pascal(value);
  31. return p ? p.charAt(0).toLowerCase() + p.slice(1) : "fn";
  32. }
  33. function uniqueName(base, used) {
  34. let name = base;
  35. let i = 2;
  36. while (used.has(name)) {
  37. name = `${base}${i}`;
  38. i += 1;
  39. }
  40. used.add(name);
  41. return name;
  42. }
  43. function tsType(schema, spec, seen = new Set()) {
  44. if (!schema) return "unknown";
  45. if (schema.$ref) {
  46. const name = schema.$ref.split("/").pop();
  47. return name ? pascal(name) : "unknown";
  48. }
  49. if (schema.allOf?.length) {
  50. return schema.allOf.map((part) => tsType(part, spec, seen)).join(" & ");
  51. }
  52. if (schema.oneOf?.length || schema.anyOf?.length) {
  53. const parts = (schema.oneOf ?? schema.anyOf).map((part) => tsType(part, spec, seen));
  54. return [...new Set(parts)].join(" | ");
  55. }
  56. if (Array.isArray(schema.enum) && schema.enum.every((item) => typeof item === "string")) {
  57. return schema.enum.map((item) => JSON.stringify(item)).join(" | ");
  58. }
  59. if (Array.isArray(schema.type)) {
  60. const nullable = schema.type.includes("null");
  61. const types = schema.type.filter((type) => type !== "null");
  62. const inner = types.length
  63. ? types.map((type) => tsType({ ...schema, type }, spec, seen)).join(" | ")
  64. : "unknown";
  65. return nullable ? `${inner} | null` : inner;
  66. }
  67. switch (schema.type) {
  68. case "string":
  69. if (schema.format === "binary") return "Blob";
  70. return "string";
  71. case "integer":
  72. case "number":
  73. return "number";
  74. case "boolean":
  75. return "boolean";
  76. case "array":
  77. return `Array<${tsType(schema.items ?? {}, spec, seen)}>`;
  78. case "object":
  79. default: {
  80. const props = schema.properties ?? {};
  81. const required = new Set(schema.required ?? []);
  82. const fields = Object.entries(props).map(([key, value]) => {
  83. const optional = required.has(key) ? "" : "?";
  84. return ` ${key}${optional}: ${tsType(value, spec, seen)};`;
  85. });
  86. if (schema.additionalProperties) {
  87. const extra =
  88. schema.additionalProperties === true
  89. ? "unknown"
  90. : tsType(schema.additionalProperties, spec, seen);
  91. fields.push(` [key: string]: ${extra};`);
  92. }
  93. if (!fields.length) return "Record<string, unknown>";
  94. return `{\n${fields.join("\n")}\n}`;
  95. }
  96. }
  97. }
  98. function schemaFromContent(content) {
  99. if (!content || typeof content !== "object") return null;
  100. return (
  101. content["application/json"]?.schema ??
  102. content["application/problem+json"]?.schema ??
  103. Object.values(content)[0]?.schema ??
  104. null
  105. );
  106. }
  107. function successResponse(operation) {
  108. const responses = operation.responses ?? {};
  109. const preferred = ["200", "201", "202", "204"];
  110. for (const code of preferred) {
  111. if (responses[code]) return { code, response: responses[code] };
  112. }
  113. const fallback = Object.entries(responses).find(([code]) => code.startsWith("2"));
  114. return fallback ? { code: fallback[0], response: fallback[1] } : { code: "200", response: {} };
  115. }
  116. function operationName(method, pathName, operation, used) {
  117. if (operation.operationId) {
  118. return uniqueName(camel(operation.operationId), used);
  119. }
  120. const slug = pathName.replace(/[{}]/g, "").replace(/^\//, "").replaceAll("/", "_");
  121. return uniqueName(camel(`${method}_${slug}`), used);
  122. }
  123. function pathToTemplate(pathName, paramNames, argPrefix) {
  124. let template = JSON.stringify(pathName);
  125. for (const name of paramNames) {
  126. template = template.replace(`{${name}}`, `" + encodeURIComponent(String(${argPrefix}.path.${name})) + "`);
  127. }
  128. return template.replace(/ \+ ""/g, "");
  129. }
  130. if (!fs.existsSync(specPath)) {
  131. die(`missing ${specPath} — run: cargo run -p server --bin export-openapi`);
  132. }
  133. const spec = JSON.parse(fs.readFileSync(specPath, "utf8"));
  134. const schemas = spec.components?.schemas ?? {};
  135. const usedNames = new Set();
  136. const typeDecls = [];
  137. const fnDecls = [];
  138. for (const [name, schema] of Object.entries(schemas)) {
  139. typeDecls.push(`export type ${pascal(name)} = ${tsType(schema, spec)};`);
  140. }
  141. const paths = spec.paths ?? {};
  142. for (const [pathName, item] of Object.entries(paths)) {
  143. for (const method of HTTP_METHODS) {
  144. const operation = item?.[method];
  145. if (!operation) continue;
  146. const name = operationName(method, pathName, operation, usedNames);
  147. const allParams = [...(item.parameters ?? []), ...(operation.parameters ?? [])];
  148. const pathParams = allParams.filter((param) => param.in === "path");
  149. const queryParams = allParams.filter((param) => param.in === "query");
  150. const bodySchema = schemaFromContent(operation.requestBody?.content);
  151. const { code: status, response } = successResponse(operation);
  152. const responseSchema = schemaFromContent(response?.content);
  153. const args = [];
  154. if (pathParams.length) {
  155. const fields = pathParams
  156. .map((param) => {
  157. const schema = param.schema ?? { type: "string" };
  158. return ` ${param.name}: ${tsType(schema, spec)};`;
  159. })
  160. .join("\n");
  161. args.push(`path: {\n${fields}\n }`);
  162. }
  163. if (queryParams.length) {
  164. const fields = queryParams
  165. .map((param) => {
  166. const schema = param.schema ?? { type: "string" };
  167. const optional = param.required ? "" : "?";
  168. return ` ${param.name}${optional}: ${tsType(schema, spec)};`;
  169. })
  170. .join("\n");
  171. args.push(`query?: {\n${fields}\n }`);
  172. }
  173. if (bodySchema) {
  174. args.push(`body: ${tsType(bodySchema, spec)}`);
  175. }
  176. const argPrefix = (pathParams.length || queryParams.length || bodySchema) ? "args" : "_args";
  177. const argList = args.length ? `${argPrefix}: {\n ${args.join(";\n ")}\n}` : "";
  178. const returnType = responseSchema ? tsType(responseSchema, spec) : "void";
  179. const pathExpr = pathToTemplate(
  180. pathName,
  181. pathParams.map((param) => param.name),
  182. argPrefix,
  183. );
  184. const queryLine = queryParams.length ? ` query: ${argPrefix}.query,` : "";
  185. const bodyLine = bodySchema ? ` body: ${argPrefix}.body,` : "";
  186. const requestFields = [
  187. ` method: "${method.toUpperCase()}",`,
  188. ` path: ${pathExpr},`,
  189. queryLine,
  190. bodyLine,
  191. ` expectedStatus: ${Number(status) || 200},`,
  192. ].filter(Boolean);
  193. fnDecls.push(`export function ${name}(${argList}): Promise<${returnType}> {
  194. return request<${returnType}>({
  195. ${requestFields.join("\n")}
  196. });
  197. }`);
  198. }
  199. }
  200. const banner = `/* eslint-disable */
  201. /* generated by scripts/generate-api.mjs — do not edit */
  202. import { request } from "./client";
  203. `;
  204. const output = `${banner}${typeDecls.join("\n\n")}\n\n${fnDecls.join("\n\n")}\n`;
  205. fs.mkdirSync(path.dirname(outPath), { recursive: true });
  206. fs.writeFileSync(outPath, output);
  207. console.log(`wrote ${outPath}`);