From 81dafda1889bd5220e5a5d720106938da94de62e Mon Sep 17 00:00:00 2001 From: mathcovax Date: Fri, 29 May 2026 17:06:00 +0000 Subject: [PATCH] feat(39): generate identified dataParser --- package-lock.json | 26 +-- package.json | 6 +- .../findIdentifiedDataParserInSteps.ts | 97 +++++++++++ scripts/plugins/codeGenerator/plugin.ts | 164 ++++++++++++++++-- .../codeGenerator/typescriptTransformer.ts | 8 + .../findIdentifiedDataParserInSteps.test.ts | 128 ++++++++++++++ 6 files changed, 399 insertions(+), 30 deletions(-) create mode 100644 scripts/plugins/codeGenerator/findIdentifiedDataParserInSteps.ts create mode 100644 tests/plugins/codeGenerator/findIdentifiedDataParserInSteps.test.ts diff --git a/package-lock.json b/package-lock.json index 5b46202..3cb5cb6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,9 +41,9 @@ "node": ">=22.15.1" }, "peerDependencies": { - "@duplojs/data-parser-tools": ">=0.3.0 <1.0.0", - "@duplojs/server-utils": ">=0.3.0 <1.0.0", - "@duplojs/utils": ">=1.8.0 <2.0.0" + "@duplojs/data-parser-tools": ">=0.5.0 <1.0.0", + "@duplojs/server-utils": ">=0.3.1 <1.0.0", + "@duplojs/utils": ">=1.8.5 <2.0.0" } }, "docs": { @@ -1125,9 +1125,9 @@ "license": "MIT" }, "node_modules/@duplojs/data-parser-tools": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@duplojs/data-parser-tools/-/data-parser-tools-0.3.0.tgz", - "integrity": "sha512-Kt8DtcYg273lkSK+yUK4EyDT+N5jxAejqdUmQe6DYLGVitu5NxItOLWWer4Zk7Pi6h/p5rwX/jyeMa5n1kVUEQ==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@duplojs/data-parser-tools/-/data-parser-tools-0.5.0.tgz", + "integrity": "sha512-M8acaPcUnF+R27LoLs1S5tEI07PDoyGXOXQh9ygRN4MWQguQzAGkwL4pKADQC8UA+f9J0O06rkuPfKDZ3K9y4g==", "license": "MIT", "peer": true, "workspaces": [ @@ -1142,7 +1142,7 @@ }, "peerDependencies": { "@duplojs/server-utils": ">=0.3.0 < 1.0.0", - "@duplojs/utils": ">=1.8.1 <2.0.0" + "@duplojs/utils": ">=1.8.4 <2.0.0" } }, "node_modules/@duplojs/eslint": { @@ -1602,9 +1602,9 @@ "link": true }, "node_modules/@duplojs/server-utils": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@duplojs/server-utils/-/server-utils-0.3.0.tgz", - "integrity": "sha512-CMVHvod5fE/ttua916oBfcvKyP2HBG32OcQfAiK8j+fr7+AoUdMJXogJKhS07pl/y03axhFr0cQLS+iKB15iLw==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@duplojs/server-utils/-/server-utils-0.3.1.tgz", + "integrity": "sha512-ygyR/rlbONRnhbW14rTv0p5nKL/suszHCowCBwCGeU2UWHqPH6uutgGwq3Glm1rPBqly9YppZSvxx/lMxopyBw==", "license": "MIT", "peer": true, "workspaces": [ @@ -1619,9 +1619,9 @@ } }, "node_modules/@duplojs/utils": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@duplojs/utils/-/utils-1.8.1.tgz", - "integrity": "sha512-dVsvAPOTCsayWSr2HDA/2XebK35NVsB8efnd3u5Tw+aqSUB09/S4uaqBNCUkkaVQW/S0OTc5OlHGxnTWsPz00A==", + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/@duplojs/utils/-/utils-1.8.5.tgz", + "integrity": "sha512-I69oR/LCcUtjGxmQaUuUitwCUvrlUzgA3WrlXJ0dLc8sCzOLK3QyCEBwIDQvFwe0rqFO4oMoEelgbJmcZbwYhQ==", "license": "MIT", "peer": true, "workspaces": [ diff --git a/package.json b/package.json index 68de402..7d190b9 100644 --- a/package.json +++ b/package.json @@ -88,9 +88,9 @@ "README.md" ], "peerDependencies": { - "@duplojs/data-parser-tools": ">=0.3.0 <1.0.0", - "@duplojs/server-utils": ">=0.3.0 <1.0.0", - "@duplojs/utils": ">=1.8.0 <2.0.0" + "@duplojs/data-parser-tools": ">=0.5.1 <1.0.0", + "@duplojs/server-utils": ">=0.3.1 <1.0.0", + "@duplojs/utils": ">=1.8.5 <2.0.0" }, "devDependencies": { "@commitlint/cli": "19.8.1", diff --git a/scripts/plugins/codeGenerator/findIdentifiedDataParserInSteps.ts b/scripts/plugins/codeGenerator/findIdentifiedDataParserInSteps.ts new file mode 100644 index 0000000..aa090cf --- /dev/null +++ b/scripts/plugins/codeGenerator/findIdentifiedDataParserInSteps.ts @@ -0,0 +1,97 @@ +import { checkerStepKind, cutStepKind, extractStepKind, handlerStepKind, presetCheckerStepKind, processStepKind, type Steps } from "@core/steps"; +import { DataParserFinder } from "@duplojs/data-parser-tools"; +import { A, DP, forward, hasSomeKinds, innerPipe, O, P, pipe, whenNot } from "@duplojs/utils"; + +export type IdentifiedDataParser = ( + & DP.DataParser + & { definition: { identifier: string } } +); + +export function dataParserHasIdentifier( + dataParser: DP.DataParser, +): dataParser is IdentifiedDataParser { + return !!dataParser.definition.identifier; +} + +export interface findIdentifiedDataParserInStepsParams { + readonly ignoreDataParser: Set; +} + +export function findIdentifiedDataParserInSteps( + steps: readonly Steps[], + params: findIdentifiedDataParserInStepsParams, +): IdentifiedDataParser[] { + return pipe( + steps, + A.flatMap( + innerPipe( + P.when( + extractStepKind.has, + (extractStep) => pipe( + extractStep.definition.shape, + O.values, + A.flatMap( + innerPipe( + whenNot( + DP.dataParserKind.has, + O.values, + ), + ), + ), + A.concat( + extractStep.definition.responseContract?.body + ? [extractStep.definition.responseContract?.body] + : [], + ), + ), + ), + P.when( + processStepKind.has, + forward, + ), + P.when( + presetCheckerStepKind.has, + (step) => [step.definition.presetChecker.definition.responseContract.body], + ), + P.when( + hasSomeKinds([ + checkerStepKind, + cutStepKind, + handlerStepKind, + ]), + (step) => pipe( + step.definition.responseContract, + A.coalescing, + A.map( + ({ body }) => body, + ), + ), + ), + P.exhaustive, + ), + ), + A.flatMap( + innerPipe( + P.when( + processStepKind.has, + (processStep) => findIdentifiedDataParserInSteps( + processStep.definition.process.definition.steps, + params, + ), + ), + P.when( + DP.dataParserKind.has, + (dataParser) => DataParserFinder.dataParserFinder( + dataParser, + dataParserHasIdentifier, + { + researchers: DataParserFinder.defaultResearchers, + ignore: params.ignoreDataParser, + }, + ), + ), + P.exhaustive, + ), + ), + ); +} diff --git a/scripts/plugins/codeGenerator/plugin.ts b/scripts/plugins/codeGenerator/plugin.ts index 6b25940..4328f87 100644 --- a/scripts/plugins/codeGenerator/plugin.ts +++ b/scripts/plugins/codeGenerator/plugin.ts @@ -1,12 +1,22 @@ import * as DataParserToTypescript from "@duplojs/data-parser-tools/toTypescript"; import { type HubPlugin } from "@core/hub"; -import { A, asserts, DP, E, equal } from "@duplojs/utils"; +import { A, asserts, DP, E, equal, G, Path, pipe } from "@duplojs/utils"; import { routeToDataParser } from "./routeToDataParser"; import { SF } from "@duplojs/server-utils"; -import { dateTransformer, fileTransformer, timeTransformer } from "./typescriptTransformer"; +import { typescriptTransformers } from "./typescriptTransformer"; +import { dataParserHasIdentifier, findIdentifiedDataParserInSteps } from "./findIdentifiedDataParserInSteps"; +import { DataParserFinder, DataParserToDataParser } from "@duplojs/data-parser-tools"; +import { type Route } from "@core/route"; + +export interface GenerateDataParserParams { + outputFolder: string; + disabledFromRoute?: boolean; + dataParsers?: DP.DataParser[]; +} export interface CodeGeneratorPluginParams { outputFile: string; + generateDataParser?: GenerateDataParserParams; } export function codeGeneratorPlugin(pluginParams: CodeGeneratorPluginParams) { @@ -19,13 +29,13 @@ export function codeGeneratorPlugin(pluginParams: CodeGeneratorPluginParams) { return; } - const routes = A.from(hub.routes); - - const dataParserRoutes = A.flatMap( - routes, - (route) => routeToDataParser(route, { + const dataParserRoutes = pipe( + hub.routes, + G.map((route) => routeToDataParser(route, { defaultExtractContract: hub.defaultExtractContract, - }), + })), + G.flat, + A.from, ); if (!A.minElements(dataParserRoutes, 1)) { @@ -37,12 +47,7 @@ export function codeGeneratorPlugin(pluginParams: CodeGeneratorPluginParams) { { identifier: "Routes", mode: "in", - transformers: [ - fileTransformer, - dateTransformer, - timeTransformer, - ...DataParserToTypescript.defaultTransformers, - ], + transformers: typescriptTransformers, }, ); @@ -50,6 +55,137 @@ export function codeGeneratorPlugin(pluginParams: CodeGeneratorPluginParams) { await SF.writeTextFile(pluginParams.outputFile, output), E.isRight, ); + + if (pluginParams.generateDataParser) { + const generateDataParserParams = pluginParams.generateDataParser; + + const buildedContext: DataParserToDataParser.BuildedContext = { + context: new Map(), + importContext: new Map(), + typescriptContext: new Map(), + importMode: "lite", + }; + const ignoreDataParser = new Set(); + + pipe( + [], + A.concat( + pipe( + generateDataParserParams.dataParsers ?? [], + A.flatMap( + (dataParser) => DataParserFinder.dataParserFinder( + dataParser, + dataParserHasIdentifier, + { + researchers: DataParserFinder.defaultResearchers, + ignore: ignoreDataParser, + }, + ), + ), + ), + ), + A.concat( + pipe( + generateDataParserParams.disabledFromRoute + ? new Set() + : hub.routes, + G.map( + (route) => findIdentifiedDataParserInSteps( + route.definition.steps, + { ignoreDataParser }, + ), + ), + G.flat, + A.from, + ), + ), + A.map( + (dataParser) => DataParserToDataParser.buildContext( + dataParser, + { + identifier: dataParser.definition.identifier, + checkerTransformers: DataParserToDataParser.defaultCheckerTransformers, + dataParserTransformers: DataParserToDataParser.defaultTransformers, + typescriptTransformers: DataParserToTypescript.defaultTransformers, + ...buildedContext, + }, + ), + ), + ); + + asserts( + await SF.writeTextFile( + Path.resolveRelative([ + generateDataParserParams.outputFolder, + "types.ts", + ]), + DataParserToTypescript.printer({ + context: buildedContext.typescriptContext, + importContext: buildedContext.importContext, + }), + ), + E.isRight, + ); + + await pipe( + buildedContext.context.entries(), + G.map( + ([dataParser, contextValue]) => { + const subImportContext: DataParserToTypescript.MapImportContext = new Map( + buildedContext.importContext, + ); + + pipe( + contextValue.dependencies, + G.map( + (dataParser) => { + const subContextValue = buildedContext.context.get(dataParser); + if (!subContextValue) { + return null; + } + + subImportContext.set(`./${subContextValue.identifier.text}`, { + direct: [subContextValue.identifier.text], + }); + + return null; + }, + ), + ); + + if (contextValue.typeIdentifier) { + subImportContext.set("./types", { + direct: [contextValue.typeIdentifier.text], + }); + } + + return { + fileName: `${contextValue.identifier.text}.ts`, + importContext: subImportContext, + context: new Map([[dataParser, contextValue]]), + importMode: buildedContext.importMode, + typescriptContext: new Map(), + }; + }, + ), + G.asyncMap( + async(context) => { + asserts( + await SF.writeTextFile( + Path.resolveRelative([ + generateDataParserParams.outputFolder, + context.fileName, + ]), + DataParserToDataParser.printer( + context, + ), + ), + E.isRight, + ); + }, + ), + ); + } }, }, ], diff --git a/scripts/plugins/codeGenerator/typescriptTransformer.ts b/scripts/plugins/codeGenerator/typescriptTransformer.ts index 2200606..6d1bfbc 100644 --- a/scripts/plugins/codeGenerator/typescriptTransformer.ts +++ b/scripts/plugins/codeGenerator/typescriptTransformer.ts @@ -1,3 +1,4 @@ +import { DataParserToTypescript } from "@duplojs/data-parser-tools"; import { createTransformer } from "@duplojs/data-parser-tools/toTypescript"; import { SDP } from "@duplojs/server-utils"; import { DP } from "@duplojs/utils"; @@ -41,3 +42,10 @@ export const timeTransformer = createTransformer( ])); }, ); + +export const typescriptTransformers = [ + fileTransformer, + dateTransformer, + timeTransformer, + ...DataParserToTypescript.defaultTransformers, +]; diff --git a/tests/plugins/codeGenerator/findIdentifiedDataParserInSteps.test.ts b/tests/plugins/codeGenerator/findIdentifiedDataParserInSteps.test.ts new file mode 100644 index 0000000..7a1a92c --- /dev/null +++ b/tests/plugins/codeGenerator/findIdentifiedDataParserInSteps.test.ts @@ -0,0 +1,128 @@ +import { createPresetChecker, ResponseContract, useProcessBuilder, useRouteBuilder } from "@core"; +import { DPE } from "@duplojs/utils"; +import { findIdentifiedDataParserInSteps } from "@plugin-codeGenerator/findIdentifiedDataParserInSteps"; +import { testChecker } from "@test-utils/checker"; + +describe("findIdentifiedDataParserInSteps", () => { + it("finds only identified data parsers from extract steps", () => { + const dataParser1 = DPE.string().setIdentifier("extractNested"); + const dataParser2 = DPE.string().setIdentifier("extractHeader"); + const dataParser3 = DPE.string().setIdentifier("extractQuery"); + + const extractBody = DPE.object({ + nested: dataParser1, + ignored: DPE.number(), + }); + + const route = useRouteBuilder("GET", "/test") + .extract({ + body: extractBody, + headers: dataParser2, + query: { + search: dataParser3, + page: DPE.number(), + }, + }) + .handler( + ResponseContract.noContent("extract.ok"), + (__, { response }) => response("extract.ok"), + ); + + const result = findIdentifiedDataParserInSteps( + route.definition.steps, + { ignoreDataParser: new Set() }, + ); + + expect(result).toStrictEqual([ + dataParser1, + dataParser2, + dataParser3, + ]); + }); + + it("finds only identified data parsers from step response contracts", () => { + const presetBody = DPE.string().setIdentifier("presetBody"); + + const presetChecker = createPresetChecker( + testChecker, + { + result: "info", + otherwise: ResponseContract.badRequest("preset.invalid", presetBody), + }, + ); + + const dataParser1 = DPE.empty().setIdentifier("checkerBody"); + const dataParser2 = DPE.string().setIdentifier("cutBody"); + const dataParser3 = DPE.string().setIdentifier("handlerBody"); + + const route = useRouteBuilder("GET", "/test") + .extract({ body: DPE.string() }) + .check( + testChecker, + { + input: () => "", + result: "info", + otherwise: ResponseContract.badRequest( + "checker.invalid", + dataParser1, + ), + }, + ) + .cut( + ResponseContract.conflict("cut.conflict", dataParser2), + (__, { response }) => response("cut.conflict", ""), + ) + .presetCheck(presetChecker, () => "") + .handler( + ResponseContract.ok( + "handler.ok", + DPE.object({ + kept: dataParser3, + ignored: DPE.number(), + }), + ), + (__, { response }) => response("handler.ok", { + kept: "", + ignored: 1, + }), + ); + + const result = findIdentifiedDataParserInSteps( + route.definition.steps, + { ignoreDataParser: new Set() }, + ); + + expect(result).toStrictEqual([ + dataParser1, + dataParser2, + presetBody, + dataParser3, + ]); + }); + + it("walks nested process steps and ignores already visited data parsers", () => { + const sharedDataParser = DPE.string().setIdentifier("sharedDataParser"); + + const process = useProcessBuilder() + .extract({ body: sharedDataParser }) + .cut( + ResponseContract.conflict("process.conflict", sharedDataParser), + (__, { response }) => response("process.conflict", ""), + ) + .exports(); + + const route = useRouteBuilder("GET", "/test") + .exec(process) + .handler( + ResponseContract.ok("route.ok", sharedDataParser), + (__, { response }) => response("route.ok", ""), + ); + + const result = findIdentifiedDataParserInSteps( + route.definition.steps, + { ignoreDataParser: new Set() }, + ); + + expect(result).toStrictEqual([sharedDataParser]); + }); +});