From e88bf859352d4a46d0a8c3584d2b5424682c4b5d Mon Sep 17 00:00:00 2001 From: Darsh Patel Date: Mon, 17 Mar 2025 18:33:30 -0700 Subject: [PATCH 1/2] feat: graphql-codegen plugin --- README.md | 150 +++++++++++++++++++++++ package.json | 24 +++- pnpm-lock.yaml | 264 ++++++++++++++++++++++++++++++++++++++++ src/core/generator.ts | 65 ++++++++++ src/core/index.ts | 3 + src/core/manifests.ts | 70 +++++++++++ src/core/plugin.ts | 48 ++++++++ src/index.ts | 11 ++ src/types/index.ts | 98 +++++++++++++++ src/utils/fragments.ts | 73 +++++++++++ src/utils/hashing.ts | 22 ++++ src/utils/index.ts | 3 + src/utils/transforms.ts | 85 +++++++++++++ 13 files changed, 913 insertions(+), 3 deletions(-) create mode 100644 README.md create mode 100644 src/core/generator.ts create mode 100644 src/core/index.ts create mode 100644 src/core/manifests.ts create mode 100644 src/core/plugin.ts create mode 100644 src/types/index.ts create mode 100644 src/utils/fragments.ts create mode 100644 src/utils/hashing.ts create mode 100644 src/utils/index.ts create mode 100644 src/utils/transforms.ts diff --git a/README.md b/README.md new file mode 100644 index 0000000..765a557 --- /dev/null +++ b/README.md @@ -0,0 +1,150 @@ +# GraphQL CodeGen Persisted Query List + +A GraphQL CodeGen plugin for generating persisted query manifests. + +## Installation + +```bash +pnpm add -D graphql-codegen-persisted-query-list +``` + +## Usage + +Add the plugin to your GraphQL CodeGen configuration: + +### Using YAML Config + +```yml +# codegen.yml +generates: + ./generated-gql/persisted-query-manifest/client.json: + plugins: + - graphql-codegen-persisted-query-list + config: + output: client + + ./generated-gql/persisted-query-manifest/server.json: + plugins: + - graphql-codegen-persisted-query-list + config: + output: server + includeAlgorithmPrefix: true # Enable prefixed document identifiers for compliance with GraphQL over HTTP spec +``` + +### Using JavaScript/TypeScript Config + +```typescript +// codegen.ts +import type { CodegenConfig } from '@graphql-codegen/cli'; + +const config: CodegenConfig = { + // ... other config + generates: { + './generated-gql/persisted-query-manifest/client.json': { + documents: ['./client/**/*.{graphql,gql}', './pages/**/*.{graphql,gql}'], + plugins: ['graphql-codegen-persisted-query-list'], + config: { + output: 'client', + }, + }, + './generated-gql/persisted-query-manifest/server.json': { + documents: ['./client/**/*.{graphql,gql}', './pages/**/*.{graphql,gql}'], + plugins: ['graphql-codegen-persisted-query-list'], + config: { + output: 'server', + includeAlgorithmPrefix: true, // Enable prefixed document identifiers for compliance with GraphQL over HTTP spec + }, + } + } +}; + +export default config; +``` + +## Configuration Options + +| Option | Type | Default | Description | +|-------------------------|------------------------|------------|------------------------------------------------------------------------| +| `output` | `'client' \| 'server'` | (required) | Format of the generated manifest | +| `algorithm` | `string` | `'sha256'` | Hash algorithm to use for generating operation IDs | +| `includeAlgorithmPrefix`| `boolean` | `false` | Whether to prefix hashes with algorithm name (e.g., `sha256:abc123...`) | + +## Output Formats + +### Client Format + +The client format provides a simple mapping between operation names and their hashes, making it easy for clients to reference operations by name: + +```json +{ + "GetUser": "abcdef123456...", + "UpdateUser": "fedcba654321..." +} +``` + +With `includeAlgorithmPrefix: true`: + +```json +{ + "GetUser": "sha256:abcdef123456...", + "UpdateUser": "sha256:fedcba654321..." +} +``` + +### Server Format + +The server format is more comprehensive, mapping operation hashes to their complete details (type, name, and body). This is ideal for server-side lookup and validation: + +```json +{ + "format": "apollo-persisted-query-manifest", + "version": 1, + "operations": { + "abcdef123456...": { + "type": "query", + "name": "GetUser", + "body": "query GetUser { user { id name } }" + }, + "fedcba654321...": { + "type": "mutation", + "name": "UpdateUser", + "body": "mutation UpdateUser($id: ID!, $name: String!) { updateUser(id: $id, name: $name) { id name } }" + } + } +} +``` + +With `includeAlgorithmPrefix: true`: + +```json +{ + "format": "apollo-persisted-query-manifest", + "version": 1, + "operations": { + "sha256:abcdef123456...": { + "type": "query", + "name": "GetUser", + "body": "query GetUser { user { id name } }" + }, + "sha256:fedcba654321...": { + "type": "mutation", + "name": "UpdateUser", + "body": "mutation UpdateUser($id: ID!, $name: String!) { updateUser(id: $id, name: $name) { id name } }" + } + } +} +``` + +## How It Works + +The plugin's workflow is straightforward but powerful: + +1. It collects all GraphQL operations from your codebase +2. It extracts and resolves all fragments used in each operation +3. It outputs a manifest file in your chosen format (client or server) + +All generated hashes use the algorithm you specify (default: `sha256`). You can enable the "Prefixed Document Identifier" format (e.g., `sha256:abc123...`) for compliance with the [GraphQL over HTTP specification](https://github.com/graphql/graphql-over-http/blob/52d56fb36d51c17e08a920510a23bdc2f6a720be/spec/Appendix%20A%20--%20Persisted%20Documents.md#sha256-hex-document-identifier) by setting `includeAlgorithmPrefix: true`. + +## License + +MIT \ No newline at end of file diff --git a/package.json b/package.json index f13d2fe..9a9ee1e 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,21 @@ { "name": "@replit/graphql-codegen-persisted-queries", "version": "0.0.1", - "description": "GraphQL codegen plugin to generate persisted query list manifests", - "main": "index.ts", + "description": "GraphQL plugin to generate persisted query manifests", + "main": "dist/index.mjs", + "module": "dist/index.mjs", + "types": "dist/index.d.mts", + "files": [ + "dist", + "README.md" + ], "scripts": { - "build": "tsup" + "build": "tsup", + "dev": "tsup --watch", + "typecheck": "tsc --noEmit", + "lint": "eslint . --ext .ts", + "clean": "rm -rf dist", + "prepare": "npm run build" }, "repository": { "type": "git", @@ -24,14 +35,21 @@ }, "homepage": "https://github.com/replit/graphql-codegen-persisted-queries#readme", "packageManager": "pnpm@8.15.5", + "peerDependencies": { + "@graphql-codegen/plugin-helpers": "^5.0.0", + "graphql": "^16.0.0" + }, "devDependencies": { + "@graphql-codegen/plugin-helpers": "^5.0.0", "@stylistic/eslint-plugin-js": "^4.2.0", "@stylistic/eslint-plugin-ts": "^4.2.0", + "@types/node": "^22.13.10", "@typescript-eslint/eslint-plugin": "^8.26.1", "@typescript-eslint/parser": "^8.26.1", "eslint": "^9.22.0", "eslint-config-prettier": "^10.1.1", "eslint-plugin-prettier": "^5.2.3", + "graphql": "^16.8.1", "prettier": "^3.5.3", "tsup": "^8.4.0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1919ab0..7732267 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,12 +5,18 @@ settings: excludeLinksFromLockfile: false devDependencies: + '@graphql-codegen/plugin-helpers': + specifier: ^5.0.0 + version: 5.1.0(graphql@16.10.0) '@stylistic/eslint-plugin-js': specifier: ^4.2.0 version: 4.2.0(eslint@9.22.0) '@stylistic/eslint-plugin-ts': specifier: ^4.2.0 version: 4.2.0(eslint@9.22.0)(typescript@5.8.2) + '@types/node': + specifier: ^22.13.10 + version: 22.13.10 '@typescript-eslint/eslint-plugin': specifier: ^8.26.1 version: 8.26.1(@typescript-eslint/parser@8.26.1)(eslint@9.22.0)(typescript@5.8.2) @@ -26,6 +32,9 @@ devDependencies: eslint-plugin-prettier: specifier: ^5.2.3 version: 5.2.3(eslint-config-prettier@10.1.1)(eslint@9.22.0)(prettier@3.5.3) + graphql: + specifier: ^16.8.1 + version: 16.10.0 prettier: specifier: ^3.5.3 version: 3.5.3 @@ -333,6 +342,43 @@ packages: levn: 0.4.1 dev: true + /@graphql-codegen/plugin-helpers@5.1.0(graphql@16.10.0): + resolution: {integrity: sha512-Y7cwEAkprbTKzVIe436TIw4w03jorsMruvCvu0HJkavaKMQbWY+lQ1RIuROgszDbxAyM35twB5/sUvYG5oW+yg==} + engines: {node: '>=16'} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + dependencies: + '@graphql-tools/utils': 10.8.6(graphql@16.10.0) + change-case-all: 1.0.15 + common-tags: 1.8.2 + graphql: 16.10.0 + import-from: 4.0.0 + lodash: 4.17.21 + tslib: 2.6.3 + dev: true + + /@graphql-tools/utils@10.8.6(graphql@16.10.0): + resolution: {integrity: sha512-Alc9Vyg0oOsGhRapfL3xvqh1zV8nKoFUdtLhXX7Ki4nClaIJXckrA86j+uxEuG3ic6j4jlM1nvcWXRn/71AVLQ==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + dependencies: + '@graphql-typed-document-node/core': 3.2.0(graphql@16.10.0) + '@whatwg-node/promise-helpers': 1.3.0 + cross-inspect: 1.0.1 + dset: 3.1.4 + graphql: 16.10.0 + tslib: 2.8.1 + dev: true + + /@graphql-typed-document-node/core@3.2.0(graphql@16.10.0): + resolution: {integrity: sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + dependencies: + graphql: 16.10.0 + dev: true + /@humanfs/core@0.19.1: resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -622,6 +668,12 @@ packages: resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} dev: true + /@types/node@22.13.10: + resolution: {integrity: sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==} + dependencies: + undici-types: 6.20.0 + dev: true + /@typescript-eslint/eslint-plugin@8.26.1(@typescript-eslint/parser@8.26.1)(eslint@9.22.0)(typescript@5.8.2): resolution: {integrity: sha512-2X3mwqsj9Bd3Ciz508ZUtoQQYpOhU/kWoUqIf49H8Z0+Vbh6UF/y0OEYp0Q0axOGzaBGs7QxRwq0knSQ8khQNA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -738,6 +790,13 @@ packages: eslint-visitor-keys: 4.2.0 dev: true + /@whatwg-node/promise-helpers@1.3.0: + resolution: {integrity: sha512-486CouizxHXucj8Ky153DDragfkMcHtVEToF5Pn/fInhUUSiCmt9Q4JVBa6UK5q4RammFBtGQ4C9qhGlXU9YbA==} + engines: {node: '>=16.0.0'} + dependencies: + tslib: 2.8.1 + dev: true + /acorn-jsx@5.3.2(acorn@8.14.1): resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -835,6 +894,21 @@ packages: engines: {node: '>=6'} dev: true + /camel-case@4.1.2: + resolution: {integrity: sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==} + dependencies: + pascal-case: 3.1.2 + tslib: 2.8.1 + dev: true + + /capital-case@1.0.4: + resolution: {integrity: sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==} + dependencies: + no-case: 3.0.4 + tslib: 2.8.1 + upper-case-first: 2.0.2 + dev: true + /chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -843,6 +917,38 @@ packages: supports-color: 7.2.0 dev: true + /change-case-all@1.0.15: + resolution: {integrity: sha512-3+GIFhk3sNuvFAJKU46o26OdzudQlPNBCu1ZQi3cMeMHhty1bhDxu2WrEilVNYaGvqUtR1VSigFcJOiS13dRhQ==} + dependencies: + change-case: 4.1.2 + is-lower-case: 2.0.2 + is-upper-case: 2.0.2 + lower-case: 2.0.2 + lower-case-first: 2.0.2 + sponge-case: 1.0.1 + swap-case: 2.0.2 + title-case: 3.0.3 + upper-case: 2.0.2 + upper-case-first: 2.0.2 + dev: true + + /change-case@4.1.2: + resolution: {integrity: sha512-bSxY2ws9OtviILG1EiY5K7NNxkqg/JnRnFxLtKQ96JaviiIxi7djMrSd0ECT9AC+lttClmYwKw53BWpOMblo7A==} + dependencies: + camel-case: 4.1.2 + capital-case: 1.0.4 + constant-case: 3.0.4 + dot-case: 3.0.4 + header-case: 2.0.4 + no-case: 3.0.4 + param-case: 3.0.4 + pascal-case: 3.1.2 + path-case: 3.0.4 + sentence-case: 3.0.4 + snake-case: 3.0.4 + tslib: 2.8.1 + dev: true + /chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -866,6 +972,11 @@ packages: engines: {node: '>= 6'} dev: true + /common-tags@1.8.2: + resolution: {integrity: sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==} + engines: {node: '>=4.0.0'} + dev: true + /concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} dev: true @@ -875,6 +986,21 @@ packages: engines: {node: ^14.18.0 || >=16.10.0} dev: true + /constant-case@3.0.4: + resolution: {integrity: sha512-I2hSBi7Vvs7BEuJDr5dDHfzb/Ruj3FyvFyh7KLilAjNQw3Be+xgqUBA2W6scVEcL0hL1dwPRtIqEPVUCKkSsyQ==} + dependencies: + no-case: 3.0.4 + tslib: 2.8.1 + upper-case: 2.0.2 + dev: true + + /cross-inspect@1.0.1: + resolution: {integrity: sha512-Pcw1JTvZLSJH83iiGWt6fRcT+BjZlCDRVwYLbUcHzv/CRpB7r0MlSrGbIyQvVSNyGnbt7G4AXuyCiDR3POvZ1A==} + engines: {node: '>=16.0.0'} + dependencies: + tslib: 2.8.1 + dev: true + /cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -900,6 +1026,18 @@ packages: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} dev: true + /dot-case@3.0.4: + resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} + dependencies: + no-case: 3.0.4 + tslib: 2.8.1 + dev: true + + /dset@3.1.4: + resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==} + engines: {node: '>=4'} + dev: true + /eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} dev: true @@ -1209,11 +1347,23 @@ packages: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} dev: true + /graphql@16.10.0: + resolution: {integrity: sha512-AjqGKbDGUFRKIRCP9tCKiIGHyriz2oHEbPIbEtcSLSs4YjReZOIPQQWek4+6hjw62H9QShXHyaGivGiYVLeYFQ==} + engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + dev: true + /has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} dev: true + /header-case@2.0.4: + resolution: {integrity: sha512-H/vuk5TEEVZwrR0lp2zed9OCo1uAILMlx0JEMgC26rzyJJ3N1v6XkwHHXJQdR2doSjcGPM6OKPYoJgf0plJ11Q==} + dependencies: + capital-case: 1.0.4 + tslib: 2.8.1 + dev: true + /ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1227,6 +1377,11 @@ packages: resolve-from: 4.0.0 dev: true + /import-from@4.0.0: + resolution: {integrity: sha512-P9J71vT5nLlDeV8FHs5nNxaLbrpfAV5cF5srvbZfpwpcJoM/xZR3hiv+q+SAnuSmuGbXMWud063iIMx/V/EWZQ==} + engines: {node: '>=12.2'} + dev: true + /imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} @@ -1249,11 +1404,23 @@ packages: is-extglob: 2.1.1 dev: true + /is-lower-case@2.0.2: + resolution: {integrity: sha512-bVcMJy4X5Og6VZfdOZstSexlEy20Sr0k/p/b2IlQJlfdKAQuMpiv5w2Ccxb8sKdRUNAG1PnHVHjFSdRDVS6NlQ==} + dependencies: + tslib: 2.8.1 + dev: true + /is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} dev: true + /is-upper-case@2.0.2: + resolution: {integrity: sha512-44pxmxAvnnAOwBg4tHPnkfvgjPwbc5QIsSstNU+YcJ1ovxVzCWpSGosPJOZh/a1tdl81fbgnLc9LLv+x2ywbPQ==} + dependencies: + tslib: 2.8.1 + dev: true + /isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} dev: true @@ -1333,6 +1500,22 @@ packages: resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} dev: true + /lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + dev: true + + /lower-case-first@2.0.2: + resolution: {integrity: sha512-EVm/rR94FJTZi3zefZ82fLWab+GX14LJN4HrWBcuo6Evmsl9hEfnqxgcHCKb9q+mNf6EVdsjx/qucYFIIB84pg==} + dependencies: + tslib: 2.8.1 + dev: true + + /lower-case@2.0.2: + resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} + dependencies: + tslib: 2.8.1 + dev: true + /lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} dev: true @@ -1384,6 +1567,13 @@ packages: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} dev: true + /no-case@3.0.4: + resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} + dependencies: + lower-case: 2.0.2 + tslib: 2.8.1 + dev: true + /object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -1419,6 +1609,13 @@ packages: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} dev: true + /param-case@3.0.4: + resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==} + dependencies: + dot-case: 3.0.4 + tslib: 2.8.1 + dev: true + /parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -1426,6 +1623,20 @@ packages: callsites: 3.1.0 dev: true + /pascal-case@3.1.2: + resolution: {integrity: sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==} + dependencies: + no-case: 3.0.4 + tslib: 2.8.1 + dev: true + + /path-case@3.0.4: + resolution: {integrity: sha512-qO4qCFjXqVTrcbPt/hQfhTQ+VhFsqNKOPtytgNKkKxSoEp3XPUQ8ObFuePylOIok5gjn69ry8XiULxCwot3Wfg==} + dependencies: + dot-case: 3.0.4 + tslib: 2.8.1 + dev: true + /path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -1572,6 +1783,14 @@ packages: hasBin: true dev: true + /sentence-case@3.0.4: + resolution: {integrity: sha512-8LS0JInaQMCRoQ7YUytAo/xUu5W2XnQxV2HI/6uM6U7CITS1RqPElr30V6uIqyMKM9lJGRVFy5/4CuzcixNYSg==} + dependencies: + no-case: 3.0.4 + tslib: 2.8.1 + upper-case-first: 2.0.2 + dev: true + /shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -1589,6 +1808,13 @@ packages: engines: {node: '>=14'} dev: true + /snake-case@3.0.4: + resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==} + dependencies: + dot-case: 3.0.4 + tslib: 2.8.1 + dev: true + /source-map@0.8.0-beta.0: resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==} engines: {node: '>= 8'} @@ -1596,6 +1822,12 @@ packages: whatwg-url: 7.1.0 dev: true + /sponge-case@1.0.1: + resolution: {integrity: sha512-dblb9Et4DAtiZ5YSUZHLl4XhH4uK80GhAZrVXdN4O2P4gQ40Wa5UIOPUHlA/nFd2PLblBZWUioLMMAVrgpoYcA==} + dependencies: + tslib: 2.8.1 + dev: true + /string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -1654,6 +1886,12 @@ packages: has-flag: 4.0.0 dev: true + /swap-case@2.0.2: + resolution: {integrity: sha512-kc6S2YS/2yXbtkSMunBtKdah4VFETZ8Oh6ONSmSd9bRxhqTrtARUCBUiWXH3xVPpvR7tz2CSnkuXVE42EcGnMw==} + dependencies: + tslib: 2.8.1 + dev: true + /synckit@0.9.2: resolution: {integrity: sha512-vrozgXDQwYO72vHjUb/HnFbQx1exDjoKzqx23aXEg2a9VIg2TSFZ8FmeZpTjUCFMYw7mpX4BE2SFu8wI7asYsw==} engines: {node: ^14.18.0 || >=16.0.0} @@ -1687,6 +1925,12 @@ packages: picomatch: 4.0.2 dev: true + /title-case@3.0.3: + resolution: {integrity: sha512-e1zGYRvbffpcHIrnuqT0Dh+gEJtDaxDSoG4JAIpq4oDFyooziLBIiYQv0GBT4FUAnUop5uZ1hiIAj7oAF6sOCA==} + dependencies: + tslib: 2.8.1 + dev: true + /to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -1718,6 +1962,10 @@ packages: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} dev: true + /tslib@2.6.3: + resolution: {integrity: sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==} + dev: true + /tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} dev: true @@ -1778,6 +2026,22 @@ packages: hasBin: true dev: true + /undici-types@6.20.0: + resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==} + dev: true + + /upper-case-first@2.0.2: + resolution: {integrity: sha512-514ppYHBaKwfJRK/pNC6c/OxfGa0obSnAl106u97Ed0I625Nin96KAjttZF6ZL3e1XLtphxnqrOi9iWgm+u+bg==} + dependencies: + tslib: 2.8.1 + dev: true + + /upper-case@2.0.2: + resolution: {integrity: sha512-KgdgDGJt2TpuwBUIjgG6lzw2GWFRCW9Qkfkiv0DxqHHLYJHmtmdUIKcZd8rHgFSjopVTlw6ggzCm1b8MFQwikg==} + dependencies: + tslib: 2.8.1 + dev: true + /uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} dependencies: diff --git a/src/core/generator.ts b/src/core/generator.ts new file mode 100644 index 0000000..5cb48d2 --- /dev/null +++ b/src/core/generator.ts @@ -0,0 +1,65 @@ +import { DocumentNode, visit } from 'graphql'; +import { + PluginConfig, + QueryIdentifierMap +} from '../types'; +import { createHash } from '../utils/hashing'; +import { addTypenameToDocument } from '../utils/transforms'; +import { findFragments, findUsedFragments } from '../utils/fragments'; +import { printDefinitions } from '../utils/transforms'; + +/** + * Generates query identifiers for all operations in the provided documents + * + * @param docs - Array of GraphQL document nodes + * @param config - Plugin configuration + * @returns Map of operation names to their identifiers + * @throws Error if an operation is missing a name + */ +export function generateQueryIds( + docs: DocumentNode[], + config: PluginConfig, +): QueryIdentifierMap { + // Add __typename to all selection sets + const processedDocs = docs.map(addTypenameToDocument); + + const result: QueryIdentifierMap = {}; + const knownFragments = findFragments(processedDocs); + + for (const doc of processedDocs) { + visit(doc, { + OperationDefinition: { + enter(def) { + if (!def.name) { + throw new Error('OperationDefinition missing name'); + } + + const operationName = def.name.value; + const usedFragments = findUsedFragments(def, knownFragments); + + // The order is important here. We want to print the operation definition first, + // then the fragments as they are included. + // Otherwise, the generated hashes won't match with the one generated on the server. + const query = printDefinitions([ + def, + ...Array.from(usedFragments.values()), + ]); + + const hash = createHash(query, config); + const usesVariables = Boolean( + def.variableDefinitions && def.variableDefinitions.length > 0, + ); + + result[operationName] = { + hash, + query, + usesVariables, + loc: doc.loc, + }; + }, + }, + }); + } + + return result; +} \ No newline at end of file diff --git a/src/core/index.ts b/src/core/index.ts new file mode 100644 index 0000000..349311e --- /dev/null +++ b/src/core/index.ts @@ -0,0 +1,3 @@ +export * from './generator'; +export * from './manifests'; +export * from './plugin'; \ No newline at end of file diff --git a/src/core/manifests.ts b/src/core/manifests.ts new file mode 100644 index 0000000..4ce8e9c --- /dev/null +++ b/src/core/manifests.ts @@ -0,0 +1,70 @@ +import { + ClientQueryListManifest, + PersistedQueryManifestOperation, + QueryIdentifierMap, + ServerQueryListManifest +} from '../types'; + +/** + * Generates a client-side persisted query manifest + * Client manifests map operation names to their hash + * + * @param queries - Map of queries with their identifiers + * @returns Client-side manifest object + */ +export function generateClientManifest( + queries: QueryIdentifierMap, +): ClientQueryListManifest { + const manifest: ClientQueryListManifest = {}; + + for (const queryName of Object.keys(queries)) { + manifest[queryName] = queries[queryName].hash; + } + + return manifest; +} + +/** + * Generates a server-side persisted query manifest + * Server manifests map query hashes to operation details + * + * @param queries - Map of queries with their identifiers + * @returns Server-side manifest object + * @throws Error if an operation type cannot be determined + */ +export function generateServerManifest( + queries: QueryIdentifierMap, +): ServerQueryListManifest { + const manifest: ServerQueryListManifest = { + format: 'apollo-persisted-query-manifest', + version: 1, + operations: {}, + }; + + const operations: Record = {}; + + for (const queryName of Object.keys(queries)) { + const query = queries[queryName].query; + let type: 'query' | 'mutation' | 'subscription'; + + // Determine operation type from the query content + if (query.startsWith('query ')) { + type = 'query'; + } else if (query.startsWith('mutation ')) { + type = 'mutation'; + } else if (query.startsWith('subscription ')) { + type = 'subscription'; + } else { + throw new Error('Unknown operation type'); + } + + operations[queries[queryName].hash] = { + type, + name: queryName, + body: query, + }; + } + + manifest.operations = operations; + return manifest; +} \ No newline at end of file diff --git a/src/core/plugin.ts b/src/core/plugin.ts new file mode 100644 index 0000000..a408dab --- /dev/null +++ b/src/core/plugin.ts @@ -0,0 +1,48 @@ +import { DocumentNode } from 'graphql'; +import { PersistedQueryPlugin } from '../types'; +import { generateQueryIds } from './generator'; +import { generateClientManifest, generateServerManifest } from './manifests'; + +/** + * GraphQL CodeGen plugin for generating persisted query manifests + * + * @param _schema - GraphQL schema (unused) + * @param documents - GraphQL documents to process + * @param config - Plugin configuration + * @returns JSON string representation of the generated manifest + * @throws Error if no documents are provided or output format is not specified + * + * For GraphQL over HTTP specification compliance, set `includeAlgorithmPrefix: true` + * to enable the "Prefixed Document Identifier" format (e.g., `sha256:abc123...`). + */ +export const plugin: PersistedQueryPlugin = ( + _schema, + documents, + config, +) => { + // Validate input documents + if ( + !documents || + documents.length === 0 || + documents.some((doc) => !doc?.document) + ) { + throw new Error('Found no documents to generate persisted query ids.'); + } + + // Extract document nodes + const documentNodes: DocumentNode[] = documents + .map((doc) => doc.document) + .filter((doc): doc is DocumentNode => doc !== undefined); + + // Generate query identifiers + const queries = generateQueryIds(documentNodes, config); + + // Generate appropriate manifest format based on configuration + if (config.output === 'client') { + return JSON.stringify(generateClientManifest(queries), null, ' '); + } else if (config.output === 'server') { + return JSON.stringify(generateServerManifest(queries), null, ' '); + } else { + throw new Error("Must configure output to 'server' or 'client'"); + } +}; \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index e69de29..a36e4ab 100644 --- a/src/index.ts +++ b/src/index.ts @@ -0,0 +1,11 @@ +/** + * GraphQL CodeGen Plugin for Persisted Query Lists + * + * The plugin can generate manifests in two formats: + * - Client format: Maps operation names to query hashes + * - Server format: Maps query hashes to full operation details + */ + +export * from './types'; +export * from './core'; +export * from './utils'; \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..850ed97 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,98 @@ +import type { FragmentDefinitionNode, Location, OperationDefinitionNode } from 'graphql'; +import type { PluginFunction } from '@graphql-codegen/plugin-helpers'; + +/** + * Configuration for the persisted query generator plugin + */ +export interface PluginConfig { + /** + * Output format - 'server' generates hash->operation mapping, + * 'client' generates operation->hash mapping + */ + output: 'server' | 'client'; + + /** + * Hash algorithm to use (defaults to 'sha256') + */ + algorithm?: string; + + /** + * Whether to prefix the hash with the algorithm name (e.g. "sha256:abc123...") + * Follows the GraphQL over HTTP specification when enabled + * @see https://github.com/graphql/graphql-over-http/blob/52d56fb36d51c17e08a920510a23bdc2f6a720be/spec/Appendix%20A%20--%20Persisted%20Documents.md#sha256-hex-document-identifier + * @default false + */ + includeAlgorithmPrefix?: boolean; +} + +/** + * Represents a single operation in the persisted query manifest + */ +export interface PersistedQueryManifestOperation { + /** Operation type (query, mutation, subscription) */ + type: 'query' | 'mutation' | 'subscription'; + /** Operation name */ + name: string; + /** Full operation body text */ + body: string; +} + +/** + * Server-side persisted query manifest format + */ +export interface ServerQueryListManifest { + format: 'apollo-persisted-query-manifest'; + version: 1; + operations: Record; +} + +/** + * Client-side persisted query manifest format + */ +export type ClientQueryListManifest = Record; + +/** + * Union type for both manifest formats + */ +export type QueryListManifest = ServerQueryListManifest | ClientQueryListManifest; + +/** + * Internal type for a GraphQL definition + */ +export type Definition = FragmentDefinitionNode | OperationDefinitionNode; + +/** + * Query identifier metadata + */ +export interface QueryIdentifier { + /** + * The document identifier hash, optionally with algorithm prefix (e.g. "sha256:abc123...") + * when includeAlgorithmPrefix is enabled + */ + hash: string; + + /** + * The full query string + */ + query: string; + + /** + * Whether the operation uses variables + */ + usesVariables: boolean; + + /** + * Source location information (if available) + */ + loc?: Location; +} + +/** + * Map of query names to their identifiers + */ +export type QueryIdentifierMap = Record; + +/** + * Plugin function type + */ +export type PersistedQueryPlugin = PluginFunction; diff --git a/src/utils/fragments.ts b/src/utils/fragments.ts new file mode 100644 index 0000000..77daef6 --- /dev/null +++ b/src/utils/fragments.ts @@ -0,0 +1,73 @@ +import { + DocumentNode, + FragmentDefinitionNode, + OperationDefinitionNode, + visit +} from 'graphql'; + +/** + * Extracts all fragment definitions from an array of documents + * + * @param docs - Array of GraphQL document nodes to scan + * @returns A map of fragment names to their definitions + */ +export function findFragments( + docs: (DocumentNode | FragmentDefinitionNode)[], +): Map { + const fragments = new Map(); + + for (const doc of docs) { + visit(doc, { + FragmentDefinition: { + enter(node) { + fragments.set(node.name.value, node); + }, + }, + }); + } + + return fragments; +} + +/** + * Finds all fragments used in an operation or fragment, including nested fragments + * + * @param operation - The operation or fragment to analyze + * @param knownFragments - Map of all available fragments + * @param _usedFragments - Optional accumulator for recursive calls + * @returns Map of all fragments used by the operation + * @throws Error if a referenced fragment cannot be found + */ +export function findUsedFragments( + operation: OperationDefinitionNode | FragmentDefinitionNode, + knownFragments: ReadonlyMap, + _usedFragments?: Map, +): Map { + const usedFragments = _usedFragments + ? _usedFragments + : new Map(); + + visit(operation, { + FragmentSpread: { + enter(node) { + const fragmentName = node.name.value; + const fragment = knownFragments.get(fragmentName); + + if (fragment) { + // Skip if we've already processed this fragment to avoid circular references + if (usedFragments.has(fragmentName)) { + return; + } + + usedFragments.set(fragmentName, fragment); + // Recursively find nested fragments + findUsedFragments(fragment, knownFragments, usedFragments); + } else { + throw new Error(`Unknown fragment: ${fragmentName}`); + } + }, + }, + }); + + return usedFragments; +} \ No newline at end of file diff --git a/src/utils/hashing.ts b/src/utils/hashing.ts new file mode 100644 index 0000000..04de594 --- /dev/null +++ b/src/utils/hashing.ts @@ -0,0 +1,22 @@ +import * as crypto from 'node:crypto'; +import { PluginConfig } from '../types'; + +/** + * Creates a hash for a given string using the specified algorithm + * + * @param content - The content string to hash + * @param config - Plugin configuration + * @returns A hex string hash of the content by default, or a prefixed hash (e.g. "sha256:abc123...") + * when includeAlgorithmPrefix is true for GraphQL over HTTP specification compliance + */ +export function createHash(content: string, config: PluginConfig): string { + const algorithm = config.algorithm || 'sha256'; + + const digest = crypto.createHash(algorithm) + .update(content, 'utf8') + .digest() + .toString('hex'); + + // Only prefix with algorithm if includeAlgorithmPrefix is true + return config.includeAlgorithmPrefix === true ? `${algorithm}:${digest}` : digest; +} \ No newline at end of file diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..96043cb --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,3 @@ +export * from './fragments'; +export * from './hashing'; +export * from './transforms'; \ No newline at end of file diff --git a/src/utils/transforms.ts b/src/utils/transforms.ts new file mode 100644 index 0000000..5b64d56 --- /dev/null +++ b/src/utils/transforms.ts @@ -0,0 +1,85 @@ +import { + DocumentNode, + FieldNode, + Kind, + OperationDefinitionNode, + print, + visit +} from 'graphql'; +import { Definition } from '../types'; + +/** + * Typename field definition to add to selection sets + */ +const TYPENAME_FIELD: FieldNode = { + kind: Kind.FIELD, + name: { + kind: Kind.NAME, + value: '__typename', + }, +}; + +/** + * Prints an array of definitions as a single string + * + * @param definitions - Array of GraphQL definitions to print + * @returns Printed string representation + */ +export function printDefinitions(definitions: (Definition | DocumentNode)[]): string { + return definitions.map(print).join('\n\n'); +} + +/** + * Adds __typename to all selection sets in a document + * Adapted from Apollo Client + * + * @param doc - GraphQL document node + * @returns Document with __typename fields added + */ +export function addTypenameToDocument(doc: DocumentNode): DocumentNode { + return visit(doc, { + SelectionSet: { + enter(node, _key, parent) { + // Don't add __typename to OperationDefinitions + if ( + parent && + (parent as OperationDefinitionNode).kind === 'OperationDefinition' + ) { + return; + } + + // No changes if no selections + const { selections } = node; + if (!selections) { + return; + } + + // Skip if selection already has __typename + const hasTypename = selections.some((selection) => { + return ( + selection.kind === 'Field' && + (selection as FieldNode).name.value === '__typename' + ); + }); + + // Skip if this is an introspection query + const isIntrospection = selections.some((selection) => { + return ( + selection.kind === 'Field' && + (selection as FieldNode).name.value.startsWith('__') + ); + }); + + if (hasTypename || isIntrospection) { + return; + } + + // Create and return a new SelectionSet with a __typename Field + return { + ...node, + selections: [...selections, TYPENAME_FIELD], + }; + }, + }, + }); +} \ No newline at end of file From f27b17de77042d4ec94dcc570214799397c6e7fd Mon Sep 17 00:00:00 2001 From: Darsh Patel Date: Tue, 18 Mar 2025 11:47:15 -0700 Subject: [PATCH 2/2] chore: rename to operations, refactor to combine manifests logic in generator --- src/core/generator.ts | 101 ++++++++++++++++++++++++++++++++---------- src/core/index.ts | 1 - src/core/manifests.ts | 70 ----------------------------- src/core/plugin.ts | 14 +++--- src/types/index.ts | 45 ++++++------------- 5 files changed, 95 insertions(+), 136 deletions(-) delete mode 100644 src/core/manifests.ts diff --git a/src/core/generator.ts b/src/core/generator.ts index 5cb48d2..6f97108 100644 --- a/src/core/generator.ts +++ b/src/core/generator.ts @@ -1,7 +1,10 @@ import { DocumentNode, visit } from 'graphql'; import { - PluginConfig, - QueryIdentifierMap + PluginConfig, + ClientOperationListManifest, + ServerOperationListManifest, + PersistedQueryManifestOperation, + ProcessedOperation } from '../types'; import { createHash } from '../utils/hashing'; import { addTypenameToDocument } from '../utils/transforms'; @@ -9,21 +12,18 @@ import { findFragments, findUsedFragments } from '../utils/fragments'; import { printDefinitions } from '../utils/transforms'; /** - * Generates query identifiers for all operations in the provided documents + * Process documents and generate operation hashes with their details * * @param docs - Array of GraphQL document nodes * @param config - Plugin configuration - * @returns Map of operation names to their identifiers + * @returns Array of processed operations with their details * @throws Error if an operation is missing a name */ -export function generateQueryIds( - docs: DocumentNode[], - config: PluginConfig, -): QueryIdentifierMap { +function processOperations(docs: DocumentNode[], config: PluginConfig): ProcessedOperation[] { // Add __typename to all selection sets const processedDocs = docs.map(addTypenameToDocument); - - const result: QueryIdentifierMap = {}; + const operations: ProcessedOperation[] = []; + const knownFragments = findFragments(processedDocs); for (const doc of processedDocs) { @@ -37,29 +37,82 @@ export function generateQueryIds( const operationName = def.name.value; const usedFragments = findUsedFragments(def, knownFragments); - // The order is important here. We want to print the operation definition first, + // The order is important here. We want to have the operation definition first, // then the fragments as they are included. // Otherwise, the generated hashes won't match with the one generated on the server. - const query = printDefinitions([ - def, - ...Array.from(usedFragments.values()), - ]); + const query = printDefinitions([def, ...usedFragments.values()]); const hash = createHash(query, config); - const usesVariables = Boolean( - def.variableDefinitions && def.variableDefinitions.length > 0, - ); - - result[operationName] = { + + operations.push({ + name: operationName, hash, + type: def.operation, query, - usesVariables, - loc: doc.loc, - }; + definition: def, + fragments: Array.from(usedFragments.values()) + }); }, }, }); } - return result; + return operations; +} + +/** + * Generates a client-side persisted query manifest + * Client manifests map operation names to their hash + * + * @param docs - Array of GraphQL document nodes + * @param config - Plugin configuration + * @returns Client-side manifest object + * @throws Error if an operation is missing a name + */ +export function generateClientManifest( + docs: DocumentNode[], + config: PluginConfig, +): ClientOperationListManifest { + const operations = processOperations(docs, config); + const manifest: ClientOperationListManifest = {}; + + for (const operation of operations) { + manifest[operation.name] = operation.hash; + } + + return manifest; +} + +/** + * Generates a server-side persisted query manifest + * Server manifests map query hashes to operation details + * + * @param docs - Array of GraphQL document nodes + * @param config - Plugin configuration + * @returns Server-side manifest object + * @throws Error if an operation is missing a name + */ +export function generateServerManifest( + docs: DocumentNode[], + config: PluginConfig, +): ServerOperationListManifest { + const operations = processOperations(docs, config); + + const manifest: ServerOperationListManifest = { + format: 'apollo-persisted-query-manifest', + version: 1, + operations: {}, + }; + + for (const operation of operations) { + const operationDetails: PersistedQueryManifestOperation = { + type: operation.type, + name: operation.name, + body: operation.query, + }; + + manifest.operations[operation.hash] = operationDetails; + } + + return manifest; } \ No newline at end of file diff --git a/src/core/index.ts b/src/core/index.ts index 349311e..dead50b 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -1,3 +1,2 @@ export * from './generator'; -export * from './manifests'; export * from './plugin'; \ No newline at end of file diff --git a/src/core/manifests.ts b/src/core/manifests.ts deleted file mode 100644 index 4ce8e9c..0000000 --- a/src/core/manifests.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { - ClientQueryListManifest, - PersistedQueryManifestOperation, - QueryIdentifierMap, - ServerQueryListManifest -} from '../types'; - -/** - * Generates a client-side persisted query manifest - * Client manifests map operation names to their hash - * - * @param queries - Map of queries with their identifiers - * @returns Client-side manifest object - */ -export function generateClientManifest( - queries: QueryIdentifierMap, -): ClientQueryListManifest { - const manifest: ClientQueryListManifest = {}; - - for (const queryName of Object.keys(queries)) { - manifest[queryName] = queries[queryName].hash; - } - - return manifest; -} - -/** - * Generates a server-side persisted query manifest - * Server manifests map query hashes to operation details - * - * @param queries - Map of queries with their identifiers - * @returns Server-side manifest object - * @throws Error if an operation type cannot be determined - */ -export function generateServerManifest( - queries: QueryIdentifierMap, -): ServerQueryListManifest { - const manifest: ServerQueryListManifest = { - format: 'apollo-persisted-query-manifest', - version: 1, - operations: {}, - }; - - const operations: Record = {}; - - for (const queryName of Object.keys(queries)) { - const query = queries[queryName].query; - let type: 'query' | 'mutation' | 'subscription'; - - // Determine operation type from the query content - if (query.startsWith('query ')) { - type = 'query'; - } else if (query.startsWith('mutation ')) { - type = 'mutation'; - } else if (query.startsWith('subscription ')) { - type = 'subscription'; - } else { - throw new Error('Unknown operation type'); - } - - operations[queries[queryName].hash] = { - type, - name: queryName, - body: query, - }; - } - - manifest.operations = operations; - return manifest; -} \ No newline at end of file diff --git a/src/core/plugin.ts b/src/core/plugin.ts index a408dab..61e03b0 100644 --- a/src/core/plugin.ts +++ b/src/core/plugin.ts @@ -1,10 +1,9 @@ import { DocumentNode } from 'graphql'; import { PersistedQueryPlugin } from '../types'; -import { generateQueryIds } from './generator'; -import { generateClientManifest, generateServerManifest } from './manifests'; +import { generateClientManifest, generateServerManifest } from './generator'; /** - * GraphQL CodeGen plugin for generating persisted query manifests + * GraphQL CodeGen plugin for generating persisted operation manifests * * @param _schema - GraphQL schema (unused) * @param documents - GraphQL documents to process @@ -26,7 +25,7 @@ export const plugin: PersistedQueryPlugin = ( documents.length === 0 || documents.some((doc) => !doc?.document) ) { - throw new Error('Found no documents to generate persisted query ids.'); + throw new Error('Found no documents to generate persisted operation ids.'); } // Extract document nodes @@ -34,14 +33,11 @@ export const plugin: PersistedQueryPlugin = ( .map((doc) => doc.document) .filter((doc): doc is DocumentNode => doc !== undefined); - // Generate query identifiers - const queries = generateQueryIds(documentNodes, config); - // Generate appropriate manifest format based on configuration if (config.output === 'client') { - return JSON.stringify(generateClientManifest(queries), null, ' '); + return JSON.stringify(generateClientManifest(documentNodes, config), null, ' '); } else if (config.output === 'server') { - return JSON.stringify(generateServerManifest(queries), null, ' '); + return JSON.stringify(generateServerManifest(documentNodes, config), null, ' '); } else { throw new Error("Must configure output to 'server' or 'client'"); } diff --git a/src/types/index.ts b/src/types/index.ts index 850ed97..dfe9fa4 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,4 +1,4 @@ -import type { FragmentDefinitionNode, Location, OperationDefinitionNode } from 'graphql'; +import type { FragmentDefinitionNode, OperationDefinitionNode, OperationTypeNode } from 'graphql'; import type { PluginFunction } from '@graphql-codegen/plugin-helpers'; /** @@ -30,7 +30,7 @@ export interface PluginConfig { */ export interface PersistedQueryManifestOperation { /** Operation type (query, mutation, subscription) */ - type: 'query' | 'mutation' | 'subscription'; + type: OperationTypeNode; /** Operation name */ name: string; /** Full operation body text */ @@ -38,23 +38,23 @@ export interface PersistedQueryManifestOperation { } /** - * Server-side persisted query manifest format + * Server-side persisted operation manifest format */ -export interface ServerQueryListManifest { +export interface ServerOperationListManifest { format: 'apollo-persisted-query-manifest'; version: 1; operations: Record; } /** - * Client-side persisted query manifest format + * Client-side persisted operation manifest format */ -export type ClientQueryListManifest = Record; +export type ClientOperationListManifest = Record; /** * Union type for both manifest formats */ -export type QueryListManifest = ServerQueryListManifest | ClientQueryListManifest; +export type OperationListManifest = ServerOperationListManifest | ClientOperationListManifest; /** * Internal type for a GraphQL definition @@ -62,36 +62,17 @@ export type QueryListManifest = ServerQueryListManifest | ClientQueryListManifes export type Definition = FragmentDefinitionNode | OperationDefinitionNode; /** - * Query identifier metadata + * Represents a processed GraphQL operation with hash and query details */ -export interface QueryIdentifier { - /** - * The document identifier hash, optionally with algorithm prefix (e.g. "sha256:abc123...") - * when includeAlgorithmPrefix is enabled - */ +export interface ProcessedOperation { + name: string; hash: string; - - /** - * The full query string - */ + type: OperationTypeNode; query: string; - - /** - * Whether the operation uses variables - */ - usesVariables: boolean; - - /** - * Source location information (if available) - */ - loc?: Location; + definition: OperationDefinitionNode; + fragments: FragmentDefinitionNode[]; } -/** - * Map of query names to their identifiers - */ -export type QueryIdentifierMap = Record; - /** * Plugin function type */