diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 4d4fd708..bb59b7e0 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -20,7 +20,7 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: 1.4.1 + bun-version: 1.4.4 - uses: dtolnay/rust-toolchain@stable diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b97d44d1..01722cef 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -6,6 +6,7 @@ on: - v1.* - v2.* - v3.* + - v4.* permissions: id-token: write @@ -21,7 +22,7 @@ jobs: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: 1.4.1 + bun-version: 1.4.4 - uses: dtolnay/rust-toolchain@stable - run: bun install --frozen-lockfile - run: bun run build:native @@ -42,7 +43,7 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: 1.4.1 + bun-version: 1.4.4 - run: bun install --frozen-lockfile - name: Set package version from tag diff --git a/.github/workflows/renderer-parity.yml b/.github/workflows/renderer-parity.yml index d7ba0a72..ef0f53a3 100644 --- a/.github/workflows/renderer-parity.yml +++ b/.github/workflows/renderer-parity.yml @@ -23,7 +23,7 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: "1.4.1" + bun-version: 1.4.4 - uses: dtolnay/rust-toolchain@stable if: matrix.renderer == 'native' @@ -63,7 +63,7 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: "1.4.1" + bun-version: 1.4.4 - run: bun install --frozen-lockfile diff --git a/.tool-versions b/.tool-versions index 1f2ac5e9..3688e5c9 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1 @@ -bun 1.4.1 +bun 1.4.4 diff --git a/README.md b/README.md index 44439ce8..efe4d3ea 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,7 @@ bunx openapi-codegen generate --config my-config.ts --excludeTags Comma-separated list of tags to exclude from generation --excludePathRegex Exclude operations whose paths match the given regular expression --excludeRedundantZodSchemas Exclude any redundant Zod schemas (default: true) + --includeSchemas Comma-separated component schemas to generate even when no operation uses them --tsNamespaces Wrap generated files in TypeScript namespaces (default: true) --importPath Module import style for generated files (default: 'ts'; options: 'ts' | 'relative' | 'absolute') @@ -148,7 +149,6 @@ bunx openapi-codegen generate --config my-config.ts --acl Generate ACL related files (default: true) --checkAcl Add ACL check to queries (default: true) - --builderConfigs Generate configs for builders (default: false) --baseUrl (Requires `--restClientImportPath` to NOT be set) Base URL for the generated REST client; falls back to the OpenAPI spec if not provided ``` @@ -167,6 +167,7 @@ bunx openapi-codegen generate --config my-config.ts --excludeTags Comma-separated list of tags to exclude from generation --excludePathRegex Exclude operations whose paths match the given regular expression --excludeRedundantZodSchemas Exclude any redundant Zod schemas (default: true) + --includeSchemas Comma-separated component schemas to generate even when no operation uses them ``` ## Development @@ -309,6 +310,24 @@ import { OpenApiQueryConfig } from "@povio/openapi-codegen-cli/query"; ; ``` +### Typed invalidation maps + +With `mutationEffects` (the default), `queryModules.ts` exports `QueryModuleMutations` (for each module, the `data` and `variables` of its mutation hooks) and registers it globally, so `InvalidationMap` types each module's callback from the spec: + +```ts +import type { InvalidationMap } from "@povio/openapi-codegen-cli/query"; +import { QueryModule } from "@/data/queryModules"; + +export const invalidationMap: InvalidationMap = { + // `data` is any of the module's mutation responses; variables that only some + // of its mutations take (here `id`) are optional. + [QueryModule.Items]: (data, { officeId, id }) => [["items", officeId, id]], +}; +``` + +The registration is global, so a program with several generated clients should pass the mutations explicitly +instead: `InvalidationMap`. + ### Runtime response validation Use `OpenApiQueryConfig.Provider` to allow generated GET query hooks to return invalid response data while still logging the response Zod error to the console. Non-GET requests still throw on invalid response data. diff --git a/bun.lock b/bun.lock index 906e8737..ac47a49c 100644 --- a/bun.lock +++ b/bun.lock @@ -18,7 +18,7 @@ "@casl/ability": "6.8.0", "@casl/react": "5.0.1", "@tanstack/react-query": "~5.90.21", - "@types/bun": "1.4.1", + "@types/bun": "1.4.4", "@types/node": "^25.3.0", "@types/prompt-sync": "^4.2.3", "@types/react": "^19.2.14", @@ -31,7 +31,7 @@ "oxlint-tsgolint": "^0.15.0", "react": "^19.2.4", "tsdown": "^0.22.14", - "tsx": "^4.21.0", + "tsrs": "0.18.0-ts7.1.0-dev.20260929", "type-fest": "^5.4.4", "typescript": "^7.0.2", "vite": "npm:rolldown-vite@^7.3.1", @@ -111,58 +111,6 @@ "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g=="], - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="], - - "@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="], - - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="], - - "@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="], - - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="], - - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="], - - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="], - - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="], - - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="], - - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="], - - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="], - - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="], - - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="], - - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="], - - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="], - - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="], - - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="], - - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="], - - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="], - - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="], - - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="], - - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="], - - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="], - - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="], - - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="], - - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="], - "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], @@ -359,6 +307,16 @@ "@tanstack/react-query": ["@tanstack/react-query@5.90.21", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg=="], + "@tsrs/darwin-arm64": ["@tsrs/darwin-arm64@0.18.0-ts7.1.0-dev.20260929", "", { "os": "darwin", "cpu": "arm64" }, "sha512-sAirMpaGA9uRQm2W+t4zC7PjnijNuFa4PCSIfLKKo0JyAUCRfnMeev0UGBQ9F3g+9Nz187tvikUs5Irh5+eajA=="], + + "@tsrs/linux-arm64-gnu": ["@tsrs/linux-arm64-gnu@0.18.0-ts7.1.0-dev.20260929", "", { "os": "linux", "cpu": "arm64" }, "sha512-zm9/z9yylig68JiZF4Aq0Cm26n09KEU8YoQRlG7tdDWTBNAIViYw8knxnWYsjMWVZ2XiU4mYq1xdrOKuOgABHg=="], + + "@tsrs/linux-arm64-musl": ["@tsrs/linux-arm64-musl@0.18.0-ts7.1.0-dev.20260929", "", { "os": "linux", "cpu": "arm64" }, "sha512-Lw7VVU/YQ9JcarODICEiwzMLdFqZprqh5RGTgbS5Uc6I95P635/g5xA1pOC8RVs4efBvAR30J54iDG3l5JEYiw=="], + + "@tsrs/linux-x64-gnu": ["@tsrs/linux-x64-gnu@0.18.0-ts7.1.0-dev.20260929", "", { "os": "linux", "cpu": "x64" }, "sha512-EdBwBgNICGu1r1h23ixdcKwsMo6E9ll7ZsY3mNERNYqE1Pwdtcat99d5E7zB1J+oy/Xk6YqiizaB6nolIjpDTw=="], + + "@tsrs/linux-x64-musl": ["@tsrs/linux-x64-musl@0.18.0-ts7.1.0-dev.20260929", "", { "os": "linux", "cpu": "x64" }, "sha512-LHk+AOAtXuTlhJhOJ2+elriEIX17ycXljojeNxrrzckOipTG1LksoVWuCSas2nu00EnWAzrdKpt9oqPylUKZfQ=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], "@types/argparse": ["@types/argparse@1.0.38", "", {}, "sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA=="], @@ -371,7 +329,7 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], - "@types/bun": ["@types/bun@1.4.1", "", { "dependencies": { "bun-types": "1.4.1" } }, "sha512-0AVGiTXGajf1rgKom3N+c5L7CBxuoyyv1i44M0nX4UDK0G/fnRAMiri93nHuVPIb429KKtAgj7HatVmmOjeQLA=="], + "@types/bun": ["@types/bun@1.4.4", "", { "dependencies": { "bun-types": "1.4.4" } }, "sha512-9wkRDXUXK124pEnAutE6n/l18j+i9GC5IAHK8TmX/2nlySETmky0OU6yL5U8pW1AoKh++AN9auDCr647BfurjQ=="], "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], @@ -553,7 +511,7 @@ "browserslist": ["browserslist@4.28.8", "", { "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", "electron-to-chromium": "^1.5.402", "node-releases": "^2.0.53", "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA=="], - "bun-types": ["bun-types@1.4.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-loKuVrAFZKfEv+JvWkHRS9GW5IqLuLRjVXN9p+vZvBN86O5hf/pBZQ5hSoyipsrMmWObZBDvWnlmKvjKTM0PdA=="], + "bun-types": ["bun-types@1.4.4", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-yjrJh5oGX7mMPKGd19YXyIjmEuj4G4zNC30bs7PM9Az7NQ3ndF3j6hbeRdvJTRQEhJ/FYQwW0a2moweEi2hMxQ=="], "cac": ["cac@7.0.0", "", {}, "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ=="], @@ -611,8 +569,6 @@ "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], - "esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="], - "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], @@ -833,7 +789,7 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "tsx": ["tsx@4.23.13", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw=="], + "tsrs": ["tsrs@0.18.0-ts7.1.0-dev.20260929", "", { "optionalDependencies": { "@tsrs/darwin-arm64": "0.18.0-ts7.1.0-dev.20260929", "@tsrs/linux-arm64-gnu": "0.18.0-ts7.1.0-dev.20260929", "@tsrs/linux-arm64-musl": "0.18.0-ts7.1.0-dev.20260929", "@tsrs/linux-x64-gnu": "0.18.0-ts7.1.0-dev.20260929", "@tsrs/linux-x64-musl": "0.18.0-ts7.1.0-dev.20260929" }, "bin": { "tsrs": "./bin/tsrs" } }, "sha512-9T0b4Uam/yMFIK16GUx0fUrJqYZnJ/gvSmV9JB8NTyDePvwCI45WFWxyEV/UMV3KfdNRJG/PBsm3fSIB9WmWsA=="], "type-fest": ["type-fest@5.9.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-yANm3Jr3GiJ1qgJlxGAVxTOIcEOk1rhQHamlXtnrCK7EHP4HeM9OGxtMg/W7HFdrVzw/ZWJKGVIJusVH85sLtw=="], @@ -899,6 +855,8 @@ "bun-types/@types/node": ["@types/node@26.4.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA=="], + "bun-types/undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + "cliui/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], "cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], @@ -919,8 +877,6 @@ "@vue/language-core/minimatch/brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="], - "bun-types/@types/node/undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], - "cliui/strip-ansi/ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="], "mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], diff --git a/native/Cargo.toml b/native/Cargo.toml index 3a0c1375..b3171067 100644 --- a/native/Cargo.toml +++ b/native/Cargo.toml @@ -12,7 +12,8 @@ codegen-units = 1 lto = "fat" opt-level = 3 panic = "abort" -strip = "symbols" +# Stripped dylibs fail to load on recent macOS ("mis-aligned LINKEDIT string pool"). +strip = false [profile.dev] opt-level = 2 diff --git a/native/src/config.rs b/native/src/config.rs index 460f66b9..238ade79 100644 --- a/native/src/config.rs +++ b/native/src/config.rs @@ -74,6 +74,8 @@ pub struct GenerateOptions { pub replace_optional_with_nullish: bool, #[serde(default = "default_true")] pub exclude_redundant_zod_schemas: bool, + #[serde(default)] + pub include_schemas: Vec, #[serde(default = "default_enum_suffix")] pub enum_suffix: String, #[serde(default)] @@ -98,8 +100,6 @@ pub struct GenerateOptions { pub parse_request_params: bool, #[serde(default)] pub inline_endpoints: bool, - #[serde(default)] - pub builder_configs: bool, #[serde(default = "default_true")] pub acl: bool, #[serde(default)] @@ -175,6 +175,9 @@ mod runtime_import_tests { #[test] fn mutation_effects_default_uses_query_subpath() { let options: super::GenerateOptions = serde_json::from_str("{}").unwrap(); - assert_eq!(options.mutation_effects_import_path, "@povio/openapi-codegen-cli/query"); + assert_eq!( + options.mutation_effects_import_path, + "@povio/openapi-codegen-cli/query" + ); } } diff --git a/native/src/endpoints.rs b/native/src/endpoints.rs index b71f5863..b09692f8 100644 --- a/native/src/endpoints.rs +++ b/native/src/endpoints.rs @@ -102,11 +102,10 @@ impl<'a> EndpointExtractor<'a> { } fn generated_dependency_names_from_references(&self, references: &[String]) -> Vec { - let registered = self.first_tags.borrow(); let mut names = Vec::new(); let mut expanded = HashSet::default(); for reference in references { - self.add_generated_dependency(reference, ®istered, &mut expanded, &mut names); + self.add_generated_dependency(reference, &mut expanded, &mut names); } names } @@ -130,7 +129,6 @@ impl<'a> EndpointExtractor<'a> { fn add_generated_dependency( &self, reference: &str, - registered: &HashMap, expanded: &mut HashSet, names: &mut Vec, ) { @@ -141,10 +139,14 @@ impl<'a> EndpointExtractor<'a> { if !names.contains(&name) { names.push(name); } - if registered.contains_key(reference) || !expanded.insert(reference.to_string()) { + if !expanded.insert(reference.to_string()) { return; } - if let Some(canonical) = self.canonical_enum_refs.get(reference) { + if let Some(canonical) = self + .canonical_enum_refs + .get(reference) + .filter(|_| self.options.extract_enums) + { let canonical_name = schema_name( canonical.rsplit('/').next().unwrap_or_default(), &self.options.schema_suffix, @@ -155,7 +157,7 @@ impl<'a> EndpointExtractor<'a> { } if let Some(children) = self.resolver.ordered_dependencies.get(reference) { for child in children { - self.add_generated_dependency(child, registered, expanded, names); + self.add_generated_dependency(child, expanded, names); } } } @@ -297,14 +299,23 @@ impl<'a> EndpointExtractor<'a> { .compile_endpoint_schema(schema, true, &indexed.tag)?; let fallback = response_schema_name(status_code, &indexed.name, unique_name, &indexed.tag); + let named = naming_schema(&self.compiler, schema); + if let Some(reference) = named + .as_ref() + .and_then(|named| named.get("$ref")) + .and_then(Value::as_str) + { + self.register_reference_usage(reference, &indexed.tag); + } + let naming = named.as_ref().unwrap_or(schema); let resolved = resolve_schema_name( - schema, + naming, schema_object, code.clone(), Some(&fallback), self.options, ); - if schema.get("$ref").is_none() && complexity(schema_object) >= 2 { + if naming.get("$ref").is_none() && complexity(schema_object) >= 2 { self.set_generated_schema(resolved.clone(), code, &indexed.tag); self.set_generated_object(&resolved, schema_object); self.set_generated_dependencies(&resolved, dependencies); @@ -582,9 +593,18 @@ impl<'a> EndpointExtractor<'a> { }; let fallback = snake_to_camel(&format!("{operation_name}_Body")); let actual = self.resolve(schema).unwrap_or(schema); + let named = naming_schema(&self.compiler, schema); + if let Some(reference) = named + .as_ref() + .and_then(|named| named.get("$ref")) + .and_then(Value::as_str) + { + self.register_reference_usage(reference, &indexed.tag); + } + let naming = named.as_ref().unwrap_or(schema); let resolved = - resolve_schema_name(schema, actual, code.clone(), Some(&fallback), self.options); - if schema.get("$ref").is_none() && complexity(actual) >= 2 { + resolve_schema_name(naming, actual, code.clone(), Some(&fallback), self.options); + if naming.get("$ref").is_none() && complexity(actual) >= 2 { self.set_generated_schema(resolved.clone(), code, &indexed.tag); self.set_generated_object(&resolved, actual); self.set_generated_dependencies(&resolved, dependencies); @@ -703,14 +723,23 @@ impl<'a> EndpointExtractor<'a> { chain }; let composed_code = format!("{code}{effective_chain}"); + let named = naming_schema(&self.compiler, schema); + if let Some(reference) = named + .as_ref() + .and_then(|named| named.get("$ref")) + .and_then(Value::as_str) + { + self.register_reference_usage(reference, &indexed.tag); + } + let naming = named.as_ref().unwrap_or(schema); let zod = resolve_schema_name( - schema, + naming, actual, composed_code.clone(), Some(&fallback), self.options, ); - if schema.get("$ref").is_none() && complexity(actual) >= 2 { + if naming.get("$ref").is_none() && complexity(actual) >= 2 { self.set_generated_schema(zod.clone(), composed_code, &indexed.tag); self.set_generated_object(&zod, actual); self.set_generated_dependencies(&zod, dependencies); @@ -770,6 +799,21 @@ impl<'a> EndpointExtractor<'a> { self.document.pointer(reference.strip_prefix('#')?) } + /// Records that `tag` uses the component enum `reference` without generating its schema here, as the + /// TypeScript renderer does for inline enums it names by a matching component enum: the enum is then + /// emitted with the other component enums, after the endpoint schemas. + fn register_reference_usage(&self, reference: &str, tag: &str) { + self.runtime_tags + .borrow_mut() + .entry(reference.to_string()) + .or_default() + .insert(tag.to_string()); + self.first_tags + .borrow_mut() + .entry(reference.to_string()) + .or_insert_with(|| tag.to_string()); + } + fn register_reference_schema(&self, schema: &Value, tag: &str) -> Result<(), String> { let Some(reference) = schema.get("$ref").and_then(Value::as_str) else { return Ok(()); @@ -919,6 +963,17 @@ fn canonical_enum_refs(document: &Value) -> HashMap { result } +/// A schema to name an endpoint schema by: an inline enum matching a component enum names like its `$ref`. +fn naming_schema(compiler: &ZodCompiler, schema: &Value) -> Option { + compiler + .matching_component_enum_ref(schema) + .map(|reference| { + let mut object = Map::new(); + object.insert("$ref".into(), Value::String(reference)); + Value::Object(object) + }) +} + fn resolve_schema_name( original: &Value, actual: &Value, @@ -1058,7 +1113,7 @@ fn capitalize(value: &str) -> String { fn is_success(status: u16) -> bool { (200..300).contains(&status) } -fn is_parameter_media(name: &str) -> bool { +pub(crate) fn is_parameter_media(name: &str) -> bool { (name.contains("application/") && name.contains("json")) || name.contains("text/") || matches!( diff --git a/native/src/lib.rs b/native/src/lib.rs index a6d2dd6c..f2e73225 100644 --- a/native/src/lib.rs +++ b/native/src/lib.rs @@ -339,7 +339,23 @@ pub fn compile_data(source: String, yaml: bool, options_json: String) -> Result< } } } + // Like the TypeScript renderer, extracted enums keep the owner their component usages gave them, + // and with `modelsInModules` go to the modules of all those usages. + let extracted_tags = compiler.extracted_schema_tags(); + if options.models_in_modules { + for (name, tags) in compiler.extracted_schema_usage_tags() { + let tags = if tags.is_empty() { + extracted_tags.get(name).cloned().into_iter().collect() + } else { + tags.iter().cloned().collect() + }; + usage_tags.insert(name.clone(), tags); + } + } for (name, tags) in &usage_tags { + if extracted_tags.contains_key(name) { + continue; + } let owner = if tags.len() == 1 { tags.iter() .next() @@ -404,10 +420,7 @@ pub fn compile_data(source: String, yaml: bool, options_json: String) -> Result< .len(); let rendered_complete = options.native_compact && options.split_by_tags - && ((options.models_in_common && options.ts_namespaces) - || (!options.models_in_common && !options.ts_namespaces)) && !options.inline_endpoints - && !options.builder_configs && options.workspace_context.is_empty() && (!options.acl || (rendered_acl.len() == acl_tag_count && rendered_shared.contains_key("appAcl"))); diff --git a/native/src/render.rs b/native/src/render.rs index 47fb3577..19f1c8e0 100644 --- a/native/src/render.rs +++ b/native/src/render.rs @@ -52,8 +52,9 @@ pub fn render_model_proxies( document, schemas, schema_refs, - schema_owners, generated_objects, + &document_enum_objects(document), + &property_refs(schema_refs, schema_owners, options), circular_schemas, &options.default_tag, options, @@ -145,6 +146,27 @@ fn render_local_models( .map(|reference| (reference, name.as_str())) }) .collect(); + let enum_objects = document_enum_objects(document); + let property_refs = property_refs(schema_refs, schema_owners, options); + // First non-ref schema per enum code, matching the previous linear `find_map` over `schemas`. + let mut enum_name_by_code: HashMap<&str, &str> = HashMap::default(); + for (name, value) in schemas { + if let Some(code) = value.as_str() + && !schema_refs.contains_key(name) + { + enum_name_by_code.entry(code).or_insert(name.as_str()); + } + } + // Inline enums with a component enum's values are rendered as that enum. + if options.extract_enums { + for (name, value) in schemas { + if let Some(code) = value.as_str().filter(|code| code.starts_with("z.enum(")) + && schema_refs.contains_key(name) + { + enum_name_by_code.entry(code).or_insert(name.as_str()); + } + } + } for (tag, mut tag_schemas) in by_tag { let mut imports: IndexMap> = IndexMap::default(); for schema_name in tag_schemas.keys() { @@ -165,15 +187,14 @@ fn render_local_models( } } } - if let Some(schema) = resolve_document_ref(document, reference) { + // An enum component declares its values; it does not use another enum. + if let Some(schema) = resolve_document_ref(document, reference) + .filter(|schema| schema.get("enum").is_none()) + { let mut enum_codes = Vec::new(); collect_inline_enum_codes(schema, &mut enum_codes); for code in enum_codes { - let Some(name) = schemas.iter().find_map(|(name, value)| { - (value.as_str() == Some(code.as_str()) - && !schema_refs.contains_key(name)) - .then_some(name.as_str()) - }) else { + let Some(name) = enum_name_by_code.get(code.as_str()).copied() else { continue; }; let Some(owner) = schema_owners.get(name).and_then(Value::as_str) else { @@ -243,8 +264,9 @@ fn render_local_models( document, &tag_schemas, schema_refs, - schema_owners, generated_objects, + &enum_objects, + &property_refs, circular_schemas, &tag, options, @@ -281,11 +303,7 @@ fn collect_inline_enum_codes(value: &Value, codes: &mut Vec) { .for_each(|value| collect_inline_enum_codes(value, codes)), Value::Object(object) => { if !object.contains_key("$ref") { - if let Some(values) = object.get("enum").and_then(Value::as_array) { - let code = format!( - "z.enum({})", - Value::Array(values.clone()).to_string().replace(',', ", ") - ); + if let Some(code) = crate::zod::enum_code(value) { if !codes.contains(&code) { codes.push(code); } @@ -372,6 +390,11 @@ fn render_proxy_modules( .map(|reference| (name.as_str(), reference)) }) .collect(); + let schema_positions: HashMap<&str, usize> = schemas + .keys() + .enumerate() + .map(|(position, name)| (name.as_str(), position)) + .collect(); let mut by_tag: IndexMap> = IndexMap::default(); for endpoint in endpoints { let tag = endpoint @@ -420,15 +443,34 @@ fn render_proxy_modules( ); } } + // Generated schemas reach components only: JavaScript collects their refs, not the + // extracted enums they use. A component's extracted enums follow its refs, in extraction + // order (the order of `schemas`). + let is_component = ref_by_name.contains_key(name.as_str()); if let Some(code) = schemas.get(&name).and_then(Value::as_str) { + let mut extracted_enums = Vec::new(); for candidate in schema_identifiers(code) { - if candidate != name - && schemas.contains_key(candidate) - && !direct_names.contains(candidate) + if candidate == name + || !schemas.contains_key(candidate) + || direct_names.contains(candidate) { + continue; + } + let extracted_enum = !ref_by_name.contains_key(candidate) + && schemas + .get(candidate) + .and_then(Value::as_str) + .is_some_and(|code| code.starts_with("z.enum(")); + if !extracted_enum { enqueue(candidate, &mut used, &mut seen, &mut queue); + } else if is_component { + extracted_enums.push(candidate); } } + extracted_enums.sort_by_key(|candidate| schema_positions.get(candidate).copied()); + for candidate in extracted_enums { + enqueue(candidate, &mut used, &mut seen, &mut queue); + } } } used.retain(|name| schemas.contains_key(name)); @@ -445,8 +487,9 @@ fn render_common_models( document: &Value, schemas: &Map, schema_refs: &Map, - schema_owners: &Map, generated_objects: &Map, + enum_objects: &HashMap, + property_refs: &PropertyRefs, circular_schemas: &[String], namespace_tag: &str, options: &GenerateOptions, @@ -458,31 +501,12 @@ fn render_common_models( .map(|config| config.namespace_suffix.as_str()) .unwrap_or("Models"); let namespace = format!("{}{}", capitalize(namespace_tag), namespace_suffix); - let property_ref_names: HashMap<&str, String> = schema_refs - .iter() - .filter_map(|(name, reference)| { - let reference = reference.as_str()?; - let type_name = remove_suffix(name, suffix); - let owner = if options.models_in_common || !options.split_by_tags { - Some(options.default_tag.as_str()) - } else { - schema_owners.get(name).and_then(Value::as_str) - }; - let type_name = if let Some(owner) = owner - && options.ts_namespaces - && owner != namespace_tag - { - format!("{}{namespace_suffix}.{type_name}", capitalize(owner)) - } else { - type_name.to_string() - }; - Some((reference, type_name)) - }) - .collect(); - let mut enum_objects = HashMap::default(); - if let Some(component_schemas) = document.pointer("/components/schemas") { - collect_enum_objects(component_schemas, &mut enum_objects); - } + let property_ref_names = PropertyRefNames { + refs: property_refs, + namespace_tag, + namespace_suffix, + ts_namespaces: options.ts_namespaces, + }; let mut lines = vec!["import { z } from \"zod\";".to_string(), String::new()]; if options.lazy_schemas && options.ts_namespaces { return render_lazy_common_models( @@ -518,7 +542,7 @@ fn render_common_models( code, schema_refs, generated_objects, - &enum_objects, + enum_objects, circular_schemas, suffix, &property_ref_names, @@ -550,7 +574,7 @@ fn render_common_schema_lines( enum_objects: &HashMap, circular_schemas: &[String], suffix: &str, - property_ref_names: &HashMap<&str, String>, + property_ref_names: &PropertyRefNames, options: &GenerateOptions, ) -> Option> { let code = code.as_str()?; @@ -599,7 +623,7 @@ fn render_schema_docs<'a>( schema_refs: &Map, generated_objects: &'a Map, enum_objects: &'a HashMap, - property_ref_names: &HashMap<&str, String>, + property_ref_names: &PropertyRefNames, ) -> (Vec, Option<&'a Value>) { let schema_object = generated_objects .get(name) @@ -665,7 +689,7 @@ fn render_lazy_common_models( enum_objects: &HashMap, circular_schemas: &[String], suffix: &str, - property_ref_names: &HashMap<&str, String>, + property_ref_names: &PropertyRefNames, namespace: &str, mut lines: Vec, options: &GenerateOptions, @@ -732,7 +756,7 @@ fn render_lazy_schema_parts( enum_objects: &HashMap, circular_schemas: &[String], suffix: &str, - property_ref_names: &HashMap<&str, String>, + property_ref_names: &PropertyRefNames, schema_names: &HashSet<&str>, namespace: &str, options: &GenerateOptions, @@ -959,7 +983,11 @@ fn render_schema_type(schema: &Value, suffix: &str, options: &GenerateOptions) - format!("{property_name}{optional}: {property_type}") }) .collect::>(); - if let Some(additional) = schema.get("additionalProperties") { + // Like JavaScript's truthiness check, `false` and `null` add no index signature. + if let Some(additional) = schema + .get("additionalProperties") + .filter(|additional| !matches!(additional, Value::Bool(false) | Value::Null)) + { let value_type = if additional.is_object() { render_schema_type(additional, suffix, options) } else { @@ -988,6 +1016,62 @@ fn add_nullable_type(schema: &Value, rendered: String) -> String { } } +/// Schema reference -> (type name, owner tag); independent of the tag being rendered. +type PropertyRefs = HashMap)>; + +fn property_refs( + schema_refs: &Map, + schema_owners: &Map, + options: &GenerateOptions, +) -> PropertyRefs { + schema_refs + .iter() + .filter_map(|(name, reference)| { + let reference = reference.as_str()?; + let owner = if options.models_in_common || !options.split_by_tags { + Some(options.default_tag.as_str()) + } else { + schema_owners.get(name).and_then(Value::as_str) + }; + Some(( + reference.to_string(), + ( + remove_suffix(name, &options.schema_suffix), + owner.map(str::to_string), + ), + )) + }) + .collect() +} + +/// Resolves property reference type names as seen from `namespace_tag`'s models file. +struct PropertyRefNames<'a> { + refs: &'a PropertyRefs, + namespace_tag: &'a str, + namespace_suffix: &'a str, + ts_namespaces: bool, +} + +impl PropertyRefNames<'_> { + fn get(&self, reference: &str) -> Option { + let (type_name, owner) = self.refs.get(reference)?; + Some(match owner { + Some(owner) if self.ts_namespaces && owner != self.namespace_tag => { + format!("{}{}.{type_name}", capitalize(owner), self.namespace_suffix) + } + _ => type_name.clone(), + }) + } +} + +fn document_enum_objects(document: &Value) -> HashMap { + let mut enum_objects = HashMap::default(); + if let Some(component_schemas) = document.pointer("/components/schemas") { + collect_enum_objects(component_schemas, &mut enum_objects); + } + enum_objects +} + fn collect_enum_objects(value: &Value, enums: &mut HashMap) { match value { Value::Array(values) => values @@ -1068,8 +1152,21 @@ fn collect_property_docs( schema: &Value, prefix: &str, properties: &mut IndexMap, - property_ref_names: &HashMap<&str, String>, + property_ref_names: &PropertyRefNames, ) { + if schema.get("type").and_then(Value::as_str) == Some("array") { + if let Some(items) = schema + .get("items") + .filter(|items| items.get("$ref").is_none()) + { + let key = if prefix.is_empty() { + "[0]".into() + } else { + format!("{prefix}.[0]") + }; + collect_property_docs(document, items, &key, properties, property_ref_names); + } + } if let Some(all_of) = schema.get("allOf").and_then(Value::as_array) { for member in all_of.iter().filter(|member| member.get("$ref").is_some()) { if let Some(reference) = member.get("$ref").and_then(Value::as_str) { @@ -1179,7 +1276,7 @@ fn collect_property_docs( fn property_doc_type( document: &Value, schema: &Value, - property_ref_names: &HashMap<&str, String>, + property_ref_names: &PropertyRefNames, ) -> String { if let Some(reference) = schema.get("$ref").and_then(Value::as_str) { return property_ref_type(reference, property_ref_names); @@ -1219,9 +1316,9 @@ fn property_doc_type( .to_string() } -fn property_ref_type(reference: &str, property_ref_names: &HashMap<&str, String>) -> String { +fn property_ref_type(reference: &str, property_ref_names: &PropertyRefNames) -> String { if let Some(name) = property_ref_names.get(reference) { - return name.clone(); + return name; } let mut name = reference .rsplit('/') @@ -1556,6 +1653,7 @@ pub fn render_endpoints( .unwrap_or(&options.default_tag); by_tag.entry(tag.to_string()).or_default().push(endpoint); } + let model_owners = ModelOwners::new(schema_owners, options); for (tag, tag_endpoints) in by_tag { if should_inline_endpoints(&tag, options) || tag_endpoints.is_empty() { continue; @@ -1567,6 +1665,7 @@ pub fn render_endpoints( &tag_endpoints, schemas, schema_owners, + &model_owners, options, )), ); @@ -1607,6 +1706,7 @@ fn render_endpoint_module( endpoints: &[&Value], schemas: &Map, schema_owners: &Map, + model_owners: &ModelOwners, options: &GenerateOptions, ) -> String { let mut lines = vec![format!( @@ -1700,7 +1800,8 @@ fn render_endpoint_module( let mut imports: IndexMap> = IndexMap::default(); for schema in &used_schemas { if is_named_schema(schema) { - let owner = if options.models_in_modules { + // Like JavaScript, shared models are imported through the module's own proxy. + let owner = if options.models_in_modules || options.models_in_common { tag } else { schema_owners @@ -1724,7 +1825,7 @@ fn render_endpoint_module( .filter(|schema| is_named_schema(schema)) { let name = remove_suffix(schema, &options.schema_suffix); - let owner = if options.models_in_modules { + let owner = if options.models_in_modules || options.models_in_common { tag } else { schema_owners @@ -1794,10 +1895,20 @@ fn render_endpoint_module( if options.ts_namespaces { lines.push("}".into()); } + let mut schema_order = used_schemas.clone(); + schema_order.extend(endpoints.iter().flat_map(|endpoint| { + endpoint + .get("parameters") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|parameter| parameter.get("zodSchema").and_then(Value::as_str)) + })); resolve_model_namespace_owners( format!("{}\n", lines.join("\n").trim_end()), tag, - schema_owners, + model_owners, + &schema_order, options, ) } @@ -2234,6 +2345,7 @@ pub fn render_queries( by_tag.entry(tag.to_string()).or_default().push(endpoint); } let tags = by_tag.into_iter().collect::>(); + let model_owners = ModelOwners::new(schema_owners, options); let chunk_size = tags.len().div_ceil(4).max(1); let modules = std::thread::scope(|scope| { tags.chunks(chunk_size) @@ -2248,6 +2360,7 @@ pub fn render_queries( tag, tag_endpoints, schema_owners, + &model_owners, options, )), ) @@ -2328,7 +2441,8 @@ fn render_query_modules(endpoints: &[Value], options: &GenerateOptions) -> Strin .get("queries") .map(|config| config.namespace_suffix.as_str()) .unwrap_or("Queries"); - let mut tags = IndexSet::default(); + // Each tag's mutation hooks, for the `QueryModuleMutations` interface. + let mut tags: IndexMap<&str, Vec> = IndexMap::default(); for endpoint in endpoints { let tag = endpoint .get("tags") @@ -2336,13 +2450,69 @@ fn render_query_modules(endpoints: &[Value], options: &GenerateOptions) -> Strin .and_then(|tags| tags.first()) .and_then(Value::as_str) .unwrap_or(&options.default_tag); - tags.insert(tag); + let hooks = tags.entry(tag).or_default(); + if is_mutation_endpoint(endpoint) { + let (_, cap) = query_names(endpoint); + if endpoint.get("method").and_then(Value::as_str) == Some("get") { + hooks.push(format!("use{cap}Mutation")); + } else { + hooks.push(format!("use{cap}")); + } + } + } + let mutation_tags = tags + .iter() + .filter(|(_, hooks)| !hooks.is_empty()) + .collect::>(); + let mut lines = Vec::new(); + if !mutation_tags.is_empty() { + lines.push(format!( + "import type {{ MutationEffectsOf }} from \"{}\";", + query_types_import_path(options) + )); + let queries_suffix = output_suffix(options, "queries", "queries"); + for (tag, _) in &mutation_tags { + let namespace = format!("{}{suffix}", capitalize(tag)); + let file = decapitalize(tag); + let from = format!( + "{}{file}/{file}.{queries_suffix}", + root_import_root(options) + ); + lines.push(if options.ts_namespaces { + format!("import type {{ {namespace} }} from \"{from}\";") + } else { + format!("import type * as {namespace} from \"{from}\";") + }); + } + lines.push(String::new()); } - let mut lines = vec!["export const enum QueryModule {".to_string()]; - for tag in tags { + lines.push("export const enum QueryModule {".to_string()); + for tag in tags.keys() { lines.push(format!(" {tag} = \"{}{suffix}\",", capitalize(tag))); } lines.push("}".into()); + lines.push(String::new()); + lines.push("export interface QueryModuleMutations {".into()); + for (tag, hooks) in &mutation_tags { + let namespace = format!("{}{suffix}", capitalize(tag)); + let hooks = hooks + .iter() + .map(|hook| format!("typeof {namespace}.{hook}")) + .collect::>() + .join(" | "); + lines.push(format!( + " [QueryModule.{tag}]: MutationEffectsOf<{hooks}>;" + )); + } + lines.push("}".into()); + lines.push(String::new()); + // Registers the types globally, so a bare `InvalidationMap` is keyed and typed by this client. + lines.push("declare global {".into()); + lines.push(" interface OpenApiCodegenQueryModules { queryModule: QueryModule }".into()); + lines.push( + " interface OpenApiCodegenQueryModuleMutations extends QueryModuleMutations {}".into(), + ); + lines.push("}".into()); lines.join("\n") } @@ -2364,7 +2534,13 @@ fn render_app_acl(endpoints: &[Value]) -> Option { } let acl_endpoints: Vec<&Value> = by_tag.into_values().flatten().collect(); if acl_endpoints.is_empty() { - return None; + // Without ACL rules the TypeScript renderer still emits an untyped ability. + return Some( + "import type { PureAbility, AbilityTuple, Subject } from \"@casl/ability\";\n\n\ + export type AppAbilities = AbilityTuple;\n\n\ + export type AppAbility = PureAbility;" + .into(), + ); } // Named schema conditions need the full TypeScript import merger for uncommon configurations. if acl_endpoints @@ -2577,7 +2753,7 @@ fn render_acl_module( { if let Some(schema) = condition.get("zodSchemaName").and_then(Value::as_str) { let name = remove_suffix(schema, &options.schema_suffix); - let owner = if options.models_in_modules { + let owner = if options.models_in_modules || options.models_in_common { tag } else { schema_owners @@ -2810,6 +2986,7 @@ fn render_query_module( tag: &str, endpoints: &[&Value], schema_owners: &Map, + model_owners: &ModelOwners, options: &GenerateOptions, ) -> String { let model_suffix = options @@ -2874,12 +3051,6 @@ fn render_query_module( }) .collect(); let mut lines = Vec::new(); - if uses_native_rest_client(options) && has_media_upload { - lines.push(format!( - "import {{ AppRestClient }} from \"{}\";", - rest_client_import_path(options) - )); - } if uses_native_rest_client(options) { let mut transport_types = Vec::new(); if !queries.is_empty() || options.axios_request_config { @@ -2901,6 +3072,12 @@ fn render_query_module( "import { type AxiosRequestConfig } from \"axios\";".to_string() }); } + if uses_native_rest_client(options) && has_media_upload { + lines.push(format!( + "import {{ AppRestClient }} from \"{}\";", + rest_client_import_path(options) + )); + } let mut query_imports = Vec::new(); if options.prefetch_queries && !queries.is_empty() { query_imports.push("QueryClient"); @@ -2970,11 +3147,7 @@ fn render_query_module( lines.push(format!( "import {{ {} }} from \"{}\";", query_types.join(", "), - if options.query_types_import_path == "@povio/openapi-codegen-cli" { - "@povio/openapi-codegen-cli/query" - } else { - &options.query_types_import_path - } + query_types_import_path(options) )); if endpoints .iter() @@ -3006,7 +3179,7 @@ fn render_query_module( .filter(|schema| is_named_schema(schema)) { let name = remove_suffix(&schema, &options.schema_suffix); - let owner = if options.models_in_modules { + let owner = if options.models_in_modules || options.models_in_common { tag } else { schema_owners @@ -3164,14 +3337,73 @@ fn render_query_module( content = content.replace(&format!("{api_namespace}."), ""); content = content.replace(&format!("{acl_namespace}."), ""); } - resolve_model_namespace_owners(content, tag, schema_owners, options) + let schema_order: Vec<&str> = endpoints + .iter() + .flat_map(|endpoint| { + endpoint + .get("parameters") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|parameter| parameter.get("zodSchema").and_then(Value::as_str)) + }) + .collect(); + resolve_model_namespace_owners(content, tag, model_owners, &schema_order, options) +} + +/// Model name (with and without the schema suffix) -> owning tags, in `schema_owners` order. +/// Built once per render; each tag's module looks names up through `for_tag`. +pub(crate) struct ModelOwners<'a> { + owners: HashMap>, +} + +impl<'a> ModelOwners<'a> { + pub(crate) fn new(schema_owners: &'a Map, options: &GenerateOptions) -> Self { + let mut owners: HashMap> = HashMap::default(); + for (schema, owner) in schema_owners { + if let Some(owner) = owner.as_str() { + owners.entry(schema.clone()).or_default().push(owner); + owners + .entry(remove_suffix(schema, &options.schema_suffix)) + .or_default() + .push(owner); + } + } + Self { owners } + } + + fn for_tag<'b>(&'b self, tag: &'b str) -> TagModelOwners<'a, 'b> { + TagModelOwners { owners: self, tag } + } +} + +/// The owners of models referenced from `tag`'s module: tags other than `tag`. +struct TagModelOwners<'a, 'b> { + owners: &'b ModelOwners<'a>, + tag: &'b str, +} + +impl<'a> TagModelOwners<'a, '_> { + /// The last owner other than this tag, as the previous per-tag map (later inserts win) had it. + fn get(&self, name: &str) -> Option<&'a str> { + self.owners + .owners + .get(name)? + .iter() + .rev() + .find(|owner| **owner != self.tag) + .copied() + } } // Namespace-based modules can reference models shared by several tags. +/// `schema_order` lists the schemas whose models the TypeScript renderer imports, in its order: the +/// imports follow the first use of each owner there. fn resolve_model_namespace_owners( content: String, tag: &str, - schema_owners: &Map, + model_owners: &ModelOwners, + schema_order: &[&str], options: &GenerateOptions, ) -> String { if !options.ts_namespaces || options.models_in_common || options.models_in_modules { @@ -3184,13 +3416,7 @@ fn resolve_model_namespace_owners( .unwrap_or("Models"); let local_namespace = format!("{}{namespace_suffix}", capitalize(tag)); let prefix = format!("{local_namespace}."); - let mut owners = HashMap::default(); - for (schema, owner) in schema_owners { - if let Some(owner) = owner.as_str().filter(|owner| *owner != tag) { - owners.insert(schema.as_str().to_string(), owner); - owners.insert(remove_suffix(schema, &options.schema_suffix), owner); - } - } + let owners = model_owners.for_tag(tag); let mut imports = IndexSet::default(); let mut has_local = false; let mut output = String::with_capacity(content.len()); @@ -3217,13 +3443,12 @@ fn resolve_model_namespace_owners( .map(|end| start + end) .unwrap_or(start); output.push_str(&line[..start]); - let mut ty = line[start..end].to_string(); - for (name, owner) in &owners { - let source = format!("{prefix}{name}"); - let target = format!("{}{namespace_suffix}.{name}", capitalize(owner)); - ty = replace_model_identifier(&ty, &source, &target); - } - output.push_str(&ty); + output.push_str(&rename_owned_models( + &line[start..end], + &prefix, + &owners, + namespace_suffix, + )); output.push_str(&line[end..]); } else { output.push_str(line); @@ -3244,11 +3469,24 @@ fn resolve_model_namespace_owners( let mut character_class = false; let mut end = remaining.len(); for (index, ch) in remaining.char_indices().skip(1) { - if escaped { escaped = false; continue; } - if ch == '\\' { escaped = true; continue; } - if ch == '[' { character_class = true; } - if ch == ']' { character_class = false; } - if ch == '/' && !character_class { end = index + 1; break; } + if escaped { + escaped = false; + continue; + } + if ch == '\\' { + escaped = true; + continue; + } + if ch == '[' { + character_class = true; + } + if ch == ']' { + character_class = false; + } + if ch == '/' && !character_class { + end = index + 1; + break; + } } output.push_str(&remaining[..end]); remaining = &remaining[end..]; @@ -3288,7 +3526,7 @@ fn resolve_model_namespace_owners( .unwrap_or(remaining.len()); let name = &remaining[..length]; if let Some(owner) = owners.get(name) { - imports.insert(*owner); + imports.insert(owner); output.push_str(&format!("{}{namespace_suffix}.", capitalize(owner))); } else { has_local = true; @@ -3304,29 +3542,40 @@ fn resolve_model_namespace_owners( } let local_import = format!("import {{ {local_namespace} }} from "); let mut lines = output.lines().map(str::to_string).collect::>(); - let insertion = lines + let local_position = lines .iter() - .position(|line| line.starts_with(&local_import)) + .position(|line| line.starts_with(&local_import)); + let insertion = local_position .unwrap_or_else(|| lines.iter().position(|line| line.is_empty()).unwrap_or(0)); - if !has_local - && lines - .get(insertion) - .is_some_and(|line| line.starts_with(&local_import)) - { - lines.remove(insertion); - } - let insertion = insertion + usize::from(has_local); + let local_line = local_position + .map(|position| lines.remove(position)) + .filter(|_| has_local); + let mut owner_order = IndexSet::default(); + for schema in schema_order.iter().filter(|schema| is_named_schema(schema)) { + owner_order.insert(owners.get(schema).unwrap_or(tag)); + } + let rank = |owner: &str| owner_order.get_index_of(owner).unwrap_or(usize::MAX); let suffix = output_suffix(options, "models", "models"); - for (offset, owner) in imports.into_iter().enumerate() { - let owner_tag = decapitalize(owner); - lines.insert( - insertion + offset, - format!( - "import {{ {}{namespace_suffix} }} from \"{}{owner_tag}/{owner_tag}.{suffix}\";", - capitalize(owner), - import_root(options) - ), - ); + let mut entries = imports + .into_iter() + .map(|owner| { + let owner_tag = decapitalize(owner); + ( + rank(owner), + format!( + "import {{ {}{namespace_suffix} }} from \"{}{owner_tag}/{owner_tag}.{suffix}\";", + capitalize(owner), + import_root(options) + ), + ) + }) + .collect::>(); + if let Some(line) = local_line { + entries.push((rank(tag), line)); + } + entries.sort_by_key(|(rank, _)| *rank); + for (offset, (_, line)) in entries.into_iter().enumerate() { + lines.insert(insertion + offset, line); } format!("{}\n", lines.join("\n")) } @@ -3335,22 +3584,38 @@ fn model_identifier_char(ch: char) -> bool { ch.is_ascii_alphanumeric() || ch == '_' || ch == '$' } -fn replace_model_identifier(value: &str, source: &str, target: &str) -> String { - let mut output = String::new(); +/// Rewrites `{prefix}{name}` to `{Owner}{namespace_suffix}.{name}` for models owned by another tag. +/// One pass: a match needs identifier boundaries on both sides, so only the identifier right after +/// the prefix can match, and a rewritten reference never forms a new match. +fn rename_owned_models( + value: &str, + prefix: &str, + owners: &TagModelOwners, + namespace_suffix: &str, +) -> String { + let mut output = String::with_capacity(value.len()); let mut remaining = value; - while let Some(index) = remaining.find(source) { + while let Some(index) = remaining.find(prefix) { output.push_str(&remaining[..index]); - let after = &remaining[index + source.len()..]; + let after = &remaining[index + prefix.len()..]; + let length = after + .find(|ch: char| !model_identifier_char(ch)) + .unwrap_or(after.len()); + let name = &after[..length]; let boundary = output .chars() .last() - .is_none_or(|ch| !model_identifier_char(ch)) - && after - .chars() - .next() - .is_none_or(|ch| !model_identifier_char(ch)); - output.push_str(if boundary { target } else { source }); - remaining = after; + .is_none_or(|ch| !model_identifier_char(ch)); + match owners.get(name).filter(|_| boundary) { + Some(owner) => { + output.push_str(&capitalize(owner)); + output.push_str(namespace_suffix); + output.push('.'); + } + None => output.push_str(prefix), + } + output.push_str(name); + remaining = &after[length..]; } output.push_str(remaining); output @@ -3372,6 +3637,22 @@ fn output_suffix<'a>(options: &'a GenerateOptions, kind: &str, default: &'a str) .unwrap_or(default) } +fn query_types_import_path(options: &GenerateOptions) -> &str { + if options.query_types_import_path == "@povio/openapi-codegen-cli" { + "@povio/openapi-codegen-cli/query" + } else { + &options.query_types_import_path + } +} + +/// Like `import_root`, for files at the output root (`queryModules.ts`). +fn root_import_root(options: &GenerateOptions) -> String { + match options.import_path.as_str() { + "relative" => "./".into(), + _ => import_root(options), + } +} + fn import_root(options: &GenerateOptions) -> String { match options.import_path.as_str() { "relative" => "../".into(), @@ -3861,23 +4142,29 @@ fn parameter_description(parameter: &Value) -> String { { parts.push(description.to_string()); } - let schema = parameter - .pointer("/parameterObject/schema") + let media = parameter + .pointer("/bodyObject/content") + .and_then(Value::as_object) + .and_then(|content| { + content + .iter() + .find(|(name, _)| crate::endpoints::is_parameter_media(name)) + }) + .map(|(_, media)| media); + let schema = media + .and_then(|media| media.get("schema")) + .or_else(|| parameter.pointer("/parameterObject/schema")) .filter(|schema| schema.get("$ref").is_none()); if let Some(schema) = schema { - if let Some(minimum) = schema.get("minimum") { - parts.push(format!("Minimum: `{minimum}`")); - } - if let Some(maximum) = schema.get("maximum") { - parts.push(format!("Maximum: `{maximum}`")); - } - if let Some(default) = schema.get("default") { - parts.push(format!("Default: `{}`", display_json(default))); - } - if let Some(example) = schema.get("example") { - parts.push(format!("Example: `{}`", display_json(example))); + for (key, label) in DESCRIPTION_KEYS { + if let Some(value) = schema.get(*key) { + parts.push(format!("{label}: `{}`", display_js_value(value))); + } } } + if let Some(example) = media.and_then(|media| media.get("example")) { + parts.push(format!("Example: `{}`", display_js_value(example))); + } if parts.is_empty() { String::new() } else { @@ -3885,13 +4172,6 @@ fn parameter_description(parameter: &Value) -> String { } } -fn display_json(value: &Value) -> String { - value - .as_str() - .map(str::to_string) - .unwrap_or_else(|| value.to_string()) -} - fn is_mutation_endpoint(endpoint: &Value) -> bool { endpoint.get("method").and_then(Value::as_str) != Some("get") || endpoint @@ -4434,7 +4714,13 @@ const regex = z.string().regex(/EmailAdminModels.BaseLogLevelEnumSchema/); const escapedRegex = z.string().regex(/\/[/]EmailAdminModels.BaseLogLevelEnumSchema/); // EmailAdminModels.BaseLogLevelEnumSchema remains unchanged "#; - let actual = resolve_model_namespace_owners(source.into(), "EmailAdmin", &owners, &options); + let actual = resolve_model_namespace_owners( + source.into(), + "EmailAdmin", + &ModelOwners::new(&owners, &options), + &[], + &options, + ); assert!(actual.contains("const value = CommonModels.BaseLogLevelEnumSchema;")); assert!(actual.contains("* @returns { CommonModels.BaseLogLevelEnum } EmailAdminModels.BaseLogLevelEnumSchema stays literal")); assert!( @@ -4446,7 +4732,9 @@ const escapedRegex = z.string().regex(/\/[/]EmailAdminModels.BaseLogLevelEnumSch assert!(actual.contains("const quoted = \"EmailAdminModels.BaseLogLevelEnumSchema\";")); assert!(actual.contains("const single = 'EmailAdminModels.BaseLogLevelEnumSchema';")); assert!(actual.contains("// EmailAdminModels.BaseLogLevelEnumSchema remains unchanged")); - assert!(actual.contains("const regex = z.string().regex(/EmailAdminModels.BaseLogLevelEnumSchema/);")); + assert!(actual.contains( + "const regex = z.string().regex(/EmailAdminModels.BaseLogLevelEnumSchema/);" + )); assert!(actual.contains(r"const escapedRegex = z.string().regex(/\/[/]EmailAdminModels.BaseLogLevelEnumSchema/);")); assert!(!actual.contains("import { EmailAdminModels }")); assert!(actual.contains("import { CommonModels }")); diff --git a/native/src/resolver.rs b/native/src/resolver.rs index bcce09e1..e4431950 100644 --- a/native/src/resolver.rs +++ b/native/src/resolver.rs @@ -171,9 +171,10 @@ fn collect_ordered_dependencies( }; let mut enum_ref_by_values: IndexMap = IndexMap::default(); for (name, schema) in schemas { - if let Some(values) = schema.get("enum").and_then(Value::as_array) { + // Keyed by the rendered code, which leaves out `null`, as the TypeScript resolver compares enums. + if let Some(code) = crate::zod::enum_code(schema) { enum_ref_by_values - .entry(Value::Array(values.clone()).to_string()) + .entry(code) .or_insert_with(|| format!("#/components/schemas/{name}")); } } @@ -228,8 +229,8 @@ fn collect_inline_enum_values(value: &Value, values: &mut HashSet) { } } Value::Object(object) => { - if let Some(enum_values) = object.get("enum").and_then(Value::as_array) { - values.insert(Value::Array(enum_values.clone()).to_string()); + if let Some(code) = crate::zod::enum_code(value) { + values.insert(code); } for value in object.values() { collect_inline_enum_values(value, values); diff --git a/native/src/zod.rs b/native/src/zod.rs index 4f1d6737..acf7381d 100644 --- a/native/src/zod.rs +++ b/native/src/zod.rs @@ -21,6 +21,7 @@ pub struct ZodCompiler<'a> { root_enums: HashMap, extracted_enums: IndexMap, extracted_enum_objects: Map, + extracted_enum_usage_tags: HashMap>, circular_getters: HashSet, runtime_tags: Rc>>>, } @@ -58,7 +59,7 @@ impl<'a> ZodCompiler<'a> { } } } - let (extracted_enums, extracted_enum_objects) = + let (extracted_enums, extracted_enum_objects, extracted_enum_usage_tags) = collect_extracted_enums(document, resolver, options, &root_enums); let circular_getters = collect_precompiled_circular_refs(document, resolver); Self { @@ -68,6 +69,7 @@ impl<'a> ZodCompiler<'a> { root_enums, extracted_enums, extracted_enum_objects, + extracted_enum_usage_tags, circular_getters, runtime_tags, } @@ -77,6 +79,19 @@ impl<'a> ZodCompiler<'a> { self.compile_components_with_tags(&HashMap::default()) } + /// The `includeSchemas` components and every schema they depend on. + fn included_schema_refs(&self) -> HashSet { + let mut refs = HashSet::default(); + for name in &self.options.include_schemas { + let reference = format!("#/components/schemas/{name}"); + if let Some(dependencies) = self.resolver.deep_dependencies.get(&reference) { + refs.extend(dependencies.iter().cloned()); + } + refs.insert(reference); + } + refs + } + pub fn compile_components_with_tags( &self, first_tags: &HashMap, @@ -89,11 +104,15 @@ impl<'a> ZodCompiler<'a> { else { return Ok(result); }; + let included = self.included_schema_refs(); for (name, schema) in schemas { let zod_name = schema_name(name, &self.options.schema_suffix); let reference = format!("#/components/schemas/{name}"); + // Endpoints can use a component enum without referencing it (see `matching_component_enum_ref`). if self.options.exclude_redundant_zod_schemas && !self.resolver.schema_tags.contains_key(&reference) + && !first_tags.contains_key(&reference) + && !included.contains(&reference) { continue; } @@ -132,6 +151,31 @@ impl<'a> ZodCompiler<'a> { Ok((code, chain(actual, meta, self.options))) } + /// The `#/components/schemas/…` ref of the component enum an inline string enum has the values of. + /// The compiler renders such a schema by that enum's name, as the TypeScript renderer turns it into + /// a `$ref`; endpoint schemas are then named like that `$ref` instead of getting a `…Param`/`…Body` + /// alias. + pub fn matching_component_enum_ref(&self, schema: &Value) -> Option { + let object = schema.as_object()?; + if object.contains_key("$ref") || !self.options.extract_enums { + return None; + } + let values = object.get("enum")?.as_array()?; + let is_string = object + .get("type") + .and_then(Value::as_str) + .is_some_and(|schema_type| schema_type.eq_ignore_ascii_case("string")); + if !is_string || (self.options.models_in_modules && values.len() == 1) { + return None; + } + let name = self.root_enums.get(&enum_code(schema)?)?; + Some(format!( + "#/components/schemas/{}", + name.strip_suffix(&self.options.schema_suffix) + .unwrap_or(name) + )) + } + pub fn endpoint_chain(&self, schema: &Value, required: bool) -> String { let actual = self.resolve_schema(schema).unwrap_or(schema); chain( @@ -162,6 +206,11 @@ impl<'a> ZodCompiler<'a> { .collect() } + /// Every tag whose schemas use each extracted enum: its modules with `modelsInModules`. + pub fn extracted_schema_usage_tags(&self) -> &HashMap> { + &self.extracted_enum_usage_tags + } + pub fn schema_owner(&self, reference: &str) -> String { self.schema_tag(reference) } @@ -467,7 +516,7 @@ impl<'a> ZodCompiler<'a> { && schema_reaches(property, root, self.resolver) }); rendered.push(if circular { - format!("get {property_name}() {{ return {code} }}") + format!("{property_name}: z.lazy(() => {code})") } else { format!("{property_name}: {code}") }); @@ -746,7 +795,7 @@ fn chain(schema: &Value, meta: Meta, options: &GenerateOptions) -> String { } } -fn enum_code(schema: &Value) -> Option { +pub(crate) fn enum_code(schema: &Value) -> Option { let values = schema.get("enum")?.as_array()?; let values = values .iter() @@ -872,9 +921,13 @@ fn collect_extracted_enums( resolver: &Resolver<'_>, options: &GenerateOptions, root_enums: &HashMap, -) -> (IndexMap, Map) { +) -> ( + IndexMap, + Map, + HashMap>, +) { if !options.extract_enums { - return (IndexMap::default(), Map::new()); + return (IndexMap::default(), Map::new(), HashMap::default()); } let mut candidates: IndexMap = IndexMap::default(); collect_operation_enum_candidates(document, resolver, &mut candidates); @@ -985,7 +1038,15 @@ fn collect_extracted_enums( ) }) .collect(); - (preliminary, objects) + let usage_tags = preliminary + .iter() + .map(|(code, (name, _))| { + let mut tags = candidates[code].tags.iter().cloned().collect::>(); + tags.sort(); + (name.clone(), tags) + }) + .collect(); + (preliminary, objects, usage_tags) } fn sanitize_enum_segment(segment: &str) -> String { diff --git a/openapi-codegen.config.mjs b/openapi-codegen.config.mjs index 71b80682..42d2202d 100644 --- a/openapi-codegen.config.mjs +++ b/openapi-codegen.config.mjs @@ -4,7 +4,6 @@ const config = { output: "./test/generated/next", includeTags: ["auth"], replaceOptionalWithNullish: true, - builderConfigs: true, infiniteQueries: true, }; diff --git a/package.json b/package.json index 806ae674..4bac1a0e 100644 --- a/package.json +++ b/package.json @@ -101,7 +101,7 @@ "gen:next": "rm -rf ./test/generated/next && mkdir -p ./test/generated/next && bun run start generate --config ./test/config.mjs --output ./test/generated/next --no-prettier", "test:generated-eq": "vitest run ./src/generators/generated-output-eq.test.ts", "gen:verify": "bun run gen:base && bun run gen:next && bun run test:generated-eq", - "typecheck": "tsc --noEmit", + "typecheck": "tsrs --noEmit", "lint": "oxlint --type-aware --fix", "lint:check": "oxlint --type-aware", "format": "oxfmt", @@ -128,7 +128,7 @@ "@casl/ability": "6.8.0", "@casl/react": "5.0.1", "@tanstack/react-query": "~5.90.21", - "@types/bun": "1.4.1", + "@types/bun": "1.4.4", "@types/node": "^25.3.0", "@types/prompt-sync": "^4.2.3", "@types/react": "^19.2.14", @@ -141,7 +141,7 @@ "oxlint-tsgolint": "^0.15.0", "react": "^19.2.4", "tsdown": "^0.22.14", - "tsx": "^4.21.0", + "tsrs": "0.18.0-ts7.1.0-dev.20260929", "type-fest": "^5.4.4", "typescript": "^7.0.2", "vite": "npm:rolldown-vite@^7.3.1", @@ -183,8 +183,8 @@ } }, "engines": { - "bun": ">= 1.4", + "bun": ">= 1.4.4", "node": ">= 18" }, - "packageManager": "bun@1.4.1" + "packageManager": "bun@1.4.4" } diff --git a/scripts/renderer-parity-cases.ts b/scripts/renderer-parity-cases.ts index a7ef9065..8aa95e5f 100644 --- a/scripts/renderer-parity-cases.ts +++ b/scripts/renderer-parity-cases.ts @@ -9,6 +9,7 @@ export const parityFixtures = [ { name: "petstore", file: "test/petstore.yaml" }, { name: "configuration", file: "test/configuration.yaml" }, { name: "petstore-health", file: "test/petstore.yaml", healthOnly: true }, + { name: "renderer-regressions", file: "test/renderer-regressions.yaml" }, ]; export function readParityFixture(fixture: (typeof parityFixtures)[number]) { diff --git a/scripts/renderer-parity-configs.ts b/scripts/renderer-parity-configs.ts index 1d3b7111..a5eb1c54 100644 --- a/scripts/renderer-parity-configs.ts +++ b/scripts/renderer-parity-configs.ts @@ -26,6 +26,7 @@ export const optionCases = { excludeTags: [{ excludeTags: ["pet"] }], excludePathRegex: [{ excludePathRegex: "^/pet" }], excludeRedundantZodSchemas: bool("excludeRedundantZodSchemas"), + includeSchemas: [{ includeSchemas: ["ReportPayloadDto", "Pet"] }], tsPath: [{ tsPath: "@/openapi" }], importPath: ["ts", "relative", "absolute"].map((importPath) => ({ importPath })) as Partial[], configs: [ @@ -90,11 +91,6 @@ export const optionCases = { })), abilityContextImportPath: [{ acl: true, abilityContextImportPath: "@test/ability-context" }], aclCheckImportPath: [{ acl: true, aclCheckImportPath: "@test/acl" }], - builderConfigs: bool("builderConfigs"), - filterParamName: [{ builderConfigs: true, filterParamName: "filters" }], - dataResponseParamNames: [{ builderConfigs: true, dataResponseParamNames: ["results"] }], - dynamicInputsImportPath: [{ builderConfigs: true, dynamicInputsImportPath: "@test/inputs" }], - dynamicColumnsImportPath: [{ builderConfigs: true, dynamicColumnsImportPath: "@test/columns" }], } satisfies Record[]>; export const lifecycleOptions = [ diff --git a/src/commands/check.command.ts b/src/commands/check.command.ts index 7be74954..f93a5c91 100644 --- a/src/commands/check.command.ts +++ b/src/commands/check.command.ts @@ -31,6 +31,9 @@ class CheckOptions implements CheckParams { @YargOption({ envAlias: "excludeRedundantZodSchemas", type: "boolean" }) excludeRedundantZodSchemas?: boolean; + @YargOption({ envAlias: "includeSchemas" }) + includeSchemas?: string; + @YargOption({ envAlias: "verbose", default: false, type: "boolean" }) verbose?: boolean; } diff --git a/src/commands/generate.command.ts b/src/commands/generate.command.ts index 814ac8a9..7f98393b 100644 --- a/src/commands/generate.command.ts +++ b/src/commands/generate.command.ts @@ -49,6 +49,9 @@ class GenerateOptions implements GenerateParams { @YargOption({ envAlias: "excludeRedundantZodSchemas", type: "boolean" }) excludeRedundantZodSchemas?: boolean; + @YargOption({ envAlias: "includeSchemas" }) + includeSchemas?: string; + @YargOption({ envAlias: "importPath" }) importPath?: "ts" | "relative" | "absolute"; @@ -124,9 +127,6 @@ class GenerateOptions implements GenerateParams { @YargOption({ envAlias: "axiosRequestConfig", type: "boolean" }) axiosRequestConfig?: boolean; - @YargOption({ envAlias: "builderConfigs", type: "boolean" }) - builderConfigs?: boolean; - @YargOption({ envAlias: "format", default: true, type: "boolean" }) format?: boolean; diff --git a/src/commands/generate.ts b/src/commands/generate.ts index 1cf395b7..cf9fe8ca 100644 --- a/src/commands/generate.ts +++ b/src/commands/generate.ts @@ -40,7 +40,6 @@ export type GenerateParams = { | "mutationScope" | "parseRequestParams" | "inlineEndpoints" - | "builderConfigs" | "modelsOnly" > >; diff --git a/src/config.ts b/src/config.ts index 5b249652..4facb011 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,16 @@ export { OpenApiRouter } from "./lib/config/router.context"; export { OpenApiQueryConfig } from "./lib/config/queryConfig.context"; -export type { InvalidationMap, InvalidationMapFunc, QueryModule } from "./lib/config/queryConfig.context"; +export type { + InvalidationMap, + InvalidationMapFunc, + MergedMutationData, + MergedMutationValues, + MergedMutationVariables, + MutationEffectsOf, + MutationEffectsTypes, + QueryModule, + RegisteredQueryModule, +} from "./lib/config/queryConfig.context"; export { OpenApiWorkspaceContext, useWorkspaceContext } from "./lib/config/workspace.context"; export { configureTranslations, ns, resources } from "./lib/config/i18n"; export type { TranslateFunction } from "./lib/config/i18n"; diff --git a/src/errors.ts b/src/errors.ts index 1181212c..3a80f920 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,3 +1,9 @@ export { ApplicationException, ErrorHandler, SharedErrorHandler, DomainErrorRegistry } from "./lib/rest/error-handling"; -export type { GeneralErrorCodes, ErrorHandlerOptions, ErrorEntry, DomainErrorEntry } from "./lib/rest/error-handling"; +export type { + GeneralErrorCodes, + ErrorHandlerOptions, + ErrorEntry, + DomainErrorEntry, + ErrorRethrower, +} from "./lib/rest/error-handling"; export { RestUtils } from "./lib/rest/rest.utils"; diff --git a/src/generators/const/buildConfigs.const.ts b/src/generators/const/buildConfigs.const.ts deleted file mode 100644 index 40be5375..00000000 --- a/src/generators/const/buildConfigs.const.ts +++ /dev/null @@ -1 +0,0 @@ -export const BUILD_CONFIG_SUFFIX = "config"; diff --git a/src/generators/const/deps.const.ts b/src/generators/const/deps.const.ts index 25482f88..df039a7a 100644 --- a/src/generators/const/deps.const.ts +++ b/src/generators/const/deps.const.ts @@ -27,10 +27,6 @@ export const ABILITY_CONTEXT_IMPORT: Import = { bindings: [ABILITY_CONTEXT], from: ACL_PACKAGE_IMPORT_PATH, }; -export const BUILDERS_UTILS = { - dynamicInputs: "dynamicInputs", - dynamicColumns: "dynamicColumns", -}; // QueryModules export const QUERY_MODULE_ENUM = "QueryModule"; diff --git a/src/generators/const/options.const.ts b/src/generators/const/options.const.ts index f74f6a5a..310f4cec 100644 --- a/src/generators/const/options.const.ts +++ b/src/generators/const/options.const.ts @@ -16,6 +16,7 @@ export const DEFAULT_GENERATE_OPTIONS: GenerateOptions = { excludeTags: [], excludePathRegex: "", excludeRedundantZodSchemas: true, + includeSchemas: [], tsNamespaces: true, treeShakeableNamespaces: false, tsPath: "@/data", @@ -37,10 +38,6 @@ export const DEFAULT_GENERATE_OPTIONS: GenerateOptions = { outputFileNameSuffix: "acl", namespaceSuffix: "Acl", }, - [GenerateType.Configs]: { - outputFileNameSuffix: "configs", - namespaceSuffix: "Configs", - }, }, baseUrl: "", modelsOnly: false, @@ -90,9 +87,4 @@ export const DEFAULT_GENERATE_OPTIONS: GenerateOptions = { abilityContextImportPath: "", aclCheckImportPath: "@povio/openapi-codegen-cli/acl", // Builder Configs options - builderConfigs: false, - filterParamName: "filter", - dataResponseParamNames: ["data", "items"], - dynamicInputsImportPath: "@povio/ui", - dynamicColumnsImportPath: "@povio/ui", }; diff --git a/src/generators/core/resolveConfig.ts b/src/generators/core/resolveConfig.ts index ae846917..e296e36e 100644 --- a/src/generators/core/resolveConfig.ts +++ b/src/generators/core/resolveConfig.ts @@ -2,24 +2,28 @@ import { DEFAULT_GENERATE_OPTIONS } from "@/generators/const/options.const"; import { GenerateOptions } from "@/generators/types/options"; import { deepMerge } from "@/generators/utils/object.utils"; +type ListParams = + | "includeTags" + | "excludeTags" + | "includeSchemas" + | "inlineEndpointsExcludeModules" + | "workspaceContext"; + +/** CLI options, where list options are comma-separated strings. */ +export type ConfigParams = Partial & Record>; + export function resolveConfig({ fileConfig = {}, - params: { includeTags, excludeTags, inlineEndpointsExcludeModules, workspaceContext, ...options }, + params: { includeTags, excludeTags, includeSchemas, inlineEndpointsExcludeModules, workspaceContext, ...options }, }: { fileConfig?: Partial | null; - params: Partial< - Omit & { - includeTags: string; - excludeTags: string; - inlineEndpointsExcludeModules: string; - workspaceContext: string; - } - >; + params: ConfigParams; }) { const resolvedConfig = deepMerge(DEFAULT_GENERATE_OPTIONS, fileConfig ?? {}, { ...options, includeTags: includeTags?.split(","), excludeTags: excludeTags?.split(","), + includeSchemas: includeSchemas?.split(","), inlineEndpointsExcludeModules: inlineEndpointsExcludeModules?.split(","), workspaceContext: workspaceContext?.split(","), }); diff --git a/src/generators/core/zod/getZodSchemasFromOpenAPIDoc.ts b/src/generators/core/zod/getZodSchemasFromOpenAPIDoc.ts index 90dc1e43..47ec5c4e 100644 --- a/src/generators/core/zod/getZodSchemasFromOpenAPIDoc.ts +++ b/src/generators/core/zod/getZodSchemasFromOpenAPIDoc.ts @@ -8,12 +8,14 @@ export function getZodSchemasFromOpenAPIDoc(resolver: SchemaResolver, profiler?: const p = profiler ?? new Profiler(false); const zodSchemas = {} as Record; const enumZodSchemas = {} as Record; + const includedSchemaRefs = getIncludedSchemaRefs(resolver); Object.entries(resolver.openApiDoc.components?.schemas ?? {}).forEach(([name, schema]) => p.runSync("zod.extract.schemaEntry", () => { const schemaData = resolver.getSchemaDataByName(name); if ( resolver.options.excludeRedundantZodSchemas && + !includedSchemaRefs.has(`#/components/schemas/${name}`) && (!schemaData || (schemaData.deepRefOperations.length === 0 && schemaData.tags.length === 0)) ) { return; @@ -47,6 +49,17 @@ export function getZodSchemasFromOpenAPIDoc(resolver: SchemaResolver, profiler?: return { zodSchemas, enumZodSchemas }; } +/** The `includeSchemas` components and every schema they depend on. */ +function getIncludedSchemaRefs(resolver: SchemaResolver) { + const refs = new Set(); + for (const name of resolver.options.includeSchemas) { + const ref = `#/components/schemas/${name}`; + refs.add(ref); + resolver.dependencyGraph.deepDependencyGraph[ref]?.forEach((dependency) => refs.add(dependency)); + } + return refs; +} + export function getEnumZodSchemasFromOpenAPIDoc(resolver: SchemaResolver) { const enumZodSchemas: EnumZodSchemaData[] = []; diff --git a/src/generators/generate/generateConfigs.test.ts b/src/generators/generate/generateConfigs.test.ts deleted file mode 100644 index c1c1fe36..00000000 --- a/src/generators/generate/generateConfigs.test.ts +++ /dev/null @@ -1,359 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { OpenAPIV3 } from "openapi-types"; - -import { DEFAULT_GENERATE_OPTIONS } from "@/generators/const/options.const"; -import { generateCodeFromOpenAPIDoc } from "@/generators/generateCodeFromOpenAPIDoc"; - -const openApiDoc = { - openapi: "3.0.0", - info: { title: "Builder Configs Test", version: "1.0.0" }, - paths: { - "/items": { - get: { - tags: ["items"], - operationId: "list", - parameters: [ - { name: "page", in: "query", schema: { type: "integer" } }, - { name: "limit", in: "query", schema: { type: "integer" } }, - ], - responses: { - "200": { - description: "OK", - content: { "application/json": { schema: { $ref: "#/components/schemas/ItemsPage" } } }, - }, - }, - }, - post: { - tags: ["items"], - operationId: "createItem", - requestBody: { - required: true, - content: { "application/json": { schema: { $ref: "#/components/schemas/Item" } } }, - }, - responses: { - "201": { - description: "Created", - content: { "application/json": { schema: { $ref: "#/components/schemas/Item" } } }, - }, - }, - }, - }, - }, - components: { - schemas: { - Item: { - type: "object", - properties: { id: { type: "string" } }, - required: ["id"], - }, - ItemsPage: { - type: "object", - properties: { - items: { type: "array", items: { $ref: "#/components/schemas/Item" } }, - page: { type: "integer" }, - totalItems: { type: "integer" }, - limit: { type: "integer" }, - }, - required: ["items", "limit"], - }, - }, - }, -} as OpenAPIV3.Document; - -// Same as openApiDoc, but createItem has a no-conditions ACL ability so checkAcl must be -// called without an argument - tests checking acl:false reuse openApiDoc instead, since -// endpoint.acl metadata is extracted regardless of the acl/checkAcl options. -const openApiDocWithAcl = { - ...openApiDoc, - paths: { - ...openApiDoc.paths, - "/items": { - ...openApiDoc.paths["/items"], - post: { - ...openApiDoc.paths["/items"]!.post, - "x-acl": [{ action: "create", subject: "Item" }], - }, - }, - }, -} as OpenAPIV3.Document; - -describe("generateConfigs builderConfigs", () => { - it("imports the sibling Queries namespace referenced by usePaginate", () => { - const files = generateCodeFromOpenAPIDoc(openApiDoc, { - ...DEFAULT_GENERATE_OPTIONS, - output: "test-output", - builderConfigs: true, - mutationEffects: false, - acl: false, - checkAcl: false, - }); - - const configsFile = files.find((file) => file.fileName.endsWith("/items/items.configs.ts")); - - expect(configsFile?.content).toContain('import { ItemsQueries } from "./items.queries";'); - expect(configsFile?.content).toContain("paginated: ItemsQueries.useList"); - }); - - it("references the QueryModule const enum directly (no typeof) and types options with MutationEffectsOptions", () => { - const files = generateCodeFromOpenAPIDoc(openApiDoc, { - ...DEFAULT_GENERATE_OPTIONS, - output: "test-output", - builderConfigs: true, - mutationEffects: true, - acl: false, - checkAcl: false, - }); - - const configsFile = files.find((file) => file.fileName.endsWith("/items/items.configs.ts")); - - expect(configsFile?.content).toContain( - 'import { useMutationEffects, type MutationEffectsOptions } from "@povio/openapi-codegen-cli/query";', - ); - expect(configsFile?.content).not.toContain('from "@povio/openapi-codegen-cli";'); - expect(configsFile?.content).toContain("useMutationEffects"); - expect(configsFile?.content).not.toContain("useMutationEffects"); - expect(configsFile?.content).toContain("& MutationEffectsOptions)"); - }); - - it("does not emit an unused OpenApiWorkspaceContext import when workspaceContext is empty", () => { - const files = generateCodeFromOpenAPIDoc(openApiDoc, { - ...DEFAULT_GENERATE_OPTIONS, - output: "test-output", - builderConfigs: true, - mutationEffects: true, - acl: false, - checkAcl: false, - workspaceContext: [], - }); - - const configsFile = files.find((file) => file.fileName.endsWith("/items/items.configs.ts")); - - expect(configsFile?.content).not.toContain("OpenApiWorkspaceContext"); - }); - - it("calls runMutationEffects with the mutation result, variables and options, matching renderMutation", () => { - const files = generateCodeFromOpenAPIDoc(openApiDoc, { - ...DEFAULT_GENERATE_OPTIONS, - output: "test-output", - builderConfigs: true, - mutationEffects: true, - acl: false, - checkAcl: false, - }); - - const configsFile = files.find((file) => file.fileName.endsWith("/items/items.configs.ts")); - - expect(configsFile?.content).toContain("await runMutationEffects(resData, variables, options);"); - expect(configsFile?.content).not.toContain("await runMutationEffects();"); - // the wrapping onSuccess must come after ...options, or a caller-provided options.onSuccess - // would silently disable mutation effects by overriding it. - expect(configsFile?.content).toMatch(/\.\.\.options,[\s\S]*onSuccess: async \(resData, variables/); - }); - - it("does not pass an argument to checkAcl when the ability has no conditions", () => { - const files = generateCodeFromOpenAPIDoc(openApiDocWithAcl, { - ...DEFAULT_GENERATE_OPTIONS, - output: "test-output", - builderConfigs: true, - acl: true, - checkAcl: true, - }); - - const configsFile = files.find((file) => file.fileName.endsWith("/items/items.configs.ts")); - - expect(configsFile?.content).toContain("checkAcl(ItemsAcl.canUseCreate());"); - }); -}); - -describe("generateConfigs builderConfigs ACL conditions", () => { - // createRocket's ability has an officeId condition, so checkAcl must be called with the - // matching object - renderMutationContent must reuse the shared renderAclCheckCall helper - // instead of unconditionally forwarding every destructured mutation arg. - const officeScopedDoc = { - openapi: "3.0.0", - info: { title: "Builder Configs ACL Conditions Test", version: "1.0.0" }, - paths: { - "/offices/{officeId}/rockets": { - get: { - tags: ["rockets"], - operationId: "list", - parameters: [ - { name: "officeId", in: "path", required: true, schema: { type: "string" } }, - { name: "page", in: "query", schema: { type: "integer" } }, - { name: "limit", in: "query", schema: { type: "integer" } }, - ], - responses: { - "200": { - description: "OK", - content: { "application/json": { schema: { $ref: "#/components/schemas/RocketsPage" } } }, - }, - }, - }, - post: { - tags: ["rockets"], - operationId: "createRocket", - "x-acl": [{ action: "create", subject: "Rocket", conditions: { officeId: "$params.officeId" } }], - parameters: [{ name: "officeId", in: "path", required: true, schema: { type: "string" } }], - requestBody: { - required: true, - content: { "application/json": { schema: { $ref: "#/components/schemas/Rocket" } } }, - }, - responses: { - "201": { - description: "Created", - content: { "application/json": { schema: { $ref: "#/components/schemas/Rocket" } } }, - }, - }, - }, - }, - }, - components: { - schemas: { - Rocket: { - type: "object", - properties: { id: { type: "string" }, name: { type: "string" } }, - required: ["id"], - }, - RocketsPage: { - type: "object", - properties: { - items: { type: "array", items: { $ref: "#/components/schemas/Rocket" } }, - page: { type: "integer" }, - totalItems: { type: "integer" }, - limit: { type: "integer" }, - }, - required: ["items"], - }, - }, - }, - } as unknown as OpenAPIV3.Document; - - it("passes the matching conditions object to checkAcl when the ability has conditions", () => { - const files = generateCodeFromOpenAPIDoc(officeScopedDoc, { - ...DEFAULT_GENERATE_OPTIONS, - output: "test-output", - builderConfigs: true, - acl: true, - checkAcl: true, - }); - - const configsFile = files.find((file) => file.fileName.endsWith("/rockets/rockets.configs.ts")); - - expect(configsFile?.content).toContain("checkAcl(RocketsAcl.canUseCreate({ officeId }"); - }); -}); - -describe("generateConfigs builderConfigs media upload", () => { - // upload's endpoint function only accepts the DTO - the file itself is uploaded separately - // to uploadInstructions.url, exactly like renderMutation does for *.queries.ts. Passing file - // as a second argument to the endpoint call is a type error: "expected 1 argument, got 2". - const mediaUploadDoc = { - openapi: "3.0.0", - info: { title: "Builder Configs Media Upload Test", version: "1.0.0" }, - paths: { - "/media": { - get: { - tags: ["media"], - operationId: "list", - parameters: [ - { name: "page", in: "query", schema: { type: "integer" } }, - { name: "limit", in: "query", schema: { type: "integer" } }, - ], - responses: { - "200": { - description: "OK", - content: { "application/json": { schema: { $ref: "#/components/schemas/MediaPage" } } }, - }, - }, - }, - post: { - tags: ["media"], - operationId: "upload", - "x-media-upload": true, - requestBody: { - required: true, - content: { "application/json": { schema: { $ref: "#/components/schemas/UploadDto" } } }, - }, - responses: { - "201": { - description: "Created", - content: { "application/json": { schema: { $ref: "#/components/schemas/UploadInstructions" } } }, - }, - }, - }, - }, - }, - components: { - schemas: { - UploadDto: { type: "object", properties: { method: { type: "string" } } }, - UploadInstructions: { type: "object", properties: { url: { type: "string" } } }, - Media: { type: "object", properties: { id: { type: "string" } }, required: ["id"] }, - MediaPage: { - type: "object", - properties: { - items: { type: "array", items: { $ref: "#/components/schemas/Media" } }, - page: { type: "integer" }, - totalItems: { type: "integer" }, - limit: { type: "integer" }, - }, - required: ["items"], - }, - }, - }, - } as unknown as OpenAPIV3.Document; - - it("calls the endpoint with only the DTO and uploads the file separately to uploadInstructions.url", () => { - const files = generateCodeFromOpenAPIDoc(mediaUploadDoc, { - ...DEFAULT_GENERATE_OPTIONS, - output: "test-output", - builderConfigs: true, - acl: false, - checkAcl: false, - }); - - const configsFile = files.find((file) => file.fileName.endsWith("/media/media.configs.ts")); - - expect(configsFile?.content).toContain("const uploadInstructions = await MediaApi.upload(data);"); - expect(configsFile?.content).not.toContain("MediaApi.upload(data, file)"); - expect(configsFile?.content).toContain( - 'const method = (uploadInstructions.method?.toLowerCase() ?? "put") as "put" | "post";', - ); - expect(configsFile?.content).not.toContain("data?.method?.toLowerCase()"); - expect(configsFile?.content).toContain("await axios[method](uploadInstructions.url, dataToSend,"); - expect(configsFile?.content).toContain("return uploadInstructions;"); - }); - - it("imports axios as the default import for the upload call", () => { - const files = generateCodeFromOpenAPIDoc(mediaUploadDoc, { - ...DEFAULT_GENERATE_OPTIONS, - output: "test-output", - builderConfigs: true, - acl: false, - checkAcl: false, - }); - - const configsFile = files.find((file) => file.fileName.endsWith("/media/media.configs.ts")); - - expect(configsFile?.content).toContain('import axios, { type AxiosRequestConfig } from "axios";'); - }); - - it("imports the application client for native upload calls", () => { - const files = generateCodeFromOpenAPIDoc(mediaUploadDoc, { - ...DEFAULT_GENERATE_OPTIONS, - output: "test-output", - restClient: "native", - builderConfigs: true, - acl: false, - checkAcl: false, - }); - - const configsFile = files.find((file) => file.fileName.endsWith("/media/media.configs.ts")); - const queriesFile = files.find((file) => file.fileName.endsWith("/media/media.queries.ts")); - - for (const file of [configsFile, queriesFile]) { - expect(file?.content).toContain('import { AppRestClient } from "@/data/app-rest-client";'); - expect(file?.content).toContain("await AppRestClient.upload("); - expect(file?.content).not.toContain('from "axios"'); - } - }); -}); diff --git a/src/generators/generate/generateConfigs.ts b/src/generators/generate/generateConfigs.ts deleted file mode 100644 index e3f92e14..00000000 --- a/src/generators/generate/generateConfigs.ts +++ /dev/null @@ -1,418 +0,0 @@ -import { ACL_CHECK_HOOK } from "@/generators/const/acl.const"; -import { - AXIOS_DEFAULT_IMPORT_NAME, - AXIOS_REQUEST_CONFIG_TYPE, - getRequestConfigTypeName, -} from "@/generators/const/endpoints.const"; -import { REST_PACKAGE_IMPORT_PATH } from "@/generators/const/package.const"; -import { APP_REST_CLIENT_NAME } from "@/generators/const/deps.const"; -import { - MUTATION_EFFECTS, - QUERY_MODULE_ENUM, - QUERY_OPTIONS_TYPES, - BUILDERS_UTILS, -} from "@/generators/const/deps.const"; -import { ZOD_IMPORT } from "@/generators/const/zod.const"; -import { BuilderConfig, DynamicColumnsConfig, DynamicInputsConfig } from "@/generators/types/builder-config"; -import { Endpoint } from "@/generators/types/endpoint"; -import { GenerateType, GenerateTypeParams, Import } from "@/generators/types/generate"; -import { - getImportedEndpointName, - mapEndpointParamsToFunctionParams, - renderMediaUploadMutationBody, -} from "@/generators/utils/generate/generate.endpoints.utils"; -import { getEndpointsImports } from "@/generators/utils/generate/generate.imports.utils"; -import { getBuilderConfigs } from "@/generators/utils/generate/generate.configs.utils"; -import { renderAclCheckCall } from "@/generators/utils/generate/generate.acl.utils"; -import { getNamespaceName } from "@/generators/utils/namespace.utils"; -import { - getAppRestClientImportPath, - getQueryModulesImportPath, - getQueryTypesImportPath, -} from "@/generators/utils/generate/generate.utils"; -import { getEndpointTag } from "@/generators/utils/tag.utils"; -import { QUERY_HOOKS } from "@/generators/const/queries.const"; - -export function generateConfigs(generateTypeParams: GenerateTypeParams) { - const { configs, hasZodImport, modelsImports, queriesImports, aclImports } = getBuilderConfigs(generateTypeParams); - if (configs.length === 0) { - return; - } - - const { resolver, tag } = generateTypeParams; - - const endpoints = configs - .flatMap((config) => [ - config.create?.mutation, - config.update?.mutation, - config.delete?.mutation, - config.bulkDelete?.mutation, - ]) - .filter((m): m is Endpoint => typeof m !== "string" && m !== undefined); - - const hasMutation = endpoints.length > 0; - const hasAclCheck = resolver.options.checkAcl && endpoints.some((e) => e.acl); - const hasMutationEffects = resolver.options.mutationEffects && hasMutation; - const hasMutationDefaultOnError = resolver.options.mutationDefaultOnError && hasMutation; - const hasAxiosRequestConfig = resolver.options.axiosRequestConfig; - const nativeClient = resolver.options.restClient === "native"; - const hasAxiosDefaultImport = !nativeClient && endpoints.some((e) => e.mediaUpload); - const hasAxiosImport = !nativeClient && (hasAxiosRequestConfig || hasAxiosDefaultImport); - const axiosImport: Import = { - defaultImport: hasAxiosDefaultImport ? AXIOS_DEFAULT_IMPORT_NAME : undefined, - bindings: [], - typeBindings: hasAxiosImport ? [AXIOS_REQUEST_CONFIG_TYPE] : [], - from: "axios", - }; - const nativeImport: Import = { - bindings: [], - typeBindings: nativeClient && hasAxiosRequestConfig ? [getRequestConfigTypeName("native")] : [], - from: REST_PACKAGE_IMPORT_PATH, - }; - const appRestClientImport: Import = { - bindings: [APP_REST_CLIENT_NAME], - from: getAppRestClientImportPath(resolver.options), - }; - - const endpointsImports = getEndpointsImports({ - tag, - endpoints, - options: resolver.options, - }); - - const queryImport: Import = { - bindings: [QUERY_HOOKS.mutation], - from: "@tanstack/react-query", - }; - - const queryTypesImport: Import = { - bindings: [...(hasMutationDefaultOnError ? ["OpenApiQueryConfig"] : [])], - typeBindings: [QUERY_OPTIONS_TYPES.mutation], - from: getQueryTypesImportPath(resolver.options), - }; - - const mutationEffectsImport: Import = { - bindings: [MUTATION_EFFECTS.hookName], - typeBindings: [MUTATION_EFFECTS.optionsType], - from: resolver.options.mutationEffectsImportPath, - }; - - const queryModulesImport: Import = { - bindings: [QUERY_MODULE_ENUM], - from: getQueryModulesImportPath(resolver.options), - }; - - const aclCheckImport: Import = { - bindings: [ACL_CHECK_HOOK], - from: resolver.options.aclCheckImportPath, - }; - - const hasDynamicInputsImport = configs.some( - (config) => config.readAll.filters || config.create?.inputDefs || config.update?.inputDefs, // || config.bulkDelete?.inputDefs, - ); - const dynamicInputsImport: Import = { - bindings: [BUILDERS_UTILS.dynamicInputs], - from: resolver.options.dynamicInputsImportPath, - }; - - const hasDynamicColumnsImport = configs.some((config) => config.readAll.columns); - const dynamicColumnsImport: Import = { - bindings: [BUILDERS_UTILS.dynamicColumns], - from: resolver.options.dynamicColumnsImportPath, - }; - - const lines: string[] = []; - if (hasAxiosImport) { - lines.push(renderImport(axiosImport)); - } - if (nativeImport.typeBindings?.length) lines.push(renderImport(nativeImport)); - if (nativeClient && endpoints.some((endpoint) => endpoint.mediaUpload)) { - lines.push(renderImport(appRestClientImport)); - } - if (hasZodImport) { - lines.push(renderImport(ZOD_IMPORT)); - } - if (hasDynamicInputsImport) { - lines.push(renderImport(dynamicInputsImport)); - } - if (hasDynamicColumnsImport) { - lines.push(renderImport(dynamicColumnsImport)); - } - for (const modelsImport of modelsImports) { - lines.push(renderImport(modelsImport)); - } - for (const queriesImport of queriesImports) { - lines.push(renderImport(queriesImport)); - } - // Endpoints are needed for inlined mutations - for (const endpointsImport of endpointsImports) { - lines.push(renderImport(endpointsImport)); - } - - if (hasMutation) { - lines.push(renderImport(queryImport)); - lines.push(renderImport(queryTypesImport)); - if (hasMutationEffects) { - lines.push(renderImport(queryModulesImport)); - lines.push(renderImport(mutationEffectsImport)); - } - if (hasAclCheck) { - lines.push(renderImport(aclCheckImport)); - } - } - - for (const aclImport of aclImports) { - lines.push(renderImport(aclImport)); - } - lines.push(""); - - if (resolver.options.tsNamespaces) { - lines.push( - `export namespace ${getNamespaceName({ type: GenerateType.Configs, tag, options: resolver.options })} {`, - ); - } - - for (const config of configs) { - lines.push(renderBuilderConfig(config, generateTypeParams)); - lines.push(""); - } - - if (resolver.options.tsNamespaces) { - lines.push("}"); - } - - return lines.join("\n").trimEnd() + "\n"; -} - -function renderImport(importData: Import) { - const namedImports = [ - ...importData.bindings, - ...(importData.typeBindings ?? []).map((binding) => (importData.typeOnly ? binding : `type ${binding}`)), - ]; - const names = [ - ...(importData.defaultImport ? [importData.defaultImport] : []), - ...(namedImports.length > 0 ? [`{ ${namedImports.join(", ")} }`] : []), - ].join(", "); - return `import${importData.typeOnly ? " type" : ""} ${names} from "${importData.from}";`; -} - -function renderInputsConfig(inputsConfig: DynamicInputsConfig) { - const lines: string[] = []; - lines.push("{"); - lines.push(` schema: ${inputsConfig.schema},`); - lines.push(" options: {"); - lines.push(" inputs: {"); - for (const key of Object.keys(inputsConfig.options.inputs)) { - lines.push(` ${key}: true,`); - } - lines.push(" },"); - lines.push(" },"); - lines.push("}"); - return lines.join("\n"); -} - -function renderColumnsConfig(columnsConfig: DynamicColumnsConfig) { - const lines: string[] = []; - lines.push("{"); - lines.push(` schema: ${columnsConfig.schema},`); - lines.push(" options: {"); - lines.push(" columns: {"); - for (const key of Object.keys(columnsConfig.options.columns)) { - lines.push(` ${key}: true,`); - } - lines.push(" },"); - if (columnsConfig.options.sortable) { - lines.push(` sortable: ${columnsConfig.options.sortable},`); - } - lines.push(" },"); - lines.push("}"); - return lines.join("\n"); -} - -function renderMutationContent(resolver: any, endpoint: Endpoint, tag: string) { - const hasAclCheck = resolver.options.checkAcl && endpoint.acl; - const hasMutationEffects = resolver.options.mutationEffects; - const hasMutationDefaultOnError = resolver.options.mutationDefaultOnError; - const hasAxiosRequestConfig = resolver.options.axiosRequestConfig; - const endpointTag = getEndpointTag(endpoint, resolver.options); - - const endpointParams = mapEndpointParamsToFunctionParams(resolver, endpoint, { - includeFileParam: true, - modelNamespaceTag: endpointTag, - }); - - const endpointParamsStr = endpointParams.map((p) => `${p.name}${p.required ? "" : "?"}: ${p.type}`).join("; "); - - const destructuredMutationArgs = endpointParams.map((p) => p.name).join(", "); - // The file itself isn't part of the endpoint function's call signature - it's uploaded - // separately to the URL the endpoint call returns. See renderMediaUploadMutationBody. - const resolvedEndpointArgs = mapEndpointParamsToFunctionParams(resolver, endpoint, { modelNamespaceTag: endpointTag }) - .map((p) => p.name) - .join(", "); - const endpointFunction = getImportedEndpointName(endpoint, resolver.options); - - const mutationVariablesType = endpoint.mediaUpload - ? `{ ${endpointParamsStr}${endpointParamsStr ? "; " : ""}abortController?: AbortController; onUploadProgress?: (progress: { loaded: number; total: number }) => void }` - : `{ ${endpointParamsStr} }`; - - const lines: string[] = []; - lines.push( - `(options?: AppMutationOptions${hasMutationEffects ? ` & ${MUTATION_EFFECTS.optionsType}` : ""}${hasAxiosRequestConfig ? `, config?: ${getRequestConfigTypeName(resolver.options.restClient)}` : ""}) => {`, - ); - if (hasMutationDefaultOnError) { - lines.push(" const queryConfig = OpenApiQueryConfig.useConfig();"); - } - - if (hasMutationEffects) { - lines.push( - ` const { runMutationEffects } = useMutationEffects<${QUERY_MODULE_ENUM}.${endpointTag}>({ currentModule: ${QUERY_MODULE_ENUM}.${tag} });`, - ); - } - if (hasAclCheck) { - lines.push(` const { checkAcl } = ${ACL_CHECK_HOOK}();`); - } - lines.push(""); - lines.push(` return ${QUERY_HOOKS.mutation}({`); - - const mutationFnArg = destructuredMutationArgs - ? `{ ${destructuredMutationArgs}${endpoint.mediaUpload ? `${destructuredMutationArgs ? ", " : ""}abortController, onUploadProgress` : ""} }` - : ""; - - lines.push(` mutationFn: ${endpoint.mediaUpload ? "async " : ""}(${mutationFnArg}) => {`); - if (hasAclCheck) { - lines.push(renderAclCheckCall(resolver, endpoint, undefined, " ")); - } - if (endpoint.mediaUpload) { - lines.push( - ...renderMediaUploadMutationBody({ resolver, endpointFunction, resolvedEndpointArgs }).map( - (line) => ` ${line}`, - ), - ); - } else { - lines.push( - ` return ${endpointFunction}(${destructuredMutationArgs}${hasAxiosRequestConfig ? `${destructuredMutationArgs ? ", " : ""}config` : ""});`, - ); - } - lines.push(" },"); - lines.push(" ...options,"); - if (hasMutationDefaultOnError) { - lines.push(" onError: options?.onError ?? queryConfig.onError,"); - } - if (hasMutationEffects) { - lines.push(" onSuccess: async (resData, variables, onMutateResult, context) => {"); - lines.push(" await runMutationEffects(resData, variables, options);"); - lines.push(" options?.onSuccess?.(resData, variables, onMutateResult, context);"); - lines.push(" },"); - } - lines.push(" });"); - lines.push("}"); - return lines - .map((line) => " " + line) - .join("\n") - .trimStart(); -} - -function renderBuilderConfig(config: BuilderConfig, params: GenerateTypeParams) { - const { resolver, tag } = params; - const lines: string[] = []; - lines.push(`export const ${config.name} = {`); - lines.push(" meta: {"); - lines.push(` title: "${config.title}",`); - lines.push(" },"); - lines.push(" readAll: {"); - if (config.readAll.acl) { - lines.push(` acl: ${config.readAll.acl},`); - } - lines.push(` schema: ${config.readAll.columns.schema},`); - lines.push(` paginated: ${config.readAll.paginated},`); - if (config.readAll.infinite) { - lines.push(` infinite: ${config.readAll.infinite},`); - } - if (config.readAll.filters) { - lines.push(" filters: {"); - lines.push(` schema: ${config.readAll.filters.schema},`); - lines.push( - ` filterDefs: ${BUILDERS_UTILS.dynamicInputs}(${renderInputsConfig(config.readAll.filters)})`, - ); - lines.push(" },"); - } - lines.push(` columns: ${BUILDERS_UTILS.dynamicColumns}(${renderColumnsConfig(config.readAll.columns)}),`); - lines.push(" },"); - - if (config.read) { - lines.push(" read: {"); - if (config.read.acl) { - lines.push(` acl: ${config.read.acl},`); - } - lines.push(` schema: ${config.read.schema},`); - lines.push(` query: ${config.read.query},`); - lines.push(" },"); - } - - if (config.create) { - lines.push(" create: {"); - if (config.create.acl) { - lines.push(` acl: ${config.create.acl},`); - } - if (config.create.schema) { - lines.push(` schema: ${config.create.schema},`); - } - lines.push( - ` mutation: ${typeof config.create.mutation === "string" ? config.create.mutation : renderMutationContent(resolver, config.create.mutation, tag)},`, - ); - if (config.create.inputDefs) { - lines.push(` inputDefs: ${BUILDERS_UTILS.dynamicInputs}(${renderInputsConfig(config.create.inputDefs)})`); - } - lines.push(" },"); - } - - if (config.update) { - lines.push(" update: {"); - if (config.update.acl) { - lines.push(` acl: ${config.update.acl},`); - } - if (config.update.schema) { - lines.push(` schema: ${config.update.schema},`); - } - lines.push( - ` mutation: ${typeof config.update.mutation === "string" ? config.update.mutation : renderMutationContent(resolver, config.update.mutation, tag)},`, - ); - if (config.update.inputDefs) { - lines.push(` inputDefs: ${BUILDERS_UTILS.dynamicInputs}(${renderInputsConfig(config.update.inputDefs)})`); - } - lines.push(" },"); - } - - if (config.delete) { - lines.push(" delete: {"); - if (config.delete.acl) { - lines.push(` acl: ${config.delete.acl},`); - } - lines.push( - ` mutation: ${typeof config.delete.mutation === "string" ? config.delete.mutation : renderMutationContent(resolver, config.delete.mutation, tag)},`, - ); - lines.push(" },"); - } - - if (config.bulkDelete) { - lines.push(" bulkDelete: {"); - if (config.bulkDelete.acl) { - lines.push(` acl: ${config.bulkDelete.acl},`); - } - if (config.bulkDelete.schema) { - lines.push(` schema: ${config.bulkDelete.schema},`); - } - lines.push( - ` mutation: ${typeof config.bulkDelete.mutation === "string" ? config.bulkDelete.mutation : renderMutationContent(resolver, config.bulkDelete.mutation, tag)},`, - ); - if (config.bulkDelete.inputDefs) { - lines.push( - ` inputDefs: ${BUILDERS_UTILS.dynamicInputs}(${renderInputsConfig(config.bulkDelete.inputDefs)})`, - ); - } - lines.push(" },"); - } - - lines.push("};"); - return lines.join("\n"); -} diff --git a/src/generators/generate/generateQueries.test.ts b/src/generators/generate/generateQueries.test.ts index 7104b2d7..120b5aa6 100644 --- a/src/generators/generate/generateQueries.test.ts +++ b/src/generators/generate/generateQueries.test.ts @@ -174,7 +174,6 @@ describe("generateQueries workspaceContext", () => { acl: false, checkAcl: false, mutationEffects: false, - builderConfigs: false, prefetchQueries: false, }); @@ -206,7 +205,6 @@ describe("generateQueries workspaceContext", () => { acl: false, checkAcl: false, mutationEffects: false, - builderConfigs: false, prefetchQueries: false, }); @@ -230,7 +228,6 @@ describe("generateQueries workspaceContext", () => { ...DEFAULT_GENERATE_OPTIONS, output: "test-output", workspaceContext: ["officeId", "positionId", "id"], - builderConfigs: false, prefetchQueries: false, }); @@ -262,7 +259,6 @@ describe("generateQueries workspaceContext", () => { workspaceContext: ["officeId"], acl: false, checkAcl: false, - builderConfigs: false, prefetchQueries: false, }); @@ -291,7 +287,6 @@ describe("generateQueries workspaceContext", () => { ...DEFAULT_GENERATE_OPTIONS, output: "test-output", workspaceContext: ["officeId"], - builderConfigs: false, prefetchQueries: false, }); @@ -318,7 +313,6 @@ describe("generateQueries workspaceContext", () => { acl: false, checkAcl: false, mutationEffects: false, - builderConfigs: false, prefetchQueries: false, }); @@ -330,7 +324,7 @@ describe("generateQueries workspaceContext", () => { expect(queriesFile?.content).toContain( "const { officeId: officeIdWorkspace } = useWorkspaceContext<{ officeId?: string }>();", ); - expect(queriesFile?.content).toContain("mutationFn: ({ officeId, data }) => { "); + expect(queriesFile?.content).toContain("mutationFn: ({ officeId, data }) => {"); expect(queriesFile?.content).toContain("const normalizeOfficeId = officeId ?? officeIdWorkspace;"); expect(queriesFile?.content).toContain("return WorkspaceApi.createPosition(normalizeOfficeId, data)"); }); @@ -342,7 +336,6 @@ describe("generateQueries workspaceContext", () => { acl: false, checkAcl: false, mutationEffects: false, - builderConfigs: false, prefetchQueries: false, }); @@ -361,7 +354,6 @@ describe("generateQueries workspaceContext", () => { checkAcl: false, mutationEffects: false, mutationDefaultOnError: true, - builderConfigs: false, prefetchQueries: false, }); @@ -440,7 +432,6 @@ describe("generateQueries mutationEffects + infiniteQuery", () => { infiniteQueries: true, acl: false, checkAcl: false, - builderConfigs: false, prefetchQueries: false, }); @@ -478,7 +469,6 @@ describe("generateQueries mutationEffects + infiniteQuery", () => { prefetchQueries: true, acl: false, checkAcl: false, - builderConfigs: false, }); const queriesFile = files.find((file) => file.fileName.endsWith("/items/items.queries.ts")); @@ -504,7 +494,6 @@ describe("generateQueries mutationEffects + infiniteQuery", () => { mutationEffectsImportPath: "@/data/useMutationEffects", aclCheckImportPath: "@/data/acl/useAclCheck", zodImportPath: "@/data/zod.extended", - builderConfigs: false, }); const queriesFile = files.find((file) => file.fileName.endsWith("/workspace/workspace.queries.ts")); @@ -525,7 +514,6 @@ describe("generateQueries mutationEffects + infiniteQuery", () => { tsNamespaces: true, inlineEndpoints: false, workspaceContext: [], - builderConfigs: false, }; const tsFiles = generateCodeFromOpenAPIDoc(structuredClone(openApiDoc), options); const nativeFiles = generateFilesFromNativeOpenAPI(JSON.stringify(openApiDoc), false, options); @@ -548,7 +536,6 @@ describe("generateQueries mutationEffects + infiniteQuery", () => { output: "test-output", restClient: "native", axiosRequestConfig: true, - builderConfigs: false, }); const appClient = files.find((file) => file.fileName.endsWith("/app-rest-client.ts")); diff --git a/src/generators/generate/generateQueries.ts b/src/generators/generate/generateQueries.ts index b2bbba84..69112e43 100644 --- a/src/generators/generate/generateQueries.ts +++ b/src/generators/generate/generateQueries.ts @@ -16,6 +16,8 @@ import { } from "@/generators/const/endpoints.const"; import { CONFIG_PACKAGE_IMPORT_PATH, REST_PACKAGE_IMPORT_PATH } from "@/generators/const/package.const"; import { QUERIES_MODULE_NAME, QUERY_HOOKS, QUERY_IMPORT } from "@/generators/const/queries.const"; +import { OpenAPIV3 } from "openapi-types"; + import { SchemaResolver } from "@/generators/core/SchemaResolver.class"; import { Endpoint, EndpointParameter } from "@/generators/types/endpoint"; import { GenerateType, GenerateTypeParams, Import } from "@/generators/types/generate"; @@ -421,8 +423,8 @@ function renderEndpointParamDescription(endpointParam: ReturnType) { const nativeContent = ( @@ -7,7 +11,7 @@ export function generateQueryModules({ resolver, data }: Omit { const endpoints = data.get(tag)?.endpoints; @@ -18,14 +22,46 @@ export function generateQueryModules({ resolver, data }: Omit getQueryName(endpoint, true)), }); }); + // Each module's mutation `data` and `variables`, for `InvalidationMap`. + const mutationModules = modules.filter((module) => module.mutationHooks.length > 0); const lines: string[] = []; + if (mutationModules.length > 0) { + lines.push(`import type { MutationEffectsOf } from "${getQueryTypesImportPath(resolver.options)}";`); + for (const module of mutationModules) { + const from = `${getImportPath(resolver.options, true)}${getTagImportPath({ + type: GenerateType.Queries, + tag: module.tag, + options: resolver.options, + })}`; + lines.push( + resolver.options.tsNamespaces + ? `import type { ${module.namespace} } from "${from}";` + : `import type * as ${module.namespace} from "${from}";`, + ); + } + lines.push(""); + } lines.push("export const enum QueryModule {"); for (const module of modules) { lines.push(` ${module.tag} = "${module.namespace}",`); } lines.push("}"); + lines.push(""); + lines.push("export interface QueryModuleMutations {"); + for (const module of mutationModules) { + const hooks = module.mutationHooks.map((hook) => `typeof ${module.namespace}.${hook}`).join(" | "); + lines.push(` [QueryModule.${module.tag}]: MutationEffectsOf<${hooks}>;`); + } + lines.push("}"); + lines.push(""); + // Registers the types globally, so a bare `InvalidationMap` is keyed and typed by this client. + lines.push("declare global {"); + lines.push(" interface OpenApiCodegenQueryModules { queryModule: QueryModule }"); + lines.push(" interface OpenApiCodegenQueryModuleMutations extends QueryModuleMutations {}"); + lines.push("}"); return lines.join("\n"); } diff --git a/src/generators/generateCodeFromOpenAPIDoc.test.ts b/src/generators/generateCodeFromOpenAPIDoc.test.ts index 81f5e12c..12c25158 100644 --- a/src/generators/generateCodeFromOpenAPIDoc.test.ts +++ b/src/generators/generateCodeFromOpenAPIDoc.test.ts @@ -327,7 +327,6 @@ describe("generateCodeFromOpenAPIDoc - modelsInCommon regressions", () => { ...DEFAULT_GENERATE_OPTIONS, output: "output", modelsInCommon: true, - builderConfigs: false, prefetchQueries: false, mutationEffects: false, } as GenerateOptions; @@ -823,7 +822,6 @@ describe("generateCodeFromOpenAPIDoc - shared parameter schema namespace", () => const files = generateCodeFromOpenAPIDoc(sharedParamSchemaDoc, { ...(DEFAULT_GENERATE_OPTIONS as GenerateOptions), - builderConfigs: true, acl: false, checkAcl: false, }); @@ -833,9 +831,6 @@ describe("generateCodeFromOpenAPIDoc - shared parameter schema namespace", () => const adminQueries = files.find(({ fileName }) => fileName.endsWith("/pushNotificationAdmin/pushNotificationAdmin.queries.ts"), )?.content; - const adminConfigs = files.find(({ fileName }) => - fileName.endsWith("/pushNotificationAdmin/pushNotificationAdmin.configs.ts"), - )?.content; test("api file uses the owning namespace (CommonModels) for the shared parameter schema", () => { expect(adminApi).toBeDefined(); @@ -848,10 +843,4 @@ describe("generateCodeFromOpenAPIDoc - shared parameter schema namespace", () => expect(adminQueries).toContain("CommonModels.TokenFiltersDto"); expect(adminQueries).not.toContain("PushNotificationAdminModels.TokenFiltersDto"); }); - - test("configs file uses the owning namespace (CommonModels) for the shared parameter schema", () => { - expect(adminConfigs).toBeDefined(); - expect(adminConfigs).toContain("CommonModels.TokenFiltersDto"); - expect(adminConfigs).not.toContain("PushNotificationAdminModels.TokenFiltersDto"); - }); }); diff --git a/src/generators/generateCodeFromOpenAPIDoc.ts b/src/generators/generateCodeFromOpenAPIDoc.ts index 9ee31f45..fcb8c2ba 100644 --- a/src/generators/generateCodeFromOpenAPIDoc.ts +++ b/src/generators/generateCodeFromOpenAPIDoc.ts @@ -1,7 +1,6 @@ import { OpenAPIV3 } from "openapi-types"; import { getDataFromOpenAPIDoc } from "./core/getDataFromOpenAPIDoc"; import { generateAcl } from "./generate/generateAcl"; -import { generateConfigs } from "./generate/generateConfigs"; import { generateEndpoints } from "./generate/generateEndpoints"; import { generateModels } from "./generate/generateModels"; import { generateQueries } from "./generate/generateQueries"; @@ -16,6 +15,7 @@ import { getZodExtendedFiles, } from "./utils/generate-files.utils"; import { getTagFileName } from "./utils/generate/generate.utils"; +import { trimTrailingWhitespace } from "./utils/string.utils"; import { shouldInlineEndpointsForTag } from "./utils/tag.utils"; import { Profiler } from "../helpers/profile.helper"; import { getDataFromNativeOpenAPIDoc } from "../native/getDataFromNativeOpenAPIDoc"; @@ -48,14 +48,12 @@ export function generateCodeFromOpenAPIDoc( ...(shouldGenerateEndpoints ? [GenerateType.Endpoints] : []), GenerateType.Queries, ...(resolver.options.acl ? [GenerateType.Acl] : []), - ...(resolver.options.builderConfigs ? [GenerateType.Configs] : []), ]; const generateFunctions: Record string | undefined> = { [GenerateType.Models]: generateModels, [GenerateType.Endpoints]: generateEndpoints, [GenerateType.Queries]: generateQueries, [GenerateType.Acl]: generateAcl, - [GenerateType.Configs]: generateConfigs, }; data.forEach((_, tag) => { @@ -84,5 +82,5 @@ export function generateCodeFromOpenAPIDoc( ); } - return generateFilesData; + return generateFilesData.map((file) => ({ ...file, content: trimTrailingWhitespace(file.content) })); } diff --git a/src/generators/generated-include-schemas.test.ts b/src/generators/generated-include-schemas.test.ts new file mode 100644 index 00000000..4a46e67b --- /dev/null +++ b/src/generators/generated-include-schemas.test.ts @@ -0,0 +1,29 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, test } from "vitest"; +import { parse } from "yaml"; + +import { generateFilesFromNativeOpenAPI } from "../native/generateFilesFromNativeOpenAPI"; + +import { DEFAULT_GENERATE_OPTIONS } from "./const/options.const"; +import { generateCodeFromOpenAPIDoc } from "./generateCodeFromOpenAPIDoc"; +import { GenerateOptions } from "./types/options"; + +// Payloads that only another app consumes are in the components, but no operation uses them. +describe("includeSchemas", () => { + const source = readFileSync("test/renderer-regressions.yaml", "utf8"); + const render = (backend: "typescript" | "native", overrides: Partial) => { + const options: GenerateOptions = { ...DEFAULT_GENERATE_OPTIONS, output: "output", ...overrides }; + const files = + backend === "typescript" + ? generateCodeFromOpenAPIDoc(parse(source), options) + : generateFilesFromNativeOpenAPI(source, true, options); + return (files ?? []).map(({ content }) => content).join("\n"); + }; + + test.each(["typescript", "native"] as const)("generates listed schemas and their dependencies (%s)", (backend) => { + expect(render(backend, {})).not.toContain("ReportPayloadDtoSchema"); + const models = render(backend, { includeSchemas: ["ReportPayloadDto"] }); + expect(models).toContain("export const ReportPayloadDtoSchema"); + expect(models).toContain("export const ReportLineDtoSchema"); + }); +}); diff --git a/src/generators/generated-invalidation-map.test.ts b/src/generators/generated-invalidation-map.test.ts new file mode 100644 index 00000000..384ee1d6 --- /dev/null +++ b/src/generators/generated-invalidation-map.test.ts @@ -0,0 +1,109 @@ +import { spawnSync } from "node:child_process"; +import { mkdtemp, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { describe, expect, test } from "vitest"; +import { parse } from "yaml"; + +import { generateFilesFromNativeOpenAPI } from "../native/generateFilesFromNativeOpenAPI"; + +import { DEFAULT_GENERATE_OPTIONS } from "./const/options.const"; +import { generateCodeFromOpenAPIDoc } from "./generateCodeFromOpenAPIDoc"; +import { GenerateOptions } from "./types/options"; + +const root = process.cwd(); + +// Typed against the generated client: `petId` exists on some pet mutations, `username` on some user ones. +// A bare `InvalidationMap` gets the module keys and types through the generated global registration. +const invalidationMap = ` +import type { InvalidationMap, MergedMutationData, MergedMutationVariables } from "@povio/openapi-codegen-cli/query"; +import { QueryModule, type QueryModuleMutations } from "./data/queryModules"; + +export const invalidationMap: InvalidationMap = { + [QueryModule.pet]: (data, { petId }) => [["pet", petId, data]], + [QueryModule.user]: (_data, variables) => [["user", variables.username]], +}; + +export const withModule: InvalidationMap = invalidationMap; +export const explicitMutations: InvalidationMap = invalidationMap; + +// Request-body fields only some mutations send are optional too. +declare const nested: MergedMutationVariables<{ data: { projectId: string } } | { id: string; data: { name: string } }>; +export const projectId: string | undefined = nested.data.projectId; + +// Responses merge the same way, and \`void\` (no variables or body) becomes \`undefined\`. +declare const response: MergedMutationData<{ data: { id: string } } | { count: number } | void>; +export const responseCount: number | undefined = response?.count; +export const noResponse: MergedMutationData = undefined; + +export const unknownVariable: InvalidationMap = { + // @ts-expect-error -- no pet mutation has \`nope\` + [QueryModule.pet]: (_data, { nope }) => [[nope]], +}; + +export const wrongType: InvalidationMap = { + // @ts-expect-error -- \`petId\` is a number + [QueryModule.pet]: (_data, { petId }) => [[petId?.toUpperCase()]], +}; +`; + +describe("generated QueryModuleMutations", () => { + test.each( + [true, false].flatMap((tsNamespaces) => + (["typescript", "native"] as const).map((backend) => ({ tsNamespaces, backend })), + ), + )( + "types invalidation map callbacks ($backend, namespaces: $tsNamespaces)", + async ({ tsNamespaces, backend }) => { + const output = await mkdtemp(join(tmpdir(), "invalidation-map-")); + try { + const source = await readFile(join(root, "test/petstore.yaml"), "utf8"); + const options: GenerateOptions = { + ...DEFAULT_GENERATE_OPTIONS, + output: join(output, "data"), + tsNamespaces, + importPath: "relative", + }; + const files = + backend === "typescript" + ? generateCodeFromOpenAPIDoc(parse(source), options) + : generateFilesFromNativeOpenAPI(source, true, options); + expect(files).toBeDefined(); + for (const file of files ?? []) { + await mkdir(dirname(file.fileName), { recursive: true }); + await writeFile(file.fileName, file.content); + } + await symlink(join(root, "node_modules"), join(output, "node_modules"), "dir"); + await writeFile(join(output, "invalidationMap.ts"), invalidationMap); + await writeFile( + join(output, "tsconfig.json"), + JSON.stringify({ + compilerOptions: { + strict: true, + noEmit: true, + skipLibCheck: true, + target: "esnext", + module: "preserve", + jsx: "react-jsx", + paths: { + "@povio/openapi-codegen-cli": [join(root, "src/index.ts")], + "@povio/openapi-codegen-cli/*": [join(root, "src/*.ts")], + }, + }, + include: ["**/*.ts"], + exclude: ["node_modules"], + }), + ); + + const typecheck = spawnSync("bun", [join(root, "node_modules/typescript/bin/tsc"), "-p", "tsconfig.json"], { + cwd: output, + encoding: "utf8", + }); + expect(typecheck.status, `${typecheck.stdout}\n${typecheck.stderr}`).toBe(0); + } finally { + await rm(output, { recursive: true, force: true }); + } + }, + 60_000, + ); +}); diff --git a/src/generators/generated-trailing-whitespace.test.ts b/src/generators/generated-trailing-whitespace.test.ts new file mode 100644 index 00000000..fb889a13 --- /dev/null +++ b/src/generators/generated-trailing-whitespace.test.ts @@ -0,0 +1,34 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, test } from "vitest"; +import { parse } from "yaml"; + +import { generateFilesFromNativeOpenAPI } from "../native/generateFilesFromNativeOpenAPI"; + +import { DEFAULT_GENERATE_OPTIONS } from "./const/options.const"; +import { generateCodeFromOpenAPIDoc } from "./generateCodeFromOpenAPIDoc"; +import { GenerateOptions } from "./types/options"; + +// Formatters such as `bun fmt` keep whitespace inside comments, so generated files must not contain any. +describe("generated files have no trailing whitespace", () => { + test.each( + ["test/petstore.yaml", "test/configuration.yaml"].flatMap((input) => + [{}, { tsNamespaces: false }, { modelsInCommon: true, checkAcl: true, infiniteQueries: true }].flatMap( + (overrides) => (["typescript", "native"] as const).map((backend) => ({ input, overrides, backend })), + ), + ), + )("$input ($backend, $overrides)", ({ input, overrides, backend }) => { + const source = readFileSync(input, "utf8"); + const options: GenerateOptions = { ...DEFAULT_GENERATE_OPTIONS, output: "output", ...overrides }; + const files = + backend === "typescript" + ? generateCodeFromOpenAPIDoc(parse(source), options) + : generateFilesFromNativeOpenAPI(source, true, options); + expect(files?.length).toBeGreaterThan(0); + const linesWithTrailingWhitespace = (files ?? []).flatMap(({ fileName, content }) => + content + .split("\n") + .flatMap((line, index) => (/[ \t]$/.test(line) ? [`${fileName}:${index + 1}: ${JSON.stringify(line)}`] : [])), + ); + expect(linesWithTrailingWhitespace).toEqual([]); + }); +}); diff --git a/src/generators/run/generate.runner.ts b/src/generators/run/generate.runner.ts index 1915f72e..27caedfc 100644 --- a/src/generators/run/generate.runner.ts +++ b/src/generators/run/generate.runner.ts @@ -2,7 +2,7 @@ import fs from "fs"; import path from "path"; import { OpenAPIV3 } from "openapi-types"; -import { resolveConfig } from "@/generators/core/resolveConfig"; +import { type ConfigParams, resolveConfig } from "@/generators/core/resolveConfig"; import { GenerateFileFormatter } from "@/generators/types/generate"; import { GenerateOptions } from "@/generators/types/options"; import { removeStaleGeneratedFiles, writeGenerateFileData } from "@/generators/utils/file.utils"; @@ -22,14 +22,7 @@ export async function runGenerate({ profiler = new Profiler(process.env.OPENAPI_CODEGEN_PROFILE === "1"), }: { fileConfig?: Partial | null; - params?: Partial< - Omit & { - includeTags: string; - excludeTags: string; - inlineEndpointsExcludeModules: string; - workspaceContext: string; - } - >; + params?: ConfigParams; formatGeneratedFile?: GenerateFileFormatter; profiler?: Profiler; }) { diff --git a/src/generators/types/builder-config.ts b/src/generators/types/builder-config.ts deleted file mode 100644 index 6e908383..00000000 --- a/src/generators/types/builder-config.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { Endpoint } from "./endpoint"; - -export interface DynamicInputsConfig { - schema: string; - options: { - inputs: Record; - }; -} - -export interface DynamicColumnsConfig { - schema: string; - options: { - columns: Record; - sortable?: string; - }; -} - -export interface BuilderConfig { - name: string; - title: string; - readAll: { - acl?: string; - paginated: string; - infinite?: string; - filters?: DynamicInputsConfig; - columns: DynamicColumnsConfig; - }; - read?: { - acl?: string; - schema: string; - query: string; - }; - create?: { - acl?: string; - schema: string; - mutation: string | Endpoint; - inputDefs?: DynamicInputsConfig; - }; - update?: { - acl?: string; - schema: string; - mutation: string | Endpoint; - inputDefs?: DynamicInputsConfig; - }; - delete?: { - acl?: string; - mutation: string | Endpoint; - }; - bulkDelete?: { - acl?: string; - schema: string; - mutation: string | Endpoint; - inputDefs?: DynamicInputsConfig; - }; -} diff --git a/src/generators/types/common.ts b/src/generators/types/common.ts index ec089e6d..05e3c48e 100644 --- a/src/generators/types/common.ts +++ b/src/generators/types/common.ts @@ -1,7 +1,6 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -export type ObjectLiteral = Record; +export type ObjectLiteral = Record; -export type List = ReadonlyArray; +export type List = ReadonlyArray; export type Length = L["length"]; diff --git a/src/generators/types/generate.ts b/src/generators/types/generate.ts index e6328516..80f49604 100644 --- a/src/generators/types/generate.ts +++ b/src/generators/types/generate.ts @@ -29,7 +29,6 @@ export enum GenerateType { Endpoints = "endpoints", Queries = "queries", Acl = "acl", - Configs = "configs", } export type GenerateData = Map< diff --git a/src/generators/types/openapi.ts b/src/generators/types/openapi.ts index f1c4d6cf..beee82dd 100644 --- a/src/generators/types/openapi.ts +++ b/src/generators/types/openapi.ts @@ -1,9 +1,8 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import { OpenAPIV3 } from "openapi-types"; export type CompositeType = "oneOf" | "anyOf" | "allOf" | "enum" | "array" | "empty-object" | "object" | "record"; -export type SingleType = Exclude; +export type SingleType = Exclude; export type PrimitiveType = "string" | "number" | "integer" | "boolean"; @@ -16,8 +15,8 @@ export interface OperationAclInfo { interface OperationObjectAdditions { "x-acl"?: OperationAclInfo[]; - "x-media-upload"?: any; - "x-media-download"?: any; + "x-media-upload"?: unknown; + "x-media-download"?: unknown; } export type OperationObject = OpenAPIV3.OperationObject; diff --git a/src/generators/types/options.ts b/src/generators/types/options.ts index 51508b6e..033f270a 100644 --- a/src/generators/types/options.ts +++ b/src/generators/types/options.ts @@ -60,14 +60,6 @@ interface ACLGenerateOptions { aclCheckImportPath: string; } -interface BuilderConfigsGenerateOptions { - builderConfigs?: boolean; - filterParamName: string; - dataResponseParamNames: string[]; - dynamicInputsImportPath: string; - dynamicColumnsImportPath: string; -} - interface GenerateConfig { outputFileNameSuffix: string; namespaceSuffix: string; @@ -84,6 +76,8 @@ interface BaseGenerateOptions { excludeTags: string[]; excludePathRegex: string; excludeRedundantZodSchemas: boolean; + /** Component schemas to generate even when no included operation uses them, with their dependencies. */ + includeSchemas: string[]; tsNamespaces: boolean; treeShakeableNamespaces?: boolean; tsPath: string; @@ -101,5 +95,4 @@ export interface GenerateOptions EndpointsGenerateOptions, QueriesGenerateOptions, InfiniteQueriesGenerateOptions, - ACLGenerateOptions, - BuilderConfigsGenerateOptions {} + ACLGenerateOptions {} diff --git a/src/generators/utils/generate/generate.configs.utils.ts b/src/generators/utils/generate/generate.configs.utils.ts deleted file mode 100644 index f93ba5db..00000000 --- a/src/generators/utils/generate/generate.configs.utils.ts +++ /dev/null @@ -1,361 +0,0 @@ -import { OpenAPIV3 } from "openapi-types"; - -import { BUILD_CONFIG_SUFFIX } from "@/generators/const/buildConfigs.const"; -import { JSON_APPLICATION_FORMAT } from "@/generators/const/endpoints.const"; -import { ANY_SCHEMA } from "@/generators/const/zod.const"; -import { SchemaResolver } from "@/generators/core/SchemaResolver.class"; -import { BuilderConfig } from "@/generators/types/builder-config"; -import { Endpoint, EndpointParameter, ExtendedEndpoint } from "@/generators/types/endpoint"; -import { GenerateTypeParams } from "@/generators/types/generate"; -import { GenerateOptions } from "@/generators/types/options"; -import { - getPathSegments, - isBulkDeleteEndpoint, - isCreateEndpoint, - isDeleteEndpoint, - isPathSegmentParam, - isReadAllEndpoint, - isReadEndpoint, - isUpdateEndpoint, -} from "@/generators/utils/endpoint.utils"; -import { isReferenceObject } from "@/generators/utils/openapi-schema.utils"; -import { camelToSpaceSeparated, capitalize, kebabToCamel } from "@/generators/utils/string.utils"; -import { isNamedZodSchema } from "@/generators/utils/zod-schema.utils"; - -import { getImportedAbilityFunctionName } from "./generate.acl.utils"; -import { getEndpointBody } from "./generate.endpoints.utils"; -import { - getAclImports, - getInfiniteQueriesImports, - getModelsImports, - getQueriesImports, - mergeImports, -} from "./generate.imports.utils"; -import { getImportedInfiniteQueryName, getImportedQueryName } from "./generate.query.utils"; -import { getImportedZodSchemaName } from "./generate.zod.utils"; - -export function getBuilderConfigs({ data, tag, resolver }: GenerateTypeParams) { - const endpoints = data.get(tag)?.endpoints; - if (!endpoints || endpoints.length === 0) { - return { - configs: [], - hasZodImport: false, - modelsImports: [], - queriesImports: [], - aclImports: [], - }; - } - - const extendedEndpoints: ExtendedEndpoint[] = endpoints.map((endpoint) => ({ - ...endpoint, - pathSegments: getPathSegments(endpoint.path), - })); - const readAllEndpoints = extendedEndpoints.filter((endpoint) => isReadAllEndpoint(endpoint, resolver.options)); - const namedReadAllEndpoints = resolveBuilderConfigNames(readAllEndpoints); - - let hasZodImport = false; - const importedZodSchemas: string[] = []; - const importedEndpoints: Endpoint[] = []; - const importedInfiniteEndpoints: Endpoint[] = []; - - const configs = Object.entries(namedReadAllEndpoints).map(([name, readAllEndpoint]) => { - // Read all config - const columnsConfig = getColumnsConfig(resolver, readAllEndpoint); - if (!columnsConfig) { - return; - } - - importedEndpoints.push(readAllEndpoint); - importedInfiniteEndpoints.push(readAllEndpoint); - - if (columnsConfig.zodSchema) { - if (isNamedZodSchema(columnsConfig.zodSchema)) { - importedZodSchemas.push(columnsConfig.zodSchema); - } else { - hasZodImport = true; - } - } - if (columnsConfig.sortableEnumSchemaName) { - importedZodSchemas.push(columnsConfig.sortableEnumSchemaName); - } - - const filter = readAllEndpoint.parameters.find((param) => param.name === resolver.options.filterParamName); - if (filter) { - importedZodSchemas.push(filter.zodSchema); - } - - const config: BuilderConfig = { - name, - title: capitalize(camelToSpaceSeparated(name.replace(/config$/i, ""))), - readAll: { - acl: getAclConfig(readAllEndpoint, resolver.options), - paginated: getImportedQueryName(readAllEndpoint, resolver.options), - infinite: resolver.options.infiniteQueries - ? getImportedInfiniteQueryName(readAllEndpoint, resolver.options) - : undefined, - filters: getInputsConfig(resolver, filter), - columns: columnsConfig.columns, - }, - }; - - // Read config - const readEndpoint = extendedEndpoints.find((endpoint) => isReadEndpoint(endpoint, readAllEndpoint)); - if (readEndpoint) { - importedEndpoints.push(readEndpoint); - - let responseSchema = readEndpoint.response; - if (isNamedZodSchema(responseSchema)) { - importedZodSchemas.push(responseSchema); - responseSchema = getImportedZodSchemaName(resolver, responseSchema); - } else { - hasZodImport = true; - } - - config.read = { - acl: getAclConfig(readEndpoint, resolver.options), - schema: responseSchema, - query: getImportedQueryName(readEndpoint, resolver.options), - }; - } - - // Create config - const createEndpoint = extendedEndpoints.find((endpoint) => isCreateEndpoint(endpoint, readAllEndpoint)); - if (createEndpoint) { - importedEndpoints.push(createEndpoint); - - const body = getEndpointBody(createEndpoint); - if (body) { - importedZodSchemas.push(body.zodSchema); - - config.create = { - acl: getAclConfig(createEndpoint, resolver.options), - schema: getImportedZodSchemaName(resolver, body.zodSchema), - mutation: createEndpoint, - inputDefs: getInputsConfig(resolver, body), - }; - } - } - - // Update config - const updateEndpoint = extendedEndpoints.find((endpoint) => isUpdateEndpoint(endpoint, readAllEndpoint)); - if (updateEndpoint) { - importedEndpoints.push(updateEndpoint); - - const body = getEndpointBody(updateEndpoint); - if (body) { - importedZodSchemas.push(body.zodSchema); - - config.update = { - acl: getAclConfig(updateEndpoint, resolver.options), - schema: getImportedZodSchemaName(resolver, body.zodSchema), - mutation: updateEndpoint, - inputDefs: getInputsConfig(resolver, body), - }; - } - } - - // Delete config - const deleteEndpoint = extendedEndpoints.find((endpoint) => isDeleteEndpoint(endpoint, readAllEndpoint)); - if (deleteEndpoint) { - importedEndpoints.push(deleteEndpoint); - - const body = getEndpointBody(deleteEndpoint); - if (body) { - importedZodSchemas.push(body.zodSchema); - - config.delete = { - acl: getAclConfig(deleteEndpoint, resolver.options), - mutation: deleteEndpoint, - }; - } - } - - // Bulk delete config - const bulkDeleteEndpoint = extendedEndpoints.find((endpoint) => isBulkDeleteEndpoint(endpoint, readAllEndpoint)); - if (bulkDeleteEndpoint) { - importedEndpoints.push(bulkDeleteEndpoint); - - const body = getEndpointBody(bulkDeleteEndpoint); - if (body) { - importedZodSchemas.push(body.zodSchema); - - config.bulkDelete = { - acl: getAclConfig(bulkDeleteEndpoint, resolver.options), - schema: getImportedZodSchemaName(resolver, body.zodSchema), - mutation: bulkDeleteEndpoint, - inputDefs: getInputsConfig(resolver, body), - }; - } - } - - return config; - }); - - const modelsImports = getModelsImports({ resolver, tag, zodSchemas: importedZodSchemas }); - const queriesImports = getQueriesImports({ tag, endpoints: importedEndpoints, options: resolver.options }); - const infiniteQueriesImports = resolver.options.infiniteQueries - ? getInfiniteQueriesImports({ - tag, - endpoints: importedInfiniteEndpoints, - options: resolver.options, - }) - : []; - const aclImports = getAclImports({ - tag, - endpoints: importedEndpoints.filter((endpoint) => endpoint.acl), - options: resolver.options, - }); - - return { - configs: configs.filter(Boolean) as BuilderConfig[], - hasZodImport, - modelsImports, - queriesImports: mergeImports(resolver.options, queriesImports, infiniteQueriesImports), - aclImports, - }; -} - -function resolveBuilderConfigNames(endpoints: ExtendedEndpoint[]): Record { - const sortedEndpoints = endpoints - .map((endpoint) => ({ - ...endpoint, - namePathSegments: endpoint.pathSegments.filter((segment) => !isPathSegmentParam(segment)), - })) - .toSorted((a, b) => a.namePathSegments.length - b.namePathSegments.length); - - const namedEndpoints: Record = {}; - for (const endpoint of sortedEndpoints) { - const namePathSegments = endpoint.namePathSegments; - - let name = - namePathSegments.length > 0 - ? `${kebabToCamel(namePathSegments.pop()!)}${capitalize(BUILD_CONFIG_SUFFIX)}` - : BUILD_CONFIG_SUFFIX; - while (namedEndpoints[name]) { - if (namePathSegments.length === 0) { - throw new Error(`Can't uniquely resolve builder config name: ${name}`); - } - name = `${kebabToCamel(namePathSegments.pop()!)}${capitalize(name)}`; - } - namedEndpoints[name] = endpoint; - } - - return namedEndpoints; -} - -function getAclConfig(endpoint: Endpoint, options: GenerateOptions) { - return options.acl && endpoint.acl ? getImportedAbilityFunctionName(endpoint, options) : undefined; -} - -function getInputsConfig(resolver: SchemaResolver, endpointParameter?: EndpointParameter) { - if (!endpointParameter) { - return; - } - - let schema: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject | undefined; - if (endpointParameter.type === "Body") { - schema = endpointParameter.bodyObject?.content?.[JSON_APPLICATION_FORMAT]?.schema; - } else { - schema = endpointParameter.parameterObject?.schema; - } - - if (!schema) { - return; - } - - const schemaObj = resolver.resolveObject(schema); - const inputs = Object.keys(schemaObj?.properties ?? {}).reduce((acc, key) => ({ ...acc, [key]: true }), {}); - return { schema: getImportedZodSchemaName(resolver, endpointParameter.zodSchema), options: { inputs } }; -} - -function resolveReadAllItemZodSchema( - resolver: SchemaResolver, - endpoint: Endpoint, - itemSchema: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject, - itemsPropertyKey: string, -): string { - if (isReferenceObject(itemSchema)) { - return resolver.getZodSchemaNameByRef(itemSchema.$ref); - } - - if (isNamedZodSchema(endpoint.response)) { - return `${getImportedZodSchemaName(resolver, endpoint.response)}.shape.${itemsPropertyKey}.element`; - } - - return ANY_SCHEMA; -} - -function getColumnsConfig(resolver: SchemaResolver, endpoint: Endpoint) { - const endpointResponse = endpoint.responseObject; - if (!endpointResponse) { - return; - } - - const schema = endpointResponse.content?.[JSON_APPLICATION_FORMAT]?.schema; - if (!schema) { - return; - } - - const properties = getSchemaProperties(resolver, schema); - const arrayPropertyKeys = Object.keys(properties).filter( - (key) => resolver.resolveObject(properties[key]).type === "array", - ); - arrayPropertyKeys.sort((a, b) => { - const aIndex = resolver.options.dataResponseParamNames.indexOf(a); - const bIndex = resolver.options.dataResponseParamNames.indexOf(b); - if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex; - if (aIndex !== -1) return -1; - if (bIndex !== -1) return 1; - return a.localeCompare(b); - }); - const propertyKey = arrayPropertyKeys[0]; - if (!propertyKey) { - return; - } - - const dataSchemaObj = resolver.resolveObject(properties[propertyKey]); - if (!dataSchemaObj || dataSchemaObj.type !== "array") { - return; - } - - const itemSchema = dataSchemaObj.items; - const itemSchemaObj = resolver.resolveObject(itemSchema); - if (!itemSchemaObj) { - return; - } - - const zodSchema = resolveReadAllItemZodSchema(resolver, endpoint, itemSchema, propertyKey); - const columns = Object.keys(itemSchemaObj?.properties ?? {}).reduce((acc, key) => ({ ...acc, [key]: true }), {}); - - const sortableEnumSchemaName = endpoint.parameters.find( - (param) => param.parameterSortingEnumSchemaName, - )?.parameterSortingEnumSchemaName; - - return { - columns: { - schema: getImportedZodSchemaName(resolver, zodSchema), - options: { - columns, - sortable: sortableEnumSchemaName ? getImportedZodSchemaName(resolver, sortableEnumSchemaName) : undefined, - }, - }, - zodSchema, - sortableEnumSchemaName, - }; -} - -function getSchemaProperties( - resolver: SchemaResolver, - schema: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject, -): { [name: string]: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject } { - const schemaObj = resolver.resolveObject(schema); - if (schemaObj.properties) { - return schemaObj.properties; - } else if ("allOf" in schemaObj && schemaObj.allOf) { - return schemaObj.allOf.reduce( - (acc, cur) => ({ ...acc, ...getSchemaProperties(resolver, cur) }), - {} as { [name: string]: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject }, - ); - } - return {}; -} diff --git a/src/generators/utils/generate/generate.endpoints.utils.ts b/src/generators/utils/generate/generate.endpoints.utils.ts index 14d8f664..070b598a 100644 --- a/src/generators/utils/generate/generate.endpoints.utils.ts +++ b/src/generators/utils/generate/generate.endpoints.utils.ts @@ -197,9 +197,8 @@ function createEndpointConfig(endpoint: Endpoint) { } /** Renders the body of a media-upload mutationFn: call the endpoint (without the file arg) to - * get upload instructions, then upload the file itself to the returned URL. Shared between - * renderMutation (*.queries.ts) and renderMutationContent (*.configs.ts / builderConfigs) so - * both mutation paths stay in sync. Lines are relative to the caller's own indent. */ + * get upload instructions, then upload the file itself to the returned URL. Lines are relative + * to the caller's own indent. */ export function renderMediaUploadMutationBody({ resolver, endpointFunction, diff --git a/src/generators/utils/generate/generate.imports.utils.ts b/src/generators/utils/generate/generate.imports.utils.ts index 4853fd23..94c7c2b1 100644 --- a/src/generators/utils/generate/generate.imports.utils.ts +++ b/src/generators/utils/generate/generate.imports.utils.ts @@ -9,7 +9,6 @@ import { getEndpointTag } from "@/generators/utils/tag.utils"; import { getAbilityFunctionName } from "./generate.acl.utils"; import { getEndpointName } from "./generate.endpoints.utils"; -import { getInfiniteQueryName, getQueryName } from "./generate.query.utils"; import { getTagImportPath } from "./generate.utils"; import { getZodSchemaInferedTypeName } from "./generate.zod.utils"; @@ -70,44 +69,6 @@ export function getEndpointsImports({ }); } -export function getQueriesImports({ - tag, - endpoints, - options, -}: { - tag: string; - endpoints: Endpoint[]; - options: GenerateOptions; -}) { - return getImports({ - type: GenerateType.Queries, - tag, - entities: endpoints, - getTag: (endpoint) => getEndpointTag(endpoint, options), - getEntityName: getQueryName, - options, - }); -} - -export function getInfiniteQueriesImports({ - tag, - endpoints, - options, -}: { - tag: string; - endpoints: Endpoint[]; - options: GenerateOptions; -}) { - return getImports({ - type: GenerateType.Queries, - tag, - entities: endpoints, - getTag: (endpoint) => getEndpointTag(endpoint, options), - getEntityName: getInfiniteQueryName, - options, - }); -} - export function getAclImports({ tag, endpoints, diff --git a/src/generators/utils/generate/generate.query.utils.ts b/src/generators/utils/generate/generate.query.utils.ts index dc69adad..3d242280 100644 --- a/src/generators/utils/generate/generate.query.utils.ts +++ b/src/generators/utils/generate/generate.query.utils.ts @@ -1,10 +1,6 @@ import { Endpoint } from "@/generators/types/endpoint"; -import { GenerateType } from "@/generators/types/generate"; -import { GenerateOptions } from "@/generators/types/options"; -import { getNamespaceName } from "@/generators/utils/namespace.utils"; import { isMutation, isQuery } from "@/generators/utils/query.utils"; import { capitalize, snakeToCamel } from "@/generators/utils/string.utils"; -import { getEndpointTag } from "@/generators/utils/tag.utils"; const operationNameByEndpoint = new WeakMap(); const capitalizedOperationNameByEndpoint = new WeakMap(); @@ -42,17 +38,3 @@ export const getPrefetchQueryName = (endpoint: Endpoint) => `prefetch${getCapita export const getPrefetchInfiniteQueryName = (endpoint: Endpoint) => `prefetch${getCapitalizedOperationName(endpoint)}Infinite`; - -export const getImportedQueryName = (endpoint: Endpoint, options: GenerateOptions) => { - const namespacePrefix = options.tsNamespaces - ? `${getNamespaceName({ type: GenerateType.Queries, tag: getEndpointTag(endpoint, options), options })}.` - : ""; - return `${namespacePrefix}${getQueryName(endpoint)}`; -}; - -export const getImportedInfiniteQueryName = (endpoint: Endpoint, options: GenerateOptions) => { - const namespacePrefix = options.tsNamespaces - ? `${getNamespaceName({ type: GenerateType.Queries, tag: getEndpointTag(endpoint, options), options })}.` - : ""; - return `${namespacePrefix}${getInfiniteQueryName(endpoint)}`; -}; diff --git a/src/generators/utils/object.utils.ts b/src/generators/utils/object.utils.ts index 29fa7943..47ce60ec 100644 --- a/src/generators/utils/object.utils.ts +++ b/src/generators/utils/object.utils.ts @@ -8,10 +8,10 @@ export function getNested< From extends ObjectLiteral, Return = HasNestedPath extends 1 ? From[Path] : Get, >(obj: From, path: Path): Return { - let target = obj || {}; + let target: unknown = obj || {}; const props = path.split("."); for (let i = 0, len = props.length; i < len; ++i) { - target = target[props[i] as keyof typeof target]; + target = (target as ObjectLiteral)[props[i]]; if (target === undefined || target === null) { break; } @@ -26,7 +26,7 @@ export function pick(obj: T, paths: Object.keys(obj).forEach((key) => { if (!paths.includes(key as K)) return; - result[key as keyof Pick] = obj[key]; + result[key as keyof Pick] = obj[key as K]; }); return result; diff --git a/src/generators/utils/string.utils.ts b/src/generators/utils/string.utils.ts index 824e1a82..9dbec29b 100644 --- a/src/generators/utils/string.utils.ts +++ b/src/generators/utils/string.utils.ts @@ -64,3 +64,6 @@ export const removeWord = (source: string, wordToRemove: string) => { }; export const isString = (str: string | undefined): str is string => str !== undefined; + +/** Removes spaces and tabs at the end of every line (formatters such as `bun fmt` keep them in comments). */ +export const trimTrailingWhitespace = (text: string) => text.replace(/[ \t]+$/gm, ""); diff --git a/src/helpers/cli.helper.ts b/src/helpers/cli.helper.ts index 83523066..e7c6cf71 100644 --- a/src/helpers/cli.helper.ts +++ b/src/helpers/cli.helper.ts @@ -1,6 +1,6 @@ import { styleText } from "node:util"; -/* eslint-disable no-console, @typescript-eslint/no-explicit-any */ +/* eslint-disable no-console */ import type { Prompt as PromptSyncPrompt } from "prompt-sync"; /** @@ -19,9 +19,9 @@ async function prompt(...args: Parameters) { * @param value * @param defaultValue */ -export function logVariable(name: string, value: any, defaultValue?: string | number) { +export function logVariable(name: string, value: unknown, defaultValue?: string | number) { if (defaultValue !== undefined && defaultValue !== value) { - console.log(`${styleText("yellow", `${name}:`.padEnd(20))}${styleText("magenta", value)}`); + console.log(`${styleText("yellow", `${name}:`.padEnd(20))}${styleText("magenta", String(value))}`); } else { console.log(`${`${name}:`.padEnd(20)}${value}`); } diff --git a/src/helpers/yargs.helper.ts b/src/helpers/yargs.helper.ts index 0eda8d53..51b94c0d 100644 --- a/src/helpers/yargs.helper.ts +++ b/src/helpers/yargs.helper.ts @@ -1,6 +1,5 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import "reflect-metadata"; -import type { Options } from "yargs"; +import type { Argv, Options } from "yargs"; interface IOptionProperties extends Options { envAlias?: string; @@ -25,28 +24,33 @@ export function YargOption(properties: IOptionProperties) { }; } -export function getYargsOption(target: any): Record { - const options = Reflect.getMetadata(optionsKey, target.prototype); +export function getYargsOption(target: new () => T): Record { + const options: Record | undefined = Reflect.getMetadata(optionsKey, target.prototype); if (!options) { - throw new Error(`Options for ${(target as any).name} were not defined`); + throw new Error(`Options for ${target.name} were not defined`); } return options; } -export function getBuilder(options: any) { - return async (y: any) => { - return y.options(getYargsOptions(options)).middleware(async (_argv: any) => { - return (await loadYargsConfig(options, _argv as any)) as any; +export function getBuilder(options: new () => T) { + return async (y: Argv) => { + return y.options(getYargsOptions(options)).middleware(async (argv) => { + // yargs replaces argv with what async middleware resolves to. + Object.assign(argv, await loadYargsConfig(options, argv)); }, true); }; } -export function getYargsOptions(target: any): Record { - return Object.entries(getYargsOption(target)).reduce( +function getYargsOptionEntries(target: new () => T): [string, IOptionProperties][] { + return Object.entries(getYargsOption(target) as Record); +} + +export function getYargsOptions(target: new () => T): Record { + return getYargsOptionEntries(target).reduce( (a, [property, options]) => { a[property as keyof T] = Object.fromEntries( Object.entries(options).filter(([optionName]) => !["envAlias", "default"].includes(optionName)), - ); + ) as Options; return a; }, {} as Record, @@ -56,12 +60,9 @@ export function getYargsOptions(target: any): Record { export async function loadYargsConfig(cls: new () => T, _argv: Record): Promise { const argv: T = new cls(); - for (const [name, o] of Object.entries(getYargsOption(cls))) { - argv[name as keyof typeof argv] = - // yargs is always right - _argv[name] ?? - // fallback to default - o.default; + for (const [name, o] of getYargsOptionEntries(cls)) { + // yargs is always right; fall back to the default + argv[name as keyof T] = (_argv[name] ?? o.default) as T[keyof T]; } return argv; diff --git a/src/index.ts b/src/index.ts index e6684fe7..0532f10d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,7 +19,13 @@ export { RestUtils } from "./lib/rest/rest.utils"; // Error handling export { ApplicationException, ErrorHandler, SharedErrorHandler, DomainErrorRegistry } from "./lib/rest/error-handling"; -export type { GeneralErrorCodes, ErrorHandlerOptions, ErrorEntry, DomainErrorEntry } from "./lib/rest/error-handling"; +export type { + GeneralErrorCodes, + ErrorHandlerOptions, + ErrorEntry, + DomainErrorEntry, + ErrorRethrower, +} from "./lib/rest/error-handling"; // React Query types export type { AppQueryOptions, AppMutationOptions, AppInfiniteQueryOptions } from "./lib/react-query.types"; @@ -29,7 +35,17 @@ export type { MutationEffectsOptions } from "./lib/react-query/useMutationEffect // Config export { OpenApiRouter } from "./lib/config/router.context"; export { OpenApiQueryConfig } from "./lib/config/queryConfig.context"; -export type { InvalidationMap, InvalidationMapFunc, QueryModule } from "./lib/config/queryConfig.context"; +export type { + InvalidationMap, + InvalidationMapFunc, + MergedMutationData, + MergedMutationValues, + MergedMutationVariables, + MutationEffectsOf, + MutationEffectsTypes, + QueryModule, + RegisteredQueryModule, +} from "./lib/config/queryConfig.context"; export { OpenApiWorkspaceContext, useWorkspaceContext } from "./lib/config/workspace.context"; // i18n resources (for consumer apps to merge into their i18n config) diff --git a/src/lib/config/queryConfig.context.tsx b/src/lib/config/queryConfig.context.tsx index 84b4e4c3..ba6a233f 100644 --- a/src/lib/config/queryConfig.context.tsx +++ b/src/lib/config/queryConfig.context.tsx @@ -1,15 +1,98 @@ import { createContext, use, useMemo } from "react"; import { PropsWithChildren } from "react"; -import type { QueryKey } from "@tanstack/react-query"; +import type { QueryKey, UseMutationResult } from "@tanstack/react-query"; export type QueryModule = string | number | symbol; -export type InvalidationMapFunc = (data: TData, variables: TVariables) => QueryKey[]; -export type InvalidationMap = Partial< - Record> +// Method syntax keeps parameters bivariant, so callbacks with annotated parameters fit the `unknown` defaults. +export type InvalidationMapFunc = { + invalidate(data: TData, variables: TVariables): QueryKey[]; +}["invalidate"]; + +/** The `data` and `variables` one mutation passes to its module's invalidation callback. */ +export interface MutationEffectsTypes { + data: unknown; + variables: unknown; +} + +/** `data` and `variables` of mutation hooks, e.g. `MutationEffectsOf`. */ +export type MutationEffectsOf = THook extends ( + ...args: never +) => UseMutationResult + ? { data: TData; variables: TVariables } + : never; + +type ObjectMembers = T extends object ? T : never; +type KeysOfUnion = T extends unknown ? keyof T : never; +type ValueOfUnion = T extends unknown ? (K extends keyof T ? T[K] : undefined) : never; +type IsUnion = T extends unknown ? ([TAll] extends [T] ? false : true) : never; +// Objects whose fields merge; arrays, functions and dates are kept as they are. +type MergeableMembers = T extends readonly unknown[] | ((...args: never) => unknown) | Date + ? never + : ObjectMembers; +type MergedValue = + true extends IsUnion> ? MergedObjects> | Exclude> : T; +// `Extract` rather than `keyof T`: a homomorphic mapped type would map each union member separately. +type MergedObjects = { [K in Extract]: MergedValue> } & { + [K in Exclude, keyof T>]?: MergedValue>; +}; + +// `void` (a mutation without variables or response body) is `undefined` at runtime. +type VoidAsUndefined = [void] extends [T] ? Exclude | undefined : T; + +/** + * A union of a module's mutation values as one object: keys some mutations lack become optional, at any + * depth (e.g. fields of request bodies only some mutations send). `void` becomes `undefined`, which is + * what callbacks receive for mutations without one. + */ +export type MergedMutationValues = [ObjectMembers] extends [never] + ? VoidAsUndefined + : MergedObjects> | VoidAsUndefined>; + +/** The variables of a module's mutations as one object; see `MergedMutationValues`. */ +export type MergedMutationVariables = MergedMutationValues; + +/** The responses of a module's mutations as one object; see `MergedMutationValues`. */ +export type MergedMutationData = MergedMutationValues; + +type InvalidationMapFuncFor = InvalidationMapFunc< + MergedMutationData, + MergedMutationVariables >; +declare global { + /** + * Mutation types per module. The generated `queryModules.ts` extends it with `QueryModuleMutations`, so + * `InvalidationMap` types its callbacks from the spec. Global, because interfaces re-exported + * from bundled declaration chunks can't be augmented through the package's entry points. + */ + // oxlint-disable-next-line typescript/no-empty-interface, typescript/no-empty-object-type -- extended by generated code + interface OpenApiCodegenQueryModuleMutations {} + + /** The generated `QueryModule` enum, registered by `queryModules.ts` as `{ queryModule: QueryModule }`. */ + // oxlint-disable-next-line typescript/no-empty-interface, typescript/no-empty-object-type -- extended by generated code + interface OpenApiCodegenQueryModules {} +} + +/** The generated `QueryModule` enum once `queryModules.ts` registers it, else any module key. */ +export type RegisteredQueryModule = OpenApiCodegenQueryModules extends { + queryModule: infer TModule extends QueryModule; +} + ? TModule + : QueryModule; + +/** + * Query keys to invalidate after a module's mutations. Without type arguments it is keyed by the generated + * `QueryModule` and typed by its `QueryModuleMutations`, so a bare `InvalidationMap` is typed from the spec. + */ +export type InvalidationMap< + TQueryModule extends QueryModule = RegisteredQueryModule, + TMutations extends { [K in TQueryModule]?: MutationEffectsTypes } = OpenApiCodegenQueryModuleMutations, +> = { + [K in TQueryModule]?: K extends keyof TMutations ? InvalidationMapFuncFor : InvalidationMapFunc; +}; + export namespace OpenApiQueryConfig { export interface Type { preferUpdate?: boolean; diff --git a/src/lib/react-query.types.ts b/src/lib/react-query.types.ts index de05f536..5ae32974 100644 --- a/src/lib/react-query.types.ts +++ b/src/lib/react-query.types.ts @@ -8,8 +8,7 @@ import { import { ApplicationException, GeneralErrorCodes } from "./rest/error-handling"; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type Function = (...args: any) => any; +type Function = (...args: never) => unknown; type IfAny = 0 extends 1 & T ? Y : N; type IsAny = IfAny; type IsUnknown = IsAny extends never ? (unknown extends T ? Y : N) : N; diff --git a/src/lib/react-query/useMutationEffects.ts b/src/lib/react-query/useMutationEffects.ts index 2df9fcf8..6990f22b 100644 --- a/src/lib/react-query/useMutationEffects.ts +++ b/src/lib/react-query/useMutationEffects.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect } from "react"; import { QueryKey, useQueryClient } from "@tanstack/react-query"; -import { InvalidationMap, OpenApiQueryConfig, QueryModule } from "../config/queryConfig.context"; +import { InvalidationMap, InvalidationMapFunc, OpenApiQueryConfig, QueryModule } from "../config/queryConfig.context"; import { broadcastQueryInvalidation, setupCrossTabListener } from "./cross-tab-invalidation"; export interface MutationEffectsOptions { @@ -44,9 +44,10 @@ export function useMutationEffects item === keyB[index]); const isQueryKeyPrefix = (queryKey: QueryKey, prefixKey: QueryKey) => prefixKey.length <= queryKey.length && prefixKey.every((item, index) => item === queryKey[index]); - const mappedInvalidationKeys = - invalidationMap?.[currentModule]?.(data, variables) ?? - config.invalidationMap?.[currentModule]?.(data, variables); + // Callbacks are typed per module; here the module is only known at runtime. + const invalidate: InvalidationMapFunc | undefined = + invalidationMap?.[currentModule] ?? config.invalidationMap?.[currentModule]; + const mappedInvalidationKeys = invalidate?.(data, variables); const shouldInvalidateQuery = (queryKey: QueryKey) => { const isUpdateKey = updateKeys?.some((key) => isQueryKeyEqual(queryKey, key)); diff --git a/src/lib/rest/error-handling.ts b/src/lib/rest/error-handling.ts index 1a14fa02..b899fd3a 100644 --- a/src/lib/rest/error-handling.ts +++ b/src/lib/rest/error-handling.ts @@ -236,3 +236,6 @@ export class ErrorHandler { // can be used for endpoints that only need general error handling export const SharedErrorHandler = new ErrorHandler({ entries: [] }); + +/** What a REST client needs from an error handler; every `ErrorHandler` satisfies it. */ +export type ErrorRethrower = Pick, "rethrowError">; diff --git a/src/lib/rest/native-rest-client.ts b/src/lib/rest/native-rest-client.ts index 3acc93f2..73ed2768 100644 --- a/src/lib/rest/native-rest-client.ts +++ b/src/lib/rest/native-rest-client.ts @@ -1,6 +1,5 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import { z } from "zod"; -import { ErrorHandler } from "./error-handling"; +import { ErrorRethrower } from "./error-handling"; import { NativeHttpError, @@ -22,7 +21,7 @@ type NativeResult = IsRawRes extends true ? NativeR export class NativeRestClient implements RestTransport { private readonly interceptors: RestTransportInterceptor[]; - private readonly errorHandler?: ErrorHandler; + private readonly errorHandler?: ErrorRethrower; constructor({ config, @@ -36,7 +35,7 @@ export class NativeRestClient implements RestTransport { fetch?: TransportFetch; }; interceptors?: RestTransportInterceptor[]; - errorHandler?: ErrorHandler; + errorHandler?: ErrorRethrower; } = {}) { this.baseURL = config?.baseURL ?? ""; this.headers = config?.headers; diff --git a/src/lib/rest/rest-client.ts b/src/lib/rest/rest-client.ts index 0897b679..f95daea8 100644 --- a/src/lib/rest/rest-client.ts +++ b/src/lib/rest/rest-client.ts @@ -1,14 +1,13 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import axios, { AxiosInstance, AxiosRequestConfig, CreateAxiosDefaults } from "axios"; import { z } from "zod"; -import { ErrorHandler, GeneralErrorCodes, SharedErrorHandler } from "./error-handling"; +import { ErrorRethrower, GeneralErrorCodes, SharedErrorHandler } from "./error-handling"; import { RestClient as IRestClient, RequestConfig, RequestInfo, Response } from "./rest-client.types"; import { RestInterceptor } from "./rest-interceptor"; export class RestClient implements IRestClient { private readonly client: AxiosInstance; - private readonly errorHandler: ErrorHandler; + private readonly errorHandler: ErrorRethrower; constructor({ config, @@ -16,25 +15,26 @@ export class RestClient implements IRestClient { errorHandler, }: { config?: CreateAxiosDefaults; - interceptors?: RestInterceptor[]; - errorHandler?: ErrorHandler; + // Attached with no arguments, so each must take none. + interceptors?: RestInterceptor<[]>[]; + errorHandler?: ErrorRethrower; } = {}) { this.client = axios.create(config); - this.errorHandler = errorHandler ?? (SharedErrorHandler as unknown as ErrorHandler); + this.errorHandler = errorHandler ?? SharedErrorHandler; this.attachInterceptors(interceptors); } - public attachInterceptors(interceptors?: RestInterceptor[], ...args: T) { + public attachInterceptors(interceptors?: RestInterceptor[], ...args: T) { if (interceptors != null) { interceptors.forEach((interceptor) => this.attachInterceptor(interceptor, ...args)); } } - public attachInterceptor(interceptor: RestInterceptor, ...args: T) { + public attachInterceptor(interceptor: RestInterceptor, ...args: T) { interceptor.addInterceptor(this.client, ...args); } - public ejectInterceptor(interceptor: RestInterceptor) { + public ejectInterceptor(interceptor: RestInterceptor) { interceptor.removeInterceptor(this.client); } @@ -49,7 +49,7 @@ export class RestClient implements IRestClient { public async post( requestInfo: RequestInfo, url: string, - data?: any, + data?: unknown, requestConfig?: AxiosRequestConfig & RequestConfig, ): Promise> { return this.makeRequest(requestInfo, { ...requestConfig, method: "post", url, data }); @@ -58,7 +58,7 @@ export class RestClient implements IRestClient { public async patch( requestInfo: RequestInfo, url: string, - data?: any, + data?: unknown, requestConfig?: AxiosRequestConfig & RequestConfig, ): Promise> { return this.makeRequest(requestInfo, { ...requestConfig, method: "patch", url, data }); @@ -67,7 +67,7 @@ export class RestClient implements IRestClient { public async put( requestInfo: RequestInfo, url: string, - data?: any, + data?: unknown, requestConfig?: AxiosRequestConfig & RequestConfig, ): Promise> { return this.makeRequest(requestInfo, { ...requestConfig, method: "put", url, data }); @@ -76,7 +76,7 @@ export class RestClient implements IRestClient { public async delete( requestInfo: RequestInfo, url: string, - data?: any, + data?: unknown, requestConfig?: AxiosRequestConfig & RequestConfig, ): Promise> { return this.makeRequest(requestInfo, { ...requestConfig, method: "delete", url, data }); diff --git a/src/lib/rest/rest-client.types.ts b/src/lib/rest/rest-client.types.ts index eefa2e7c..b349149f 100644 --- a/src/lib/rest/rest-client.types.ts +++ b/src/lib/rest/rest-client.types.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import { AxiosRequestConfig, AxiosResponse } from "axios"; import { z } from "zod"; @@ -22,11 +21,11 @@ export type Response = IsRawRes exten : ZOutput; export interface RestClient { - attachInterceptors(interceptors?: RestInterceptor[], ...args: T): void; + attachInterceptors(interceptors?: RestInterceptor[], ...args: T): void; - attachInterceptor(interceptor: RestInterceptor, ...args: T): void; + attachInterceptor(interceptor: RestInterceptor, ...args: T): void; - ejectInterceptor(interceptor: RestInterceptor): void; + ejectInterceptor(interceptor: RestInterceptor): void; get( requestInfo: RequestInfo, @@ -37,28 +36,28 @@ export interface RestClient { post( requestInfo: RequestInfo, url: string, - data?: any, + data?: unknown, requestConfig?: AxiosRequestConfig & RequestConfig, ): Promise>; patch( requestInfo: RequestInfo, url: string, - data?: any, + data?: unknown, requestConfig?: AxiosRequestConfig & RequestConfig, ): Promise>; put( requestInfo: RequestInfo, url: string, - data?: any, + data?: unknown, requestConfig?: AxiosRequestConfig & RequestConfig, ): Promise>; delete( requestInfo: RequestInfo, url: string, - data?: any, + data?: unknown, requestConfig?: AxiosRequestConfig & RequestConfig, ): Promise>; } diff --git a/src/lib/rest/rest-interceptor.ts b/src/lib/rest/rest-interceptor.ts index 69c7eb20..ef53f869 100644 --- a/src/lib/rest/rest-interceptor.ts +++ b/src/lib/rest/rest-interceptor.ts @@ -1,7 +1,6 @@ import { AxiosInstance } from "axios"; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export class RestInterceptor { +export class RestInterceptor { private interceptorIdMap: { client: AxiosInstance; interceptorId: number }[] = []; constructor(private applyInterceptor: (client: AxiosInstance, ...args: T) => number) {} diff --git a/src/native/configuration-parity.test.ts b/src/native/configuration-parity.test.ts index db85f371..50b069b7 100644 --- a/src/native/configuration-parity.test.ts +++ b/src/native/configuration-parity.test.ts @@ -42,21 +42,16 @@ for (const fixture of parityFixtures) { }); } -test("canonical layouts retain full native rendering and local namespaces use native hybrid", () => { +test("every namespace and model layout renders fully native", () => { vi.stubEnv("OPENAPI_CODEGEN_NATIVE", "1"); const source = readParityFixture(parityFixtures[0]); - for (const [tsNamespaces, modelsInCommon] of [ - [true, true], - [false, false], - ]) { - expect(renderParityCase(source, { name: "full", options: { tsNamespaces, modelsInCommon } }, "native").route).toBe( - "full-native", - ); + for (const tsNamespaces of [true, false]) { + for (const modelsInCommon of [true, false]) { + expect( + renderParityCase(source, { name: "full", options: { tsNamespaces, modelsInCommon } }, "native").route, + ).toBe("full-native"); + } } - expect( - renderParityCase(source, { name: "hybrid", options: { tsNamespaces: true, modelsInCommon: false } }, "native") - .route, - ).toBe("hybrid-native"); }); test("mutation scope include/exclude examples exercise the selected path mutation", () => { diff --git a/src/native/generateFilesFromNativeOpenAPI.test.ts b/src/native/generateFilesFromNativeOpenAPI.test.ts index 2c080133..748b25c1 100644 --- a/src/native/generateFilesFromNativeOpenAPI.test.ts +++ b/src/native/generateFilesFromNativeOpenAPI.test.ts @@ -15,18 +15,18 @@ describe("supportsCompleteNativeRender", () => { expect(supportsCompleteNativeRender(supportedOptions)).toBe(true); }); - it("supports namespace-free module-local models", () => { - expect(supportsCompleteNativeRender({ ...supportedOptions, modelsInCommon: false, tsNamespaces: false })).toBe( - true, - ); + it.each([ + ["module-local models with namespaces", { modelsInCommon: false, tsNamespaces: true }], + ["namespace-free module-local models", { modelsInCommon: false, tsNamespaces: false }], + ["namespace-free common models", { modelsInCommon: true, tsNamespaces: false }], + ["the default options", DEFAULT_GENERATE_OPTIONS], + ])("supports %s", (_name, override) => { + expect(supportsCompleteNativeRender({ ...supportedOptions, ...override })).toBe(true); }); it.each([ - ["module-local models with namespaces", { modelsInCommon: false }], - ["namespace-free common models", { tsNamespaces: false }], ["unsplit output", { splitByTags: false }], ["inline endpoints", { inlineEndpoints: true }], - ["builder configs", { builderConfigs: true }], ["workspace context", { workspaceContext: ["officeId"] }], ])("does not claim full native support for %s", (_name, override) => { expect(supportsCompleteNativeRender({ ...supportedOptions, ...override })).toBe(false); diff --git a/src/native/generateFilesFromNativeOpenAPI.ts b/src/native/generateFilesFromNativeOpenAPI.ts index f7c9245b..5ba78e8e 100644 --- a/src/native/generateFilesFromNativeOpenAPI.ts +++ b/src/native/generateFilesFromNativeOpenAPI.ts @@ -2,6 +2,7 @@ import { GenerateFileData, GenerateType } from "@/generators/types/generate"; import { GenerateOptions } from "@/generators/types/options"; import { getOutputFileName } from "@/generators/utils/file.utils"; import { getTagFileName } from "@/generators/utils/generate/generate.utils"; +import { trimTrailingWhitespace } from "@/generators/utils/string.utils"; import { compileNativeData } from "./native-bindings"; @@ -80,17 +81,11 @@ export function generateFilesFromNativeOpenAPI( } } - return files; + return files.map((file) => ({ ...file, content: trimTrailingWhitespace(file.content) })); } export function supportsCompleteNativeRender(options: GenerateOptions) { - return ( - options.splitByTags && - ((options.modelsInCommon && options.tsNamespaces) || (!options.modelsInCommon && !options.tsNamespaces)) && - !options.inlineEndpoints && - !options.builderConfigs && - (options.workspaceContext?.length ?? 0) === 0 - ); + return options.splitByTags && !options.inlineEndpoints && (options.workspaceContext?.length ?? 0) === 0; } function taggedFile(options: GenerateOptions, tag: string, type: GenerateType, content: string) { diff --git a/src/query.ts b/src/query.ts index aa7d11ea..5bbafc78 100644 --- a/src/query.ts +++ b/src/query.ts @@ -2,4 +2,14 @@ export type { AppQueryOptions, AppMutationOptions, AppInfiniteQueryOptions } fro export { useMutationEffects } from "./lib/react-query/useMutationEffects"; export type { MutationEffectsOptions } from "./lib/react-query/useMutationEffects"; export { OpenApiQueryConfig } from "./lib/config/queryConfig.context"; -export type { InvalidationMap, InvalidationMapFunc, QueryModule } from "./lib/config/queryConfig.context"; +export type { + InvalidationMap, + InvalidationMapFunc, + MergedMutationData, + MergedMutationValues, + MergedMutationVariables, + MutationEffectsOf, + MutationEffectsTypes, + QueryModule, + RegisteredQueryModule, +} from "./lib/config/queryConfig.context"; diff --git a/test/config.ts b/test/config.ts index 531b4c21..82af2ccf 100644 --- a/test/config.ts +++ b/test/config.ts @@ -5,7 +5,6 @@ export const config: OpenAPICodegenConfig = { output: "./output", includeTags: ["auth"], replaceOptionalWithNullish: true, - builderConfigs: true, infiniteQueries: true, }; diff --git a/test/renderer-regressions.yaml b/test/renderer-regressions.yaml new file mode 100644 index 00000000..84742dc5 --- /dev/null +++ b/test/renderer-regressions.yaml @@ -0,0 +1,385 @@ +openapi: 3.0.3 +info: + title: Renderer regressions + version: "1" + description: >- + Shapes from a large production spec where the native and TypeScript renderers used to differ. + Each path or schema notes the difference it covers. +paths: + # An inline path enum with a component enum's values is named by that enum, which is emitted with + # the other component enums, after the endpoint error schemas. + /tracking/providers/{provider}/refresh: + post: + operationId: TrackingController_refreshProvider + tags: [Tracking] + parameters: + - name: provider + in: path + required: true + schema: { type: string, enum: [alpha, beta, gamma] } + responses: + "204": { description: Refreshed } + "400": + description: Not found + content: + application/json: + schema: + type: object + required: [status, code, message] + properties: + status: { type: string, enum: [error] } + code: { type: string, enum: [tracking_not_found] } + message: { type: string } + /tracking/providers/{provider}/sync: + post: + operationId: TrackingController_syncProvider + tags: [Tracking] + parameters: + - name: provider + in: path + required: true + schema: { type: string, enum: [alpha, beta, gamma] } + responses: + "204": { description: Synced } + "400": + description: Invalid + content: + application/json: + schema: + type: object + required: [status, code, message] + properties: + status: { type: string, enum: [error] } + code: { type: string, enum: [invalid_direction] } + message: { type: string } + # Query parameter docs list every schema constraint; the response is owned by Common. + /tracking/labels: + get: + operationId: TrackingController_labels + tags: [Tracking] + parameters: + - name: query + in: query + required: false + schema: { nullable: true, type: string, minLength: 2, maxLength: 200 } + - name: limit + in: query + schema: { type: integer, minimum: 1, maximum: 100, default: 20, example: 10 } + responses: + "200": + description: Labels + content: + application/json: + schema: { $ref: "#/components/schemas/LabelPageDto" } + # Imports follow the first use of each owner: Common's response comes before the local filter. + /accounts/labels: + get: + operationId: AccountsController_labels + tags: [Accounts] + parameters: + - name: direction + in: query + schema: { $ref: "#/components/schemas/DirectionEnum" } + - name: filter + in: query + schema: + nullable: true + allOf: [{ $ref: "#/components/schemas/AccountLabelsFilterDto" }] + responses: + "200": + description: Labels + content: + application/json: + schema: { $ref: "#/components/schemas/LabelPageDto" } + /accounts/items: + get: + operationId: AccountsController_items + tags: [Accounts] + parameters: + - name: direction + in: query + schema: { $ref: "#/components/schemas/DirectionEnum" } + - name: metric + in: query + schema: { type: string, enum: [count, sum] } + responses: + "200": + description: Items + content: + application/json: + schema: { $ref: "#/components/schemas/AccountItemListDto" } + post: + operationId: AccountsController_createItem + tags: [Accounts] + requestBody: + required: true + description: The item to create + content: + application/json: + schema: { $ref: "#/components/schemas/CreateRemarkDto" } + example: '{"position":1}' + responses: + "201": { description: Created } + # Body parameter docs use the media type's schema and example. + /accounts/notes: + post: + operationId: AccountsController_createNote + tags: [Accounts] + requestBody: + content: + application/json: + schema: { type: string, minLength: 1, maxLength: 500 } + example: A note + responses: + "204": { description: Created } + # The extracted `status` enum is owned by the tag of the component that declares it, not by the + # tags whose inline error responses reuse it. + /scanner/results: + get: + operationId: ScannerController_results + tags: [Scanner] + responses: + "200": + description: Results + content: + application/json: + schema: { $ref: "#/components/schemas/ScannerErrorDto" } + # A later endpoint's composite response depends on the component enum with the same values as a + # property's enum, although an earlier endpoint already registered the property's schema. + /quotes/{quoteId}/items: + post: + operationId: QuoteItemsController_create + tags: [Quote Items] + parameters: + - { name: quoteId, in: path, required: true, schema: { type: string } } + responses: + "200": + description: Created + content: + application/json: + schema: { $ref: "#/components/schemas/QuoteItemDto" } + /quotes/{quoteId}/items/bulk: + post: + operationId: QuoteItemsController_createMany + tags: [Quote Items] + parameters: + - { name: quoteId, in: path, required: true, schema: { type: string } } + responses: + "200": + description: Created + content: + application/json: + schema: + type: array + items: { $ref: "#/components/schemas/QuoteItemDto" } + /positions/{positionId}/items: + get: + operationId: PositionItemsController_list + tags: [Position Items] + parameters: + - { name: positionId, in: path, required: true, schema: { type: string } } + responses: + "200": + description: Items + content: + application/json: + schema: { $ref: "#/components/schemas/PositionItemDto" } + # Two component enums share values; neither declaration uses the other. + /messages/outgoing: + get: + operationId: OutgoingMessagesController_list + tags: [Outgoing Messages] + responses: + "200": + description: Messages + content: + application/json: + schema: { $ref: "#/components/schemas/OutgoingMessageDto" } + /messages/incoming: + get: + operationId: IncomingMessagesController_list + tags: [Incoming Messages] + responses: + "200": + description: Messages + content: + application/json: + schema: { $ref: "#/components/schemas/IncomingMessageDto" } + # Recursive properties use `z.lazy`; `additionalProperties: false` adds no index signature. + /folders/tree: + get: + operationId: FoldersController_tree + tags: [Folders] + responses: + "200": + description: Tree + content: + application/json: + schema: { $ref: "#/components/schemas/FolderTreeDto" } + # The native transport types are imported before the rest client used for uploads. + /uploads/{uploadId}: + get: + operationId: UploadsController_get + tags: [Uploads] + parameters: + - { name: uploadId, in: path, required: true, schema: { type: string } } + responses: + "200": + description: Upload + content: + application/json: + schema: { $ref: "#/components/schemas/UploadDto" } + post: + operationId: UploadsController_upload + tags: [Uploads] + x-media-upload: true + parameters: + - { name: uploadId, in: path, required: true, schema: { type: string } } + requestBody: + content: + application/json: + schema: { $ref: "#/components/schemas/UploadRequestDto" } + responses: + "200": + description: Upload instructions + content: + application/json: + schema: { $ref: "#/components/schemas/UploadDto" } +components: + schemas: + ProviderNameEnum: + type: string + enum: [alpha, beta, gamma] + DirectionEnum: + type: string + enum: [Import, Export] + LabelPageDto: + type: object + required: [items, total] + properties: + items: + type: array + items: + type: object + required: [id, label] + properties: + id: { type: string } + label: { type: string } + total: { type: integer } + # Declared before the filter, but has no dependencies, so it sorts after it. + CreateRemarkDto: + type: object + required: [content] + properties: + content: + type: object + properties: + html: { nullable: true, type: string } + json: { nullable: true, type: object, additionalProperties: {} } + position: { nullable: true, type: integer } + # `null` in an enum does not stop it matching DirectionEnum. + AccountLabelsFilterDto: + type: object + properties: + search: { nullable: true, type: string } + direction: { nullable: true, type: string, enum: [Import, Export, null] } + # A top-level array documents its items' properties. + AccountItemListDto: + type: array + items: + type: object + required: [id, name] + properties: + id: { type: string } + name: { type: string, description: The item name } + tags: { type: array, items: { type: string } } + ScannerErrorDto: + type: object + required: [status, message] + properties: + status: { type: string, enum: [error] } + message: { type: string } + ItemTypeAEnum: + type: string + enum: [CHARGE, TEXT, DIVIDER] + ItemTypeBEnum: + type: string + enum: [CHARGE, TEXT, DIVIDER] + # The nullable status matches an extracted enum owned by Common, which the models then import. + QuoteItemDto: + type: object + required: [id, type] + properties: + id: { type: string } + type: { $ref: "#/components/schemas/ItemTypeBEnum" } + determinationStatus: { nullable: true, type: string, enum: [MATCHED, NO_MATCH, null] } + PositionItemDto: + type: object + required: [id, type] + properties: + id: { type: string } + type: { $ref: "#/components/schemas/ItemTypeAEnum" } + determinationStatus: { type: string, enum: [MATCHED, NO_MATCH] } + MessageStatusAEnum: + type: string + enum: [Pending, Uploaded, Failed] + MessageStatusBEnum: + type: string + enum: [Pending, Uploaded, Failed] + OutgoingMessageDto: + type: object + properties: + status: { $ref: "#/components/schemas/MessageStatusAEnum" } + IncomingMessageDto: + type: object + properties: + status: { $ref: "#/components/schemas/MessageStatusBEnum" } + FolderTreeDto: + type: object + required: [id, folders] + properties: + id: { type: string } + folders: + type: array + items: { $ref: "#/components/schemas/FolderTreeDto__schema0" } + FolderTreeDto__schema0: + type: object + required: [id, name, folders] + additionalProperties: false + properties: + id: { type: string } + name: { type: string } + symlink: + nullable: true + type: object + required: [targetId] + additionalProperties: false + properties: + targetId: { type: string } + folders: + type: array + items: { $ref: "#/components/schemas/FolderTreeDto__schema0" } + UploadDto: + type: object + properties: + id: { type: string } + url: { type: string } + UploadRequestDto: + type: object + properties: + fileName: { type: string } + # No operation uses these; `includeSchemas: [ReportPayloadDto]` generates both. + ReportPayloadDto: + type: object + required: [title, lines] + properties: + title: { type: string } + locale: { type: string, enum: [en, de] } + lines: + type: array + items: { $ref: "#/components/schemas/ReportLineDto" } + ReportLineDto: + type: object + properties: + label: { type: string } + amount: { type: number }