diff --git a/.changeset/bruno-fixes.md b/.changeset/bruno-fixes.md new file mode 100644 index 0000000..128e6b9 --- /dev/null +++ b/.changeset/bruno-fixes.md @@ -0,0 +1,12 @@ +--- +"@labdigital/graphql-codegen-bruno": minor +--- + +Add the `url` and `auth` options for the request URL and auth mode; they default to the previously hardcoded `{{graphql-gateway}}/graphql` and `none`. + +This release changes the generated output: + +- A request now contains only its operation followed by the fragments it (transitively) uses, each once. Previously it contained the complete source document (including other operations in the same document) and fragments could be included twice. The GraphQL is printed with `graphql`'s `print`, so comments of the source document are no longer included. +- Subscriptions are written to `subscriptions/` instead of `mutations/`. Run once with `clean: true` (or remove them manually) to remove the subscriptions from `mutations/`. +- `clean: true` now only removes the previously generated requests (the `.bru` files, except `folder.bru`, in the `queries/`, `mutations/` and `subscriptions/` folders of the output directory) instead of everything in the output directory, so it no longer deletes `bruno.json`, environments or other files. +- prettier is no longer bundled (the package was 5 MB); formatting is done by `graphql`'s `print`. diff --git a/.changeset/k6-fix-document-modes.md b/.changeset/k6-fix-document-modes.md new file mode 100644 index 0000000..87b85d6 --- /dev/null +++ b/.changeset/k6-fix-document-modes.md @@ -0,0 +1,7 @@ +--- +"@labdigital/graphql-codegen-k6": patch +--- + +Fix the generated code for `documentMode` `graphQLTag` (the default) and `external`: the `Requester` type referred to `DocumentNode` without importing it, so the output did not type check. `DocumentNode` is now imported from `documentNodeImport` (default `graphql`), respecting `useTypeImports`. + +Fix `documentMode: "string"`: the output referred to a `TypedDocumentString` class that was never defined. The class is now generated (the same class as `typed-document-node` and the client preset use), the operation documents are typed with their result and variables, and the requester receives the `TypedDocumentString` (which serializes to the query string). diff --git a/.changeset/typedefs-extend-schema.md b/.changeset/typedefs-extend-schema.md new file mode 100644 index 0000000..b6deabe --- /dev/null +++ b/.changeset/typedefs-extend-schema.md @@ -0,0 +1,7 @@ +--- +"@labdigital/graphql-codegen-typedefs": minor +--- + +Only print the schema definition as `extend schema { ... }`. Previously the whole printed schema was prefixed with `extend`, which produced invalid output for schemas without root types (`extend type ...`, `extend directive ...`) and for schemas with a description. The description of the schema is dropped from the extension, as extensions cannot have one. + +Add the `extendSchema` option (default `true`, the previous behaviour); set it to `false` to print a plain `schema { ... }` definition instead. diff --git a/.gitignore b/.gitignore index 49a976f..6bde798 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ dist/* test-reports/* .turbo/ +.typecheck/ diff --git a/packages/bruno/README.md b/packages/bruno/README.md index 51cc699..8191500 100644 --- a/packages/bruno/README.md +++ b/packages/bruno/README.md @@ -35,8 +35,12 @@ const config: CodegenConfig = { "api-collection/generated/index.json": { plugins: ["@labdigital/graphql-codegen-bruno"], config: { - // Empty the output directory before generating + // Remove previously generated requests before generating clean: true, + // URL of the requests (default: "{{graphql-gateway}}/graphql") + url: "{{baseUrl}}/graphql", + // Use the auth of the collection (default: "none") + auth: "inherit", // Headers added to every request headers: { Authorization: "Bearer {{token}}", @@ -68,20 +72,24 @@ api-collection/ ├── index.json ├── mutations/ │ └── AddToCart.bru - └── queries/ - ├── GetProduct.bru - └── ListProducts.bru + ├── queries/ + │ ├── GetProduct.bru + │ └── ListProducts.bru + └── subscriptions/ + └── CartUpdated.bru ``` -The requests are sent to `{{graphql-gateway}}/graphql`, so define a +By default the requests are sent to `{{graphql-gateway}}/graphql`, so define a `graphql-gateway` variable (for example `http://localhost:4000`) in your Bruno -environment. +environment, or set `url` to match your environment variables. ## Configuration | Option | Type | Default | Description | | ------ | ---- | ------- | ----------- | -| `clean` | `boolean` | `false` | Remove everything in the output directory (the directory of the output file) before generating. | +| `clean` | `boolean` | `false` | Remove the previously generated requests before generating: the `.bru` files in the `queries/`, `mutations/` and `subscriptions/` folders of the output directory (except `folder.bru`), and those folders when they are empty afterwards. Other files, such as `bruno.json`, `collection.bru` or environments, are never removed. | +| `url` | `string` | `"{{graphql-gateway}}/graphql"` | URL of every request. May use Bruno variables. | +| `auth` | `string` | `"none"` | Auth mode of every request. Use `inherit` to use the auth configured on the collection or folder. | | `headers` | `Record` | `{}` | Headers added to the `headers` block of every request. Values may use Bruno variables, such as `{{token}}`. | | `defaults` | `Record` | `{}` | Values for operation variables, by variable name. A default replaces the generated example value of a top-level variable with the same name; variables that the operation does not declare are ignored. | @@ -119,9 +127,9 @@ meta { } post { - url: {{graphql-gateway}}/graphql + url: {{baseUrl}}/graphql body: graphql - auth: none + auth: inherit } headers { @@ -134,12 +142,12 @@ body:graphql { ...ProductFields } } + fragment ProductFields on Product { id name price } - } body:graphql:vars { @@ -189,18 +197,15 @@ Variables get an example value based on their type: - Codegen requires a file as output, so the plugin needs an output *file* even though it generates a directory. The file is used for the JSON index. -- `clean: true` removes **everything** in the directory of the output file. - Point the output to a dedicated directory inside your collection, not to the - collection root, or it removes your `bruno.json` and environments as well. -- Queries are written to `queries/`; mutations (and subscriptions) to - `mutations/`. Files are named after the operation, so operation names must be - unique. +- Queries are written to `queries/`, mutations to `mutations/` and + subscriptions to `subscriptions/`. Files are named after the operation, so + operation names must be unique. - Unnamed operations are skipped. -- The request body contains the complete source document of the operation - (one `.graphql` file, or one `gql` template literal in TypeScript), followed - by the fragments the operation uses. Put one operation per document; - otherwise every request contains all operations of that document, and - fragments defined in the same document are included twice. -- The URL (`{{graphql-gateway}}/graphql`) and `auth: none` are fixed. +- The request body contains the operation, followed by the fragments it uses + (also fragments used by those fragments, and fragments defined in other + documents), each fragment once. The GraphQL is printed with `graphql`'s + `print`, so comments in the source documents are not included. +- Without `clean`, requests of operations that were removed or renamed stay in + the output directory. - `defaults` only applies to top-level variables; it replaces the whole value, it does not merge into input objects. diff --git a/packages/bruno/package.json b/packages/bruno/package.json index 5c28c24..e536ffc 100644 --- a/packages/bruno/package.json +++ b/packages/bruno/package.json @@ -43,7 +43,6 @@ "@types/node": "^26.6.5", "@vitest/coverage-v8": "5.0.3", "graphql": "^17.0.2", - "prettier": "^3.9.10", "publint": "^0.3.25", "tsdown": "^0.23.1", "typescript": "^7.0.2", diff --git a/packages/bruno/src/__mocks__/operations.graphql b/packages/bruno/src/__mocks__/operations.graphql new file mode 100644 index 0000000..ad74189 --- /dev/null +++ b/packages/bruno/src/__mocks__/operations.graphql @@ -0,0 +1,42 @@ +# Several operations in one document + +query GetProduct($id: ID!) { + product(id: $id) { + ...ProductFields + } +} + +mutation UpdateCustomer($id: ID!, $firstName: String) { + updateCustomer(id: $id, firstName: $firstName) { + id + ...CustomerDetails + } +} + +subscription CustomerUpdated($id: ID!) { + customerUpdated(id: $id) { + id + ... on Customer { + ...CustomerDetails + } + } +} + +# Unnamed operations are skipped +# biome-ignore lint/correctness/useLoneAnonymousOperation: tests unnamed operations +# biome-ignore lint/correctness/useGraphqlNamedOperations: tests unnamed operations +query { + product(id: "1") { + id + } +} + +fragment ProductFields on Product { + id + name + price +} + +fragment UnusedFields on Product { + id +} diff --git a/packages/bruno/src/__mocks__/schema.graphqls b/packages/bruno/src/__mocks__/schema.graphqls index 82b9ba5..f5e1006 100644 --- a/packages/bruno/src/__mocks__/schema.graphqls +++ b/packages/bruno/src/__mocks__/schema.graphqls @@ -3,6 +3,14 @@ type Query { product(id: ID!): Product } +type Mutation { + updateCustomer(id: ID!, firstName: String): Customer +} + +type Subscription { + customerUpdated(id: ID!): Customer +} + input CustomerInfo { credentials: Credentials! authMethod: AuthMethod! diff --git a/packages/bruno/src/__output__/queries/GetCustomer.bru b/packages/bruno/src/__output__/queries/GetCustomer.bru deleted file mode 100644 index ebbb145..0000000 --- a/packages/bruno/src/__output__/queries/GetCustomer.bru +++ /dev/null @@ -1,55 +0,0 @@ -meta { - name: GetCustomer - type: graphql -} - -post { - url: {{graphql-gateway}}/graphql - body: graphql - auth: none -} - -body:graphql { - query GetCustomer($info: CustomerInfo!) { - customer(info: $info) { - ...CustomerDetails - } - } - - fragment CustomerDetails on Customer { - firstName - email - addresses { - ...AddressDetails - } - } - - fragment AddressDetails on Address { - id - city - } - fragment CustomerDetails on Customer { - firstName - email - addresses { - ...AddressDetails - } - } - fragment AddressDetails on Address { - id - city - } - -} - -body:graphql:vars { - { - "info": { - "credentials": { - "email": "example", - "password": "example" - }, - "authMethod": "EMAIL" - } - } -} \ No newline at end of file diff --git a/packages/bruno/src/__snapshots__/mutations/UpdateCustomer.bru b/packages/bruno/src/__snapshots__/mutations/UpdateCustomer.bru new file mode 100644 index 0000000..1c9358e --- /dev/null +++ b/packages/bruno/src/__snapshots__/mutations/UpdateCustomer.bru @@ -0,0 +1,39 @@ +meta { + name: UpdateCustomer + type: graphql +} + +post { + url: {{graphql-gateway}}/graphql + body: graphql + auth: none +} + +body:graphql { + mutation UpdateCustomer($id: ID!, $firstName: String) { + updateCustomer(id: $id, firstName: $firstName) { + id + ...CustomerDetails + } + } + + fragment CustomerDetails on Customer { + firstName + email + addresses { + ...AddressDetails + } + } + + fragment AddressDetails on Address { + id + city + } +} + +body:graphql:vars { + { + "id": "example-id", + "firstName": "example" + } +} \ No newline at end of file diff --git a/packages/bruno/src/__snapshots__/queries/GetCustomer.bru b/packages/bruno/src/__snapshots__/queries/GetCustomer.bru index ebbb145..a812609 100644 --- a/packages/bruno/src/__snapshots__/queries/GetCustomer.bru +++ b/packages/bruno/src/__snapshots__/queries/GetCustomer.bru @@ -28,18 +28,6 @@ body:graphql { id city } - fragment CustomerDetails on Customer { - firstName - email - addresses { - ...AddressDetails - } - } - fragment AddressDetails on Address { - id - city - } - } body:graphql:vars { diff --git a/packages/bruno/src/__snapshots__/queries/GetProduct.bru b/packages/bruno/src/__snapshots__/queries/GetProduct.bru new file mode 100644 index 0000000..21f9abc --- /dev/null +++ b/packages/bruno/src/__snapshots__/queries/GetProduct.bru @@ -0,0 +1,30 @@ +meta { + name: GetProduct + type: graphql +} + +post { + url: {{graphql-gateway}}/graphql + body: graphql + auth: none +} + +body:graphql { + query GetProduct($id: ID!) { + product(id: $id) { + ...ProductFields + } + } + + fragment ProductFields on Product { + id + name + price + } +} + +body:graphql:vars { + { + "id": "example-id" + } +} \ No newline at end of file diff --git a/packages/bruno/src/__snapshots__/subscriptions/CustomerUpdated.bru b/packages/bruno/src/__snapshots__/subscriptions/CustomerUpdated.bru new file mode 100644 index 0000000..c9c30f0 --- /dev/null +++ b/packages/bruno/src/__snapshots__/subscriptions/CustomerUpdated.bru @@ -0,0 +1,40 @@ +meta { + name: CustomerUpdated + type: graphql +} + +post { + url: {{graphql-gateway}}/graphql + body: graphql + auth: none +} + +body:graphql { + subscription CustomerUpdated($id: ID!) { + customerUpdated(id: $id) { + id + ... on Customer { + ...CustomerDetails + } + } + } + + fragment CustomerDetails on Customer { + firstName + email + addresses { + ...AddressDetails + } + } + + fragment AddressDetails on Address { + id + city + } +} + +body:graphql:vars { + { + "id": "example-id" + } +} \ No newline at end of file diff --git a/packages/bruno/src/bruno.ts b/packages/bruno/src/bruno.ts index 5b8944c..72092f1 100644 --- a/packages/bruno/src/bruno.ts +++ b/packages/bruno/src/bruno.ts @@ -1,28 +1,43 @@ -import prettier from "prettier"; import type { FileContent } from "./operations"; +export const DEFAULT_URL = "{{graphql-gateway}}/graphql"; +export const DEFAULT_AUTH = "none"; + export interface BrunoPluginConfig { - defaults: Record; - headers: Record; - clean: boolean; + /** + * Values for operation variables, by variable name. Replaces the generated + * example value of a top-level variable with the same name. + */ + defaults?: Record; + /** Headers added to every request. */ + headers?: Record; + /** + * Remove the previously generated requests (the `.bru` files in the + * `queries/`, `mutations/` and `subscriptions/` folders of the output + * directory) before generating. + */ + clean?: boolean; + /** URL of the requests. Defaults to `{{graphql-gateway}}/graphql`. */ + url?: string; + /** + * Auth mode of the requests, for example `inherit` to use the auth of the + * collection. Defaults to `none`. + */ + auth?: string; } -export const asBruno = async ( +export const asBruno = ( operation: FileContent, config: BrunoPluginConfig, -) => { - const formattedContent = await prettier.format(operation.content, { - parser: "graphql", - }); - - const vars = mergeDefaults(operation.vars, config.defaults); +): string => { + const vars = mergeDefaults(operation.vars, config.defaults ?? {}); const file = new FileCreator(); file.addElement("meta", [`name: ${operation.name}`, "type: graphql"]); file.addElement("post", [ - "url: {{graphql-gateway}}/graphql", + `url: ${config.url ?? DEFAULT_URL}`, "body: graphql", - "auth: none", + `auth: ${config.auth ?? DEFAULT_AUTH}`, ]); file.addElement( "headers", @@ -31,7 +46,7 @@ export const asBruno = async ( ), ); - file.addElement("body:graphql", formattedContent.split("\n")); + file.addElement("body:graphql", operation.content.split("\n")); file.addElement( "body:graphql:vars", diff --git a/packages/bruno/src/index.test.ts b/packages/bruno/src/index.test.ts index 6b2e125..b73749c 100644 --- a/packages/bruno/src/index.test.ts +++ b/packages/bruno/src/index.test.ts @@ -1,49 +1,226 @@ -import * as fs from "node:fs"; +import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import type { Types } from "@graphql-codegen/plugin-helpers"; import { GraphQLFileLoader } from "@graphql-tools/graphql-file-loader"; import { loadDocumentsSync, loadSchemaSync } from "@graphql-tools/load"; -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { BrunoPluginConfig } from "./bruno"; import { plugin } from "./index"; -const mockOutputDir = path.join(__dirname, "__output__"); const snapshotDir = path.join(__dirname, "__snapshots__"); -const mockSchemaPath = path.join(__dirname, "__mocks__", "schema.graphqls"); -const mockDocumentsPath = path.join(__dirname, "__mocks__", "query.graphql"); +const mocksDir = path.join(__dirname, "__mocks__"); + +const schema = loadSchemaSync(path.join(mocksDir, "schema.graphqls"), { + loaders: [new GraphQLFileLoader()], +}); +const documents = loadDocumentsSync( + [ + path.join(mocksDir, "query.graphql"), + path.join(mocksDir, "operations.graphql"), + ], + { loaders: [new GraphQLFileLoader()] }, +) as Types.DocumentFile[]; + +const defaultConfig: BrunoPluginConfig = { + defaults: {}, + headers: {}, + clean: false, +}; + +/** Lists the files in a directory, recursively, as sorted relative paths. */ +const listFiles = (dir: string): string[] => + (fs.readdirSync(dir, { recursive: true, withFileTypes: true }) as fs.Dirent[]) + .filter((entry) => entry.isFile()) + .map((entry) => path.relative(dir, path.join(entry.parentPath, entry.name))) + .sort(); describe("Bruno Plugin", () => { + let collectionDir: string; + let outputDir: string; + let outputFile: string; + + beforeEach(() => { + collectionDir = fs.mkdtempSync(path.join(os.tmpdir(), "codegen-bruno-")); + outputDir = path.join(collectionDir, "generated"); + outputFile = path.join(outputDir, "index.json"); + }); + + afterEach(() => { + fs.rmSync(collectionDir, { recursive: true, force: true }); + }); + + const read = (file: string) => + fs.readFileSync(path.join(outputDir, file), "utf-8"); + it("should generate the expected output", async () => { - // Load the mock schema - const schema = loadSchemaSync(mockSchemaPath, { - loaders: [new GraphQLFileLoader()], + const data = await plugin(schema, documents, defaultConfig, { + outputFile, + }); + + const index: Record = + JSON.parse(data as string); + const resolved = Object.fromEntries( + Object.entries(index).map(([name, { filename, source }]) => [ + name, + { filename, source: path.resolve(outputDir, source) }, + ]), + ); + expect(resolved).toEqual({ + CustomerUpdated: { + filename: "CustomerUpdated.bru", + source: path.join(mocksDir, "operations.graphql"), + }, + GetCustomer: { + filename: "GetCustomer.bru", + source: path.join(mocksDir, "query.graphql"), + }, + GetProduct: { + filename: "GetProduct.bru", + source: path.join(mocksDir, "operations.graphql"), + }, + UpdateCustomer: { + filename: "UpdateCustomer.bru", + source: path.join(mocksDir, "operations.graphql"), + }, }); + expect(listFiles(outputDir)).toEqual([ + path.join("mutations", "UpdateCustomer.bru"), + path.join("queries", "GetCustomer.bru"), + path.join("queries", "GetProduct.bru"), + path.join("subscriptions", "CustomerUpdated.bru"), + ]); - // Load the mock documents - const documents = loadDocumentsSync(mockDocumentsPath, { - loaders: [new GraphQLFileLoader()], - }) as Types.DocumentFile[]; + for (const file of listFiles(outputDir)) { + await expect(read(file)).toMatchFileSnapshot( + path.join(snapshotDir, file), + ); + } + }); - // Clear the output directory before running the test - fs.rmSync(mockOutputDir, { recursive: true, force: true }); + it("only includes the operation and the fragments it uses, once", async () => { + await plugin(schema, documents, defaultConfig, { outputFile }); - // Run the plugin - const outputFile = path.join(mockOutputDir, "output.json"); - const pluginConfig: BrunoPluginConfig = { - defaults: {}, - headers: {}, - clean: false, - }; - const data = await plugin(schema, documents, pluginConfig, { outputFile }); + const getCustomer = read(path.join("queries", "GetCustomer.bru")); + expect(getCustomer.match(/fragment CustomerDetails/g)).toHaveLength(1); + expect(getCustomer.match(/fragment AddressDetails/g)).toHaveLength(1); - expect(data).toBeDefined(); + const getProduct = read(path.join("queries", "GetProduct.bru")); + expect(getProduct).toContain("query GetProduct"); + expect(getProduct).toContain("fragment ProductFields"); + expect(getProduct).not.toContain("UpdateCustomer"); + expect(getProduct).not.toContain("CustomerUpdated"); + expect(getProduct).not.toContain("UnusedFields"); + expect(getProduct).not.toContain("# Several operations"); + + // Fragments from another document, used via an inline fragment + const customerUpdated = read( + path.join("subscriptions", "CustomerUpdated.bru"), + ); + expect(customerUpdated).toContain("fragment CustomerDetails"); + expect(customerUpdated).toContain("fragment AddressDetails"); + }); - const result = fs.readFileSync( - path.join(mockOutputDir, "queries", "GetCustomer.bru"), - "utf-8", + it("uses the configured url, auth and headers", async () => { + await plugin( + schema, + documents, + { + ...defaultConfig, + url: "{{baseUrl}}/api/graphql", + auth: "inherit", + headers: { Authorization: "Bearer {{token}}" }, + defaults: { id: "product-1" }, + }, + { outputFile }, ); - await expect(result).toMatchFileSnapshot( - path.join(snapshotDir, "queries", "GetCustomer.bru"), + + const getProduct = read(path.join("queries", "GetProduct.bru")); + expect(getProduct).toContain( + [ + "post {", + " url: {{baseUrl}}/api/graphql", + " body: graphql", + " auth: inherit", + "}", + "", + "headers {", + " Authorization: Bearer {{token}}", + "}", + ].join("\n"), ); + expect(getProduct).toContain('"id": "product-1"'); + }); + + describe("clean", () => { + const writeFile = (file: string, content = "") => { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, content); + }; + + it("only removes previously generated requests", async () => { + // The collection, with the generated requests in its root + outputFile = path.join(collectionDir, "index.json"); + outputDir = collectionDir; + writeFile(path.join(collectionDir, "bruno.json"), "{}"); + writeFile(path.join(collectionDir, "collection.bru")); + writeFile(path.join(collectionDir, "environments", "local.bru")); + writeFile(path.join(collectionDir, "custom", "Custom.bru")); + writeFile(path.join(collectionDir, "queries", "folder.bru")); + writeFile(path.join(collectionDir, "queries", "notes.md")); + // Generated by a previous run, for operations that no longer exist + writeFile(path.join(collectionDir, "queries", "Removed.bru")); + writeFile(path.join(collectionDir, "mutations", "Removed.bru")); + writeFile(path.join(collectionDir, "subscriptions", "Removed.bru")); + + await plugin( + schema, + documents, + { ...defaultConfig, clean: true }, + { outputFile }, + ); + + expect(listFiles(collectionDir)).toEqual([ + "bruno.json", + "collection.bru", + path.join("custom", "Custom.bru"), + path.join("environments", "local.bru"), + path.join("mutations", "UpdateCustomer.bru"), + path.join("queries", "GetCustomer.bru"), + path.join("queries", "GetProduct.bru"), + path.join("queries", "folder.bru"), + path.join("queries", "notes.md"), + path.join("subscriptions", "CustomerUpdated.bru"), + ]); + }); + + it("removes folders that are empty afterwards", async () => { + writeFile(path.join(outputDir, "queries", "Removed.bru")); + + await plugin( + schema, + [documents[1]], + { ...defaultConfig, clean: true }, + { outputFile }, + ); + await plugin( + schema, + [], + { ...defaultConfig, clean: true }, + { outputFile }, + ); + + expect(fs.readdirSync(outputDir)).toEqual([]); + }); + + it("keeps everything when clean is not enabled", async () => { + writeFile(path.join(outputDir, "queries", "Removed.bru")); + + await plugin(schema, documents, defaultConfig, { outputFile }); + + expect(listFiles(outputDir)).toContain( + path.join("queries", "Removed.bru"), + ); + }); }); }); diff --git a/packages/bruno/src/index.ts b/packages/bruno/src/index.ts index c31daf9..61ab092 100644 --- a/packages/bruno/src/index.ts +++ b/packages/bruno/src/index.ts @@ -1,7 +1,7 @@ import nodeFs from "node:fs"; import path from "node:path"; import type { PluginFunction, Types } from "@graphql-codegen/plugin-helpers"; -import type { GraphQLSchema } from "graphql"; +import type { GraphQLSchema, OperationTypeNode } from "graphql"; import { asBruno, type BrunoPluginConfig } from "./bruno"; import { extractOperations } from "./operations"; @@ -14,9 +14,9 @@ export const plugin: PluginFunction = async ( if (!info?.outputFile) { throw new Error("Output directory not provided"); } - const outputDir = path.dirname(info?.outputFile); + const outputDir = path.dirname(info.outputFile); if (config.clean) { - emptyDirSync(outputDir); + cleanGeneratedFiles(outputDir); } const operations = extractOperations(schema, documents).sort((a, b) => @@ -24,13 +24,14 @@ export const plugin: PluginFunction = async ( ); const result: Record> = {}; for (const operation of operations) { - const subpath = operation.kind === "query" ? "queries" : "mutations"; const fileName = `${operation.name}.bru`; - const outputPath = path.join(outputDir, subpath, fileName); + const outputPath = path.join( + outputDir, + OPERATION_FOLDERS[operation.kind], + fileName, + ); - const formattedContent = await asBruno(operation, config); - - outputFileSync(outputPath, formattedContent); + outputFileSync(outputPath, asBruno(operation, config)); result[operation.name] = { filename: fileName, source: operation.location @@ -41,16 +42,37 @@ export const plugin: PluginFunction = async ( return JSON.stringify(result, null, 2); }; -function emptyDirSync(dirPath: string): void { - if (!nodeFs.existsSync(dirPath)) { - nodeFs.mkdirSync(dirPath, { recursive: true }); - return; - } +/** The folder (in the output directory) per operation type. */ +const OPERATION_FOLDERS: Record = { + query: "queries", + mutation: "mutations", + subscription: "subscriptions", +}; - const files = nodeFs.readdirSync(dirPath); - for (const file of files) { - const filePath = path.join(dirPath, file); - nodeFs.rmSync(filePath, { recursive: true, force: true }); +/** + * Removes the requests generated by a previous run: the `.bru` files in the + * folders this plugin writes to, and those folders when they are empty + * afterwards. Other files, such as `bruno.json`, environments or a + * `folder.bru` with folder settings, are kept. + */ +function cleanGeneratedFiles(outputDir: string): void { + for (const folder of Object.values(OPERATION_FOLDERS)) { + const dirPath = path.join(outputDir, folder); + if (!nodeFs.existsSync(dirPath)) { + continue; + } + for (const entry of nodeFs.readdirSync(dirPath, { withFileTypes: true })) { + if ( + entry.isFile() && + entry.name.endsWith(".bru") && + entry.name !== "folder.bru" + ) { + nodeFs.rmSync(path.join(dirPath, entry.name)); + } + } + if (nodeFs.readdirSync(dirPath).length === 0) { + nodeFs.rmdirSync(dirPath); + } } } @@ -61,3 +83,5 @@ function outputFileSync(filePath: string, content: string): void { } nodeFs.writeFileSync(filePath, content); } + +export type { BrunoPluginConfig }; diff --git a/packages/bruno/src/operations.test.ts b/packages/bruno/src/operations.test.ts index 1dfac6a..78e1e27 100644 --- a/packages/bruno/src/operations.test.ts +++ b/packages/bruno/src/operations.test.ts @@ -1,7 +1,65 @@ +import { type FragmentDefinitionNode, Kind, parse } from "graphql"; import { describe, expect, it } from "vitest"; +import { collectUsedFragments } from "./operations"; -describe("operations", () => { - it("should add two numbers", () => { - expect(1 + 1).toBe(2); +const document = parse(` + query GetCustomer { + customer { + ...A + addresses { + ...B + } + ... on Customer { + ...C + } + } + } + + fragment A on Customer { id ...B } + fragment B on Address { id ...D } + fragment C on Customer { id ...A } + fragment D on Address { city } + fragment Unused on Customer { id } + fragment Cyclic1 on Customer { ...Cyclic2 } + fragment Cyclic2 on Customer { ...Cyclic1 } +`); + +const fragments = new Map( + document.definitions + .filter((d) => d.kind === Kind.FRAGMENT_DEFINITION) + .map((d) => [d.name.value, d as FragmentDefinitionNode]), +); + +describe("collectUsedFragments", () => { + it("returns the transitively used fragments once, in order of use", () => { + const operation = document.definitions[0]; + if (operation.kind !== Kind.OPERATION_DEFINITION) { + throw new Error("Expected an operation"); + } + expect(collectUsedFragments(operation, fragments)).toEqual([ + "A", + "B", + "D", + "C", + ]); + }); + + it("handles cyclic fragments", () => { + const fragment = fragments.get("Cyclic1"); + if (!fragment) { + throw new Error("Expected a fragment"); + } + expect(collectUsedFragments(fragment, fragments)).toEqual([ + "Cyclic2", + "Cyclic1", + ]); + }); + + it("includes unknown fragments by name", () => { + const operation = parse("{ ...External }").definitions[0]; + if (operation.kind !== Kind.OPERATION_DEFINITION) { + throw new Error("Expected an operation"); + } + expect(collectUsedFragments(operation, fragments)).toEqual(["External"]); }); }); diff --git a/packages/bruno/src/operations.ts b/packages/bruno/src/operations.ts index e2db23d..8ee4c51 100644 --- a/packages/bruno/src/operations.ts +++ b/packages/bruno/src/operations.ts @@ -1,106 +1,100 @@ -import assert from "node:assert"; import type { Types } from "@graphql-codegen/plugin-helpers"; import { type FragmentDefinitionNode, type GraphQLSchema, Kind, type OperationDefinitionNode, + type OperationTypeNode, print, + visit, } from "graphql"; import { generateExampleVariables } from "./vars"; -export interface BruPluginConfig { - outputDir?: string; -} - export type FileContent = { - kind: string | undefined; + kind: OperationTypeNode; name: string; content: string; vars: Record; location: string | undefined; }; +/** + * Returns the names of the fragments used by the given node, including the + * fragments used by those fragments, in order of first use. Every fragment is + * listed once. + */ +export const collectUsedFragments = ( + node: OperationDefinitionNode | FragmentDefinitionNode, + fragments: ReadonlyMap, +): string[] => { + const used = new Set(); + + const collect = ( + current: OperationDefinitionNode | FragmentDefinitionNode, + ) => { + visit(current, { + FragmentSpread(spread) { + const name = spread.name.value; + if (used.has(name)) { + return; + } + used.add(name); + const fragment = fragments.get(name); + if (fragment) { + collect(fragment); + } + }, + }); + }; + + collect(node); + return [...used]; +}; + export const extractOperations = ( schema: GraphQLSchema, documents: Types.DocumentFile[], ): FileContent[] => { - const results: FileContent[] = []; - - // Step 1: Collect all fragments - const allFragments = new Map(); - + // Collect the fragments of all documents, so operations can use fragments + // defined in other documents. + const fragments = new Map(); for (const doc of documents) { - if (doc.document) { - for (const definition of doc.document.definitions) { - if (definition.kind === Kind.FRAGMENT_DEFINITION) { - const fragmentName = definition.name.value; - // allFragments[fragmentName] = definition as FragmentDefinitionNode; - allFragments.set(fragmentName, definition as FragmentDefinitionNode); - } + for (const definition of doc.document?.definitions ?? []) { + if (definition.kind === Kind.FRAGMENT_DEFINITION) { + fragments.set(definition.name.value, definition); } } } - // Step 2: Process operations and append only used fragments + const results: FileContent[] = []; for (const doc of documents) { - if (doc.document) { - const operations = doc.document.definitions - .filter((def) => def.kind === Kind.OPERATION_DEFINITION) - .filter((operation) => operation.name?.value !== undefined) // Filter out unnamed operations - .map((operation): FileContent => { - assert(operation.name?.value); - - const operationString = doc.rawSDL || ""; - - // Collect the names of all fragments used in this operation - const usedFragments = new Set(); - const collectFragments = ( - node: OperationDefinitionNode | FragmentDefinitionNode, - ) => { - if (node.selectionSet) { - for (const selection of node.selectionSet.selections) { - if (selection.kind === Kind.FRAGMENT_SPREAD) { - usedFragments.add(selection.name.value); - // Recursively collect fragments within fragments - const fragment = allFragments.get(selection.name.value); - if (fragment) { - collectFragments(fragment); - } - } else if (selection.selectionSet) { - // @ts-expect-error - collectFragments(selection as OperationDefinitionNode); - } - } - } - }; - - collectFragments(operation); - - // Append only the used fragments - let fileContent = operationString; - - for (const fragmentName of usedFragments) { - const fragment = allFragments.get(fragmentName); - if (fragment) { - const fragmentString = print(fragment); - fileContent += `\n${fragmentString}`; - } - } + for (const definition of doc.document?.definitions ?? []) { + // Unnamed operations are skipped + if ( + definition.kind !== Kind.OPERATION_DEFINITION || + !definition.name?.value + ) { + continue; + } - return { - name: operation.name?.value, - location: operation.loc?.source.name, - kind: operation.operation, - content: fileContent, - vars: generateExampleVariables( - schema, - operation as OperationDefinitionNode, - ), - }; - }); + // The request contains the operation itself, followed by the + // fragments it (transitively) uses. + const content = [ + definition, + ...collectUsedFragments(definition, fragments) + .map((name) => fragments.get(name)) + .filter((fragment) => fragment !== undefined), + ] + .map((node) => print(node)) + .join("\n\n"); - results.push(...operations); + results.push({ + name: definition.name.value, + location: definition.loc?.source.name ?? doc.location, + kind: definition.operation, + content, + vars: generateExampleVariables(schema, definition), + }); } } diff --git a/packages/bruno/tsdown.config.ts b/packages/bruno/tsdown.config.ts index c10773e..17c39bf 100644 --- a/packages/bruno/tsdown.config.ts +++ b/packages/bruno/tsdown.config.ts @@ -13,6 +13,4 @@ export default defineConfig({ outDir: "dist", fixedExtension: false, outputOptions: { codeSplitting: false }, - // prettier is a devDependency and intentionally bundled (same as with tsup) - deps: { onlyBundle: ["prettier"] }, }); diff --git a/packages/k6/README.md b/packages/k6/README.md index ca367dc..e555262 100644 --- a/packages/k6/README.md +++ b/packages/k6/README.md @@ -22,8 +22,14 @@ pnpm add -D @types/k6 ``` `graphql` and `graphql-tag` are peer dependencies. The plugin only generates -the SDK methods; the operation types it refers to come from -`@graphql-codegen/typescript-operations`. +the operation documents and the SDK methods; the operation types it refers to +come from `@graphql-codegen/typescript-operations`. + +Use `typescript-operations` (v6) on its own, **without** the `typescript` +plugin in the same output: `typescript-operations` v6 generates the enum and +input types the operations need itself, so adding `typescript` results in +duplicate declarations of those types. This plugin does not generate any schema +types. ## Usage @@ -37,7 +43,8 @@ const config: CodegenConfig = { "k6/sdk.ts": { plugins: ["typescript-operations", "@labdigital/graphql-codegen-k6"], config: { - // Embed the operations as DocumentNode objects (see "Notes") + // Embed the operations as DocumentNode objects (the default, + // "graphQLTag", uses gql from graphql-tag) documentMode: "documentNode", useTypeImports: true, }, @@ -105,7 +112,7 @@ The options that affect the generated SDK: | Option | Type | Default | Description | | ------ | ---- | ------- | ----------- | -| `documentMode` | `"graphQLTag" \| "documentNode" \| "documentNodeImportFragments" \| "external" \| "string"` | `"graphQLTag"` | How the operation documents are emitted. `graphQLTag` uses `gql` from `graphql-tag`, `documentNode` embeds them as plain `DocumentNode` objects, `external` imports them from `importDocumentNodeExternallyFrom`. See the notes below. | +| `documentMode` | `"graphQLTag" \| "documentNode" \| "documentNodeImportFragments" \| "external" \| "string"` | `"graphQLTag"` | How the operation documents are emitted. `graphQLTag` uses `gql` from `graphql-tag`, `documentNode` embeds them as plain `DocumentNode` objects, `external` imports them from `importDocumentNodeExternallyFrom`, `string` emits them as `TypedDocumentString` (see [String documents](#string-documents)). | | `importDocumentNodeExternallyFrom` | `string` | `""` | Module to import the documents from when `documentMode` is `external`. The SDK then refers to them as `Operations.Document`. | | `importOperationTypesFrom` | `string` | `""` | Namespace prefix for the operation result and variables types used in the SDK (for example `Types` results in `Types.GetProductQuery`). Used by presets such as `near-operation-file`. | | `gqlImport` | `string` | `"graphql-tag#gql"` | Module (and export) to import `gql` from, when `documentMode` is `graphQLTag`. | @@ -131,6 +138,21 @@ When the plugin runs in the same output as `typescript-operations` (as in the example above), shared options such as `namingConvention` can be set once in the `config` of the output. +### String documents + +With `documentMode: "string"` the operations are emitted as instances of a +`TypedDocumentString` class (a `String` subclass, the same class as generated +by `typed-document-node` and the client preset), which the plugin generates in +the output file. The requester receives the document as a +`TypedDocumentString`; it serializes to the query string, so you do not need +`graphql` at runtime: + +```ts +const sdk = getSdk((doc, variables, options) => + http.post(endpoint, JSON.stringify({ query: doc, variables }), options), +); +``` + ## Example output For this operation: @@ -185,11 +207,6 @@ variables (such as `ListProducts`). - Every operation must be named; codegen fails on unnamed operations. - The SDK methods return the raw k6 `RefinedResponse`; the response body is not typed or parsed. Use `res.json()` to read the result. -- Use `documentMode: "documentNode"` (as above). With the default - `graphQLTag` mode the generated `Requester` type refers to `DocumentNode` - without importing it, so the file does not type check. `documentMode: - "string"` is not supported: the generated code uses a `TypedDocumentString` - class that is not emitted. -- Use `typescript-operations` without the `typescript` plugin in the same - output: `typescript-operations` v6 already generates the input and enum - types it needs, and combining both results in duplicate type declarations. +- The generated `Requester` type refers to `DocumentNode` (or + `TypedDocumentString` with `documentMode: "string"`); the plugin imports it + from `documentNodeImport` (default `graphql`). diff --git a/packages/k6/package.json b/packages/k6/package.json index 722a239..8cab06c 100644 --- a/packages/k6/package.json +++ b/packages/k6/package.json @@ -37,7 +37,11 @@ "auto-bind": "5.0.1" }, "devDependencies": { + "@graphql-codegen/typescript-operations": "^6.1.10", + "@types/k6": "^2.3.0", + "@types/node": "^26.6.5", "graphql": "^17.0.2", + "graphql-tag": "^2.0.0", "publint": "^0.3.25", "tsdown": "^0.23.1", "tslib": "2.8.1", diff --git a/packages/k6/src/__mocks__/operations.graphql b/packages/k6/src/__mocks__/operations.graphql new file mode 100644 index 0000000..d2d75fc --- /dev/null +++ b/packages/k6/src/__mocks__/operations.graphql @@ -0,0 +1,34 @@ +query GetProduct($id: ID!, $locale: Locale) { + product(id: $id, locale: $locale) { + ...ProductFields + } +} + +query ListProducts($filter: ProductFilter) { + products(filter: $filter) { + ...ProductFields + } +} + +mutation AddToCart($productId: ID!, $quantity: Int!) { + addToCart(productId: $productId, quantity: $quantity) { + id + totalItems + } +} + +subscription CartUpdated($cartId: ID!) { + cartUpdated(cartId: $cartId) { + id + } +} + +fragment ProductFields on Product { + id + name + ...ProductPrice +} + +fragment ProductPrice on Product { + price +} diff --git a/packages/k6/src/__mocks__/schema.graphqls b/packages/k6/src/__mocks__/schema.graphqls new file mode 100644 index 0000000..f9ce7bd --- /dev/null +++ b/packages/k6/src/__mocks__/schema.graphqls @@ -0,0 +1,33 @@ +type Query { + product(id: ID!, locale: Locale): Product + products(filter: ProductFilter): [Product!]! +} + +type Mutation { + addToCart(productId: ID!, quantity: Int!): Cart! +} + +type Subscription { + cartUpdated(cartId: ID!): Cart! +} + +enum Locale { + NL + EN +} + +input ProductFilter { + query: String + locale: Locale +} + +type Product { + id: ID! + name: String! + price: Float! +} + +type Cart { + id: ID! + totalItems: Int! +} diff --git a/packages/k6/src/__snapshots__/index.test.ts.snap b/packages/k6/src/__snapshots__/index.test.ts.snap new file mode 100644 index 0000000..766085f --- /dev/null +++ b/packages/k6/src/__snapshots__/index.test.ts.snap @@ -0,0 +1,329 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`k6 plugin > documentMode documentNode (useTypeImports: false) > generates the sdk 1`] = ` +"import { type RefinedResponse, type ResponseType } from "k6/http"; +import { DocumentNode } from 'graphql'; +export const ProductPriceFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ProductPrice"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Product"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"price"}}]}}]} as unknown as DocumentNode; +export const ProductFieldsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ProductFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Product"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"ProductPrice"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ProductPrice"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Product"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"price"}}]}}]} as unknown as DocumentNode; +export const GetProductDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetProduct"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"locale"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Locale"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"product"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}},{"kind":"Argument","name":{"kind":"Name","value":"locale"},"value":{"kind":"Variable","name":{"kind":"Name","value":"locale"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ProductFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ProductFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Product"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"ProductPrice"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ProductPrice"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Product"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"price"}}]}}]} as unknown as DocumentNode; +export const ListProductsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ListProducts"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filter"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ProductFilter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"products"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ProductFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ProductFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Product"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"ProductPrice"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ProductPrice"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Product"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"price"}}]}}]} as unknown as DocumentNode; +export const AddToCartDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"AddToCart"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"productId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"quantity"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"addToCart"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"productId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"productId"}}},{"kind":"Argument","name":{"kind":"Name","value":"quantity"},"value":{"kind":"Variable","name":{"kind":"Name","value":"quantity"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"totalItems"}}]}}]}}]} as unknown as DocumentNode; +export const CartUpdatedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"CartUpdated"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"cartId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cartUpdated"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"cartId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"cartId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]} as unknown as DocumentNode; + + export type Requester = (doc: DocumentNode, vars?: V, options?: C) => RefinedResponse + + export function getSdk(requester: Requester) { + return { + GetProduct(variables: GetProductQueryVariables, options?: C): RefinedResponse { + return requester(GetProductDocument, variables, options); + }, + ListProducts(variables?: ListProductsQueryVariables, options?: C): RefinedResponse { + return requester(ListProductsDocument, variables, options); + }, + AddToCart(variables: AddToCartMutationVariables, options?: C): RefinedResponse { + return requester(AddToCartDocument, variables, options); + }, + CartUpdated(variables: CartUpdatedSubscriptionVariables, options?: C): RefinedResponse { + return requester(CartUpdatedDocument, variables, options); + } + }; + } + export type Sdk = ReturnType;" +`; + +exports[`k6 plugin > documentMode documentNodeImportFragments (useTypeImports: false) > generates the sdk 1`] = ` +"import { type RefinedResponse, type ResponseType } from "k6/http"; +import { DocumentNode } from 'graphql'; +export const ProductPriceFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ProductPrice"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Product"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"price"}}]}}]} as unknown as DocumentNode; +export const ProductFieldsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ProductFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Product"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"ProductPrice"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ProductPrice"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Product"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"price"}}]}}]} as unknown as DocumentNode; +export const GetProductDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetProduct"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"locale"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Locale"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"product"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}},{"kind":"Argument","name":{"kind":"Name","value":"locale"},"value":{"kind":"Variable","name":{"kind":"Name","value":"locale"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ProductFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ProductPrice"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Product"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"price"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ProductFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Product"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"ProductPrice"}}]}}]} as unknown as DocumentNode; +export const ListProductsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ListProducts"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filter"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ProductFilter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"products"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ProductFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ProductPrice"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Product"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"price"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ProductFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Product"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"ProductPrice"}}]}}]} as unknown as DocumentNode; +export const AddToCartDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"AddToCart"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"productId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"quantity"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"addToCart"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"productId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"productId"}}},{"kind":"Argument","name":{"kind":"Name","value":"quantity"},"value":{"kind":"Variable","name":{"kind":"Name","value":"quantity"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"totalItems"}}]}}]}}]} as unknown as DocumentNode; +export const CartUpdatedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"CartUpdated"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"cartId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cartUpdated"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"cartId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"cartId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]} as unknown as DocumentNode; + + export type Requester = (doc: DocumentNode, vars?: V, options?: C) => RefinedResponse + + export function getSdk(requester: Requester) { + return { + GetProduct(variables: GetProductQueryVariables, options?: C): RefinedResponse { + return requester(GetProductDocument, variables, options); + }, + ListProducts(variables?: ListProductsQueryVariables, options?: C): RefinedResponse { + return requester(ListProductsDocument, variables, options); + }, + AddToCart(variables: AddToCartMutationVariables, options?: C): RefinedResponse { + return requester(AddToCartDocument, variables, options); + }, + CartUpdated(variables: CartUpdatedSubscriptionVariables, options?: C): RefinedResponse { + return requester(CartUpdatedDocument, variables, options); + } + }; + } + export type Sdk = ReturnType;" +`; + +exports[`k6 plugin > documentMode external > generates the sdk 1`] = ` +"import { type RefinedResponse, type ResponseType } from "k6/http"; +import * as Operations from './operations'; +import { DocumentNode } from 'graphql'; + + + + + + + export type Requester = (doc: DocumentNode, vars?: V, options?: C) => RefinedResponse + + export function getSdk(requester: Requester) { + return { + GetProduct(variables: GetProductQueryVariables, options?: C): RefinedResponse { + return requester(Operations.GetProductDocument, variables, options); + }, + ListProducts(variables?: ListProductsQueryVariables, options?: C): RefinedResponse { + return requester(Operations.ListProductsDocument, variables, options); + }, + AddToCart(variables: AddToCartMutationVariables, options?: C): RefinedResponse { + return requester(Operations.AddToCartDocument, variables, options); + }, + CartUpdated(variables: CartUpdatedSubscriptionVariables, options?: C): RefinedResponse { + return requester(Operations.CartUpdatedDocument, variables, options); + } + }; + } + export type Sdk = ReturnType;" +`; + +exports[`k6 plugin > documentMode graphQLTag (useTypeImports: false) > generates the sdk 1`] = ` +"import { type RefinedResponse, type ResponseType } from "k6/http"; +import gql from 'graphql-tag'; +import { DocumentNode } from 'graphql'; +export const ProductPriceFragmentDoc = gql\` + fragment ProductPrice on Product { + price +} + \`; +export const ProductFieldsFragmentDoc = gql\` + fragment ProductFields on Product { + id + name + ...ProductPrice +} + \`; +export const GetProductDocument = gql\` + query GetProduct($id: ID!, $locale: Locale) { + product(id: $id, locale: $locale) { + ...ProductFields + } +} + \${ProductFieldsFragmentDoc} +\${ProductPriceFragmentDoc}\`; +export const ListProductsDocument = gql\` + query ListProducts($filter: ProductFilter) { + products(filter: $filter) { + ...ProductFields + } +} + \${ProductFieldsFragmentDoc} +\${ProductPriceFragmentDoc}\`; +export const AddToCartDocument = gql\` + mutation AddToCart($productId: ID!, $quantity: Int!) { + addToCart(productId: $productId, quantity: $quantity) { + id + totalItems + } +} + \`; +export const CartUpdatedDocument = gql\` + subscription CartUpdated($cartId: ID!) { + cartUpdated(cartId: $cartId) { + id + } +} + \`; + + export type Requester = (doc: DocumentNode, vars?: V, options?: C) => RefinedResponse + + export function getSdk(requester: Requester) { + return { + GetProduct(variables: GetProductQueryVariables, options?: C): RefinedResponse { + return requester(GetProductDocument, variables, options); + }, + ListProducts(variables?: ListProductsQueryVariables, options?: C): RefinedResponse { + return requester(ListProductsDocument, variables, options); + }, + AddToCart(variables: AddToCartMutationVariables, options?: C): RefinedResponse { + return requester(AddToCartDocument, variables, options); + }, + CartUpdated(variables: CartUpdatedSubscriptionVariables, options?: C): RefinedResponse { + return requester(CartUpdatedDocument, variables, options); + } + }; + } + export type Sdk = ReturnType;" +`; + +exports[`k6 plugin > documentMode graphQLTag (useTypeImports: true) > generates the sdk 1`] = ` +"import { type RefinedResponse, type ResponseType } from "k6/http"; +import gql from 'graphql-tag'; +import type { DocumentNode } from 'graphql'; +export const ProductPriceFragmentDoc = gql\` + fragment ProductPrice on Product { + price +} + \`; +export const ProductFieldsFragmentDoc = gql\` + fragment ProductFields on Product { + id + name + ...ProductPrice +} + \`; +export const GetProductDocument = gql\` + query GetProduct($id: ID!, $locale: Locale) { + product(id: $id, locale: $locale) { + ...ProductFields + } +} + \${ProductFieldsFragmentDoc} +\${ProductPriceFragmentDoc}\`; +export const ListProductsDocument = gql\` + query ListProducts($filter: ProductFilter) { + products(filter: $filter) { + ...ProductFields + } +} + \${ProductFieldsFragmentDoc} +\${ProductPriceFragmentDoc}\`; +export const AddToCartDocument = gql\` + mutation AddToCart($productId: ID!, $quantity: Int!) { + addToCart(productId: $productId, quantity: $quantity) { + id + totalItems + } +} + \`; +export const CartUpdatedDocument = gql\` + subscription CartUpdated($cartId: ID!) { + cartUpdated(cartId: $cartId) { + id + } +} + \`; + + export type Requester = (doc: DocumentNode, vars?: V, options?: C) => RefinedResponse + + export function getSdk(requester: Requester) { + return { + GetProduct(variables: GetProductQueryVariables, options?: C): RefinedResponse { + return requester(GetProductDocument, variables, options); + }, + ListProducts(variables?: ListProductsQueryVariables, options?: C): RefinedResponse { + return requester(ListProductsDocument, variables, options); + }, + AddToCart(variables: AddToCartMutationVariables, options?: C): RefinedResponse { + return requester(AddToCartDocument, variables, options); + }, + CartUpdated(variables: CartUpdatedSubscriptionVariables, options?: C): RefinedResponse { + return requester(CartUpdatedDocument, variables, options); + } + }; + } + export type Sdk = ReturnType;" +`; + +exports[`k6 plugin > documentMode string > generates the sdk 1`] = ` +"import { type RefinedResponse, type ResponseType } from "k6/http"; +export class TypedDocumentString extends String { + __apiType?: (variables: TVariables) => TResult; + private value: string; + public __meta__?: Record | undefined; + + constructor(value: string, __meta__?: Record | undefined) { + super(value); + this.value = value; + this.__meta__ = __meta__; + } + + override toString(): string { + return this.value; + } +} + +export const ProductPriceFragmentDoc = new TypedDocumentString(\` + fragment ProductPrice on Product { + price +} + \`, {"fragmentName":"ProductPrice"}); +export const ProductFieldsFragmentDoc = new TypedDocumentString(\` + fragment ProductFields on Product { + id + name + ...ProductPrice +} + fragment ProductPrice on Product { + price +}\`, {"fragmentName":"ProductFields"}); +export const GetProductDocument = new TypedDocumentString(\` + query GetProduct($id: ID!, $locale: Locale) { + product(id: $id, locale: $locale) { + ...ProductFields + } +} + fragment ProductFields on Product { + id + name + ...ProductPrice +} +fragment ProductPrice on Product { + price +}\`) as unknown as TypedDocumentString; +export const ListProductsDocument = new TypedDocumentString(\` + query ListProducts($filter: ProductFilter) { + products(filter: $filter) { + ...ProductFields + } +} + fragment ProductFields on Product { + id + name + ...ProductPrice +} +fragment ProductPrice on Product { + price +}\`) as unknown as TypedDocumentString; +export const AddToCartDocument = new TypedDocumentString(\` + mutation AddToCart($productId: ID!, $quantity: Int!) { + addToCart(productId: $productId, quantity: $quantity) { + id + totalItems + } +} + \`) as unknown as TypedDocumentString; +export const CartUpdatedDocument = new TypedDocumentString(\` + subscription CartUpdated($cartId: ID!) { + cartUpdated(cartId: $cartId) { + id + } +} + \`) as unknown as TypedDocumentString; + + export type Requester = (doc: TypedDocumentString, vars?: V, options?: C) => RefinedResponse + + export function getSdk(requester: Requester) { + return { + GetProduct(variables: GetProductQueryVariables, options?: C): RefinedResponse { + return requester(GetProductDocument, variables, options); + }, + ListProducts(variables?: ListProductsQueryVariables, options?: C): RefinedResponse { + return requester(ListProductsDocument, variables, options); + }, + AddToCart(variables: AddToCartMutationVariables, options?: C): RefinedResponse { + return requester(AddToCartDocument, variables, options); + }, + CartUpdated(variables: CartUpdatedSubscriptionVariables, options?: C): RefinedResponse { + return requester(CartUpdatedDocument, variables, options); + } + }; + } + export type Sdk = ReturnType;" +`; diff --git a/packages/k6/src/index.test.ts b/packages/k6/src/index.test.ts index c33c5f3..6489b49 100644 --- a/packages/k6/src/index.test.ts +++ b/packages/k6/src/index.test.ts @@ -1,7 +1,263 @@ -import { describe, expect, it } from "vitest"; +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import type { Types } from "@graphql-codegen/plugin-helpers"; +import { plugin as typescriptOperationsPlugin } from "@graphql-codegen/typescript-operations"; +import { DocumentMode } from "@graphql-codegen/visitor-plugin-common"; +import { buildSchema, parse } from "graphql"; +import { afterAll, describe, expect, it } from "vitest"; +import type { RawK6PluginConfig } from "./config"; +import { plugin } from "./index"; -describe("dummy", () => { - it("test", () => { - expect(true).toBe(true); +const packageDir = path.join(__dirname, ".."); +const mocksDir = path.join(__dirname, "__mocks__"); +// Inside the package so that the fixture resolves `graphql`, `graphql-tag` +// and `@types/k6` from its node_modules (ignored by git). +const typecheckDir = path.join(packageDir, ".typecheck"); + +const schema = buildSchema( + fs.readFileSync(path.join(mocksDir, "schema.graphqls"), "utf-8"), +); +const documents: Types.DocumentFile[] = [ + { + location: path.join(mocksDir, "operations.graphql"), + document: parse( + fs.readFileSync(path.join(mocksDir, "operations.graphql"), "utf-8"), + ), + }, +]; + +const outputToString = (output: Types.PluginOutput): string => { + if (typeof output === "string") { + return output; + } + return [...(output.prepend ?? []), output.content, ...(output.append ?? [])] + .filter(Boolean) + .join("\n"); +}; + +const generateK6 = async (config: RawK6PluginConfig) => + outputToString( + await plugin(schema, documents, config, { outputFile: "sdk.ts" }), + ); + +/** The output of `typescript-operations` followed by the k6 plugin. */ +const generateSdk = async (config: RawK6PluginConfig) => { + const operations = outputToString( + await typescriptOperationsPlugin(schema, documents, config, { + outputFile: "sdk.ts", + }), + ); + return `${operations}\n${await generateK6(config)}`; +}; + +const tsconfig = { + compilerOptions: { + target: "ES2022", + lib: ["ES2022"], + module: "ESNext", + moduleResolution: "bundler", + strict: true, + noEmit: true, + skipLibCheck: true, + types: ["k6"], + }, + include: ["*.ts"], +}; + +const documentNodeUsage = ` +import { print } from "graphql"; +import http, { type Params } from "k6/http"; +import { getSdk } from "./sdk"; + +const sdk = getSdk((doc, variables, options) => + http.post("http://localhost/graphql", JSON.stringify({ query: print(doc), variables }), options), +); +`; + +const stringUsage = ` +import http, { type Params } from "k6/http"; +import { getSdk } from "./sdk"; + +const sdk = getSdk((doc, variables, options) => + http.post("http://localhost/graphql", JSON.stringify({ query: doc, variables }), options), +); +`; + +const sdkCalls = ` +export default function () { + sdk.GetProduct({ id: "1", locale: "NL" }, { tags: { name: "GetProduct" } }); + sdk.ListProducts(); + sdk.ListProducts({ filter: { query: "shoe" } }); + sdk.AddToCart({ productId: "1", quantity: 2 }); + sdk.CartUpdated({ cartId: "1" }); + // @ts-expect-error required variables are missing + sdk.GetProduct(); + // @ts-expect-error unknown enum value + sdk.GetProduct({ id: "1", locale: "DE" }); +} +`; + +const typecheck = (name: string, files: Record) => { + const dir = path.join(typecheckDir, name); + fs.rmSync(dir, { recursive: true, force: true }); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "tsconfig.json"), JSON.stringify(tsconfig)); + for (const [file, content] of Object.entries(files)) { + fs.writeFileSync(path.join(dir, file), content); + } + try { + execFileSync( + path.join(packageDir, "node_modules", ".bin", "tsc"), + ["-p", dir], + { encoding: "utf-8", stdio: "pipe" }, + ); + } catch (error) { + const { stdout, stderr } = error as { stdout: string; stderr: string }; + throw new Error(`Type check of ${name} failed:\n${stdout}${stderr}`); + } +}; + +afterAll(() => { + fs.rmSync(typecheckDir, { recursive: true, force: true }); +}); + +describe("k6 plugin", () => { + describe.each([ + { documentMode: DocumentMode.graphQLTag, useTypeImports: false }, + { documentMode: DocumentMode.graphQLTag, useTypeImports: true }, + { documentMode: DocumentMode.documentNode, useTypeImports: false }, + { + documentMode: DocumentMode.documentNodeImportFragments, + useTypeImports: false, + }, + ])( + "documentMode $documentMode (useTypeImports: $useTypeImports)", + (config) => { + it("generates the sdk", async () => { + const output = await generateK6(config); + expect(output).toMatchSnapshot(); + + const documentNodeImport = config.useTypeImports + ? "import type { DocumentNode } from 'graphql';" + : "import { DocumentNode } from 'graphql';"; + expect( + output.split("\n").filter((line) => line === documentNodeImport), + ).toHaveLength(1); + expect(output).toContain("(doc: DocumentNode, vars?: V"); + }); + + it("type checks", { timeout: 30_000 }, async () => { + const name = `${config.documentMode}${config.useTypeImports ? "-type-imports" : ""}`; + typecheck(name, { + "sdk.ts": await generateSdk(config), + "script.ts": documentNodeUsage + sdkCalls, + }); + }); + }, + ); + + describe("documentMode string", () => { + const config = { documentMode: DocumentMode.string }; + + it("generates the sdk", async () => { + const output = await generateK6(config); + expect(output).toMatchSnapshot(); + + expect(output).toContain( + "export class TypedDocumentString extends String", + ); + expect(output).toContain( + "as unknown as TypedDocumentString;", + ); + expect(output).toContain("(doc: TypedDocumentString, vars?: V"); + expect(output).not.toContain("DocumentNode"); + }); + + it("type checks", { timeout: 30_000 }, async () => { + typecheck("string", { + "sdk.ts": await generateSdk(config), + "script.ts": stringUsage + sdkCalls, + }); + }); + + it("serializes the document as the query string", async () => { + const output = await generateK6(config); + const source = output.slice( + output.indexOf("export class TypedDocumentString"), + output.indexOf("export const"), + ); + // Let vite transpile the generated class and evaluate it + const dir = path.join(typecheckDir, "runtime"); + fs.mkdirSync(dir, { recursive: true }); + const file = path.join(dir, "typed-document-string.ts"); + fs.writeFileSync(file, source); + const { TypedDocumentString } = await import(file); + const doc = new TypedDocumentString("query A { a }"); + expect(JSON.stringify({ query: doc })).toBe('{"query":"query A { a }"}'); + expect(String(doc)).toBe("query A { a }"); + }); + }); + + describe("documentMode external", () => { + const config = { + documentMode: DocumentMode.external, + importDocumentNodeExternallyFrom: "./operations", + }; + + it("generates the sdk", async () => { + const output = await generateK6(config); + expect(output).toMatchSnapshot(); + expect(output).toContain("import * as Operations from './operations';"); + expect(output).toContain("import { DocumentNode } from 'graphql';"); + }); + + it("type checks", { timeout: 30_000 }, async () => { + typecheck("external", { + "operations.ts": await generateSdk({ + documentMode: DocumentMode.documentNode, + }), + "sdk.ts": await generateSdk(config), + "script.ts": documentNodeUsage + sdkCalls, + }); + }); + }); + + it("only generates documents and the sdk, no schema types", async () => { + // typescript-operations v6 generates the enum and input types it needs; + // the k6 plugin must not declare any of them (again). + const output = await generateK6({}); + const declarations = [ + ...output.matchAll( + /^\s*export (?:type|enum|const|class|function) (\w+)/gm, + ), + ].map((match) => match[1]); + expect(declarations).toEqual([ + "ProductPriceFragmentDoc", + "ProductFieldsFragmentDoc", + "GetProductDocument", + "ListProductsDocument", + "AddToCartDocument", + "CartUpdatedDocument", + "Requester", + "getSdk", + "Sdk", + ]); + }); + + it("rejects unnamed operations", async () => { + expect(() => + plugin( + schema, + [ + { + location: "anonymous.graphql", + document: parse("{ products { id } }"), + }, + ], + {}, + { outputFile: "sdk.ts" }, + ), + ).toThrow(/cannot generate SDK for unnamed operation/); }); }); diff --git a/packages/k6/src/index.ts b/packages/k6/src/index.ts index 9080902..2000815 100644 --- a/packages/k6/src/index.ts +++ b/packages/k6/src/index.ts @@ -48,6 +48,9 @@ export const plugin: PluginFunction = ( return { prepend: visitor.getImports(), content: [ + ...(visitor.typedDocumentStringClass + ? [visitor.typedDocumentStringClass] + : []), visitor.fragments, ...visitorResult.definitions.filter((t) => typeof t === "string"), visitor.sdkContent, diff --git a/packages/k6/src/visitor.ts b/packages/k6/src/visitor.ts index fab4f9e..81f8986 100644 --- a/packages/k6/src/visitor.ts +++ b/packages/k6/src/visitor.ts @@ -7,6 +7,7 @@ import { } from "@graphql-codegen/visitor-plugin-common"; import autoBind from "auto-bind"; import { + type FragmentDefinitionNode, type GraphQLSchema, Kind, type OperationDefinitionNode, @@ -55,7 +56,7 @@ export class GenericSdkVisitor extends ClientSideBaseVisitor< ): string { if (node.name == null) { throw new Error( - `PlugJn '@labdigital/graphql-codegen-k6' cannot generate SDK for unnamed operation.\n\n${print(node)}`, + `Plugin '@labdigital/graphql-codegen-k6' cannot generate SDK for unnamed operation.\n\n${print(node)}`, ); } else { this._operationsToInclude.push({ @@ -71,6 +72,77 @@ export class GenericSdkVisitor extends ClientSideBaseVisitor< return ""; } + /** + * The `Requester` type refers to `DocumentNode` in every mode except + * `string`, but the base visitor only imports it for the `documentNode*` + * modes. Add the import for the other modes as well. + */ + public getImports(options: { excludeFragments?: boolean } = {}): string[] { + const imports = super.getImports(options); + if (this.config.documentMode === DocumentMode.string) { + return imports; + } + const documentNodeImport = this._generateImport( + this._parseImport( + this.config.documentNodeImport || "graphql#DocumentNode", + ), + "DocumentNode", + true, + ); + if (documentNodeImport && !imports.includes(documentNodeImport)) { + imports.push(documentNodeImport); + } + return imports; + } + + /** + * With `documentMode: "string"` the documents are emitted as + * `new TypedDocumentString(...)`; type them with the result and variables + * of the operation, the same way `typed-document-node` does. + */ + protected getDocumentNodeSignature( + resultType: string, + variablesTypes: string, + node: FragmentDefinitionNode | OperationDefinitionNode, + ): string { + if ( + this.config.documentMode === DocumentMode.string && + node.kind === Kind.OPERATION_DEFINITION + ) { + return ` as unknown as TypedDocumentString<${this._externalImportPrefix}${resultType}, ${this._externalImportPrefix}${variablesTypes}>`; + } + return super.getDocumentNodeSignature(resultType, variablesTypes, node); + } + + /** + * The `TypedDocumentString` class used by `documentMode: "string"`. This is + * the same class as generated by `typed-document-node` and the client + * preset, without the dependency on `@graphql-typed-document-node/core` + * (the `__apiType` property is structurally compatible with its + * `DocumentTypeDecoration`). + */ + public get typedDocumentStringClass(): string { + if (this.config.documentMode !== DocumentMode.string) { + return ""; + } + return `export class TypedDocumentString extends String { + __apiType?: (variables: TVariables) => TResult; + private value: string; + public __meta__?: Record | undefined; + + constructor(value: string, __meta__?: Record | undefined) { + super(value); + this.value = value; + this.__meta__ = __meta__; + } + + override toString(): string { + return this.value; + } +} +`; + } + private getDocumentNodeVariable(documentVariableName: string): string { return this.config.documentMode === DocumentMode.external ? `Operations.${documentVariableName}` @@ -102,7 +174,7 @@ export class GenericSdkVisitor extends ClientSideBaseVisitor< const documentNodeType = this.config.documentMode === DocumentMode.string - ? "string" + ? "TypedDocumentString" : "DocumentNode"; return ` diff --git a/packages/k6/tsconfig.json b/packages/k6/tsconfig.json index 876f1c0..5e8378f 100644 --- a/packages/k6/tsconfig.json +++ b/packages/k6/tsconfig.json @@ -6,7 +6,8 @@ "outDir": "./dist", "esModuleInterop": true, "skipLibCheck": true, - "strict": true + "strict": true, + "types": ["node"] }, "include": ["src/**/*.ts"] } diff --git a/packages/k6/vitest.config.ts b/packages/k6/vitest.config.ts new file mode 100644 index 0000000..5050228 --- /dev/null +++ b/packages/k6/vitest.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + // graphql 17 exposes a separate "development" build; make sure the test + // files and the (externalized) graphql-tools loaders share one instance. + ssr: { + resolve: { + conditions: ["module-sync", "node", "import", "default"], + externalConditions: ["module-sync", "node"], + }, + }, + test: { + testTimeout: 30000, + coverage: { + provider: "v8", + include: ["src/**/*.ts"], + }, + passWithNoTests: true, + server: { + deps: { + fallbackCJS: true, + }, + }, + }, +}); diff --git a/packages/typedefs/README.md b/packages/typedefs/README.md index 814554a..de871ad 100644 --- a/packages/typedefs/README.md +++ b/packages/typedefs/README.md @@ -55,7 +55,9 @@ const schema = makeExecutableSchema({ typeDefs, resolvers }); ## Configuration -This plugin has no configuration options. +| Option | Type | Default | Description | +| ------ | ---- | ------- | ----------- | +| `extendSchema` | `boolean` | `true` | Print the schema definition as an extension, `extend schema { ... }`, instead of `schema { ... }`. | ## Example output @@ -88,7 +90,7 @@ the plugin generates `src/generated/typedefs.ts`: import { gql } from "graphql-tag"; export const typeDefs = gql` - extend schema { + extend schema { query: Query mutation: Mutation } @@ -118,9 +120,14 @@ export const typeDefs = gql` - The schema is printed with `printSchemaWithDirectives` from `@graphql-tools/utils`, so directives on the schema and its types are kept. Types are sorted alphabetically. -- The output starts with the schema definition, printed as an extension: - `extend schema { ... }`. `makeExecutableSchema` from `@graphql-tools/schema` - accepts this as is. +- The schema definition (the root operation types) is printed as an extension, + `extend schema { ... }`, which `graphql`'s `buildSchema` and + `makeExecutableSchema` from `@graphql-tools/schema` accept as is, and which + can be merged with other type definitions that define the schema. Set + `extendSchema: false` to print a plain `schema { ... }` definition instead. + Schema extensions cannot have a description, so the description of the + schema (if any) is only kept with `extendSchema: false`. A schema without + root types has no schema definition; nothing is extended then. - Backticks in the schema (for example in descriptions) are escaped. - The schema is written as a whole; there is no option to include or exclude types. diff --git a/packages/typedefs/src/__snapshots__/index.test.ts.snap b/packages/typedefs/src/__snapshots__/index.test.ts.snap new file mode 100644 index 0000000..325f4ff --- /dev/null +++ b/packages/typedefs/src/__snapshots__/index.test.ts.snap @@ -0,0 +1,32 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`typedefs plugin > generates the typeDefs module 1`] = ` +"// This file is automatically generated by @labdigital/graphql-codegen-typedefs +import { gql } from "graphql-tag"; + +export const typeDefs = gql\` + extend schema { + query: Query + mutation: Mutation + } + + type Query { + """Uses \\\`backticks\\\`""" + product(id: ID!): Product + } + + type Mutation { + addToCart(productId: ID!, quantity: Int!): Cart! + } + + type Product { + id: ID! + name: String! + } + + type Cart { + id: ID! + totalItems: Int! + } +\`;" +`; diff --git a/packages/typedefs/src/index.test.ts b/packages/typedefs/src/index.test.ts new file mode 100644 index 0000000..9a42133 --- /dev/null +++ b/packages/typedefs/src/index.test.ts @@ -0,0 +1,91 @@ +import { buildSchema, printSchema } from "graphql"; +import { describe, expect, it } from "vitest"; +import { plugin } from "./index"; + +/** Returns the SDL inside the generated gql`...` template literal. */ +const extractSDL = (output: string): string => { + const match = output.match(/gql`\n([\s\S]*)\n`;$/); + if (!match) { + throw new Error(`Unexpected output:\n${output}`); + } + return match[1].replace(/\\`/g, "`"); +}; + +const schemaSDL = /* GraphQL */ ` + type Query { + "Uses \`backticks\`" + product(id: ID!): Product + } + + type Mutation { + addToCart(productId: ID!, quantity: Int!): Cart! + } + + type Product { + id: ID! + name: String! + } + + type Cart { + id: ID! + totalItems: Int! + } +`; + +describe("typedefs plugin", () => { + it("generates the typeDefs module", () => { + const output = plugin(buildSchema(schemaSDL)); + expect(output).toMatchSnapshot(); + expect(extractSDL(output)).toMatch(/^\textend schema \{\n/); + }); + + it("generates valid SDL that builds the same schema", () => { + const schema = buildSchema(schemaSDL); + for (const extendSchema of [true, false]) { + const sdl = extractSDL(plugin(schema, [], { extendSchema })); + expect(printSchema(buildSchema(sdl))).toBe(printSchema(schema)); + } + }); + + it("prints a plain schema definition when extendSchema is false", () => { + const sdl = extractSDL( + plugin(buildSchema(schemaSDL), [], { extendSchema: false }), + ); + expect(sdl).toMatch(/^\tschema \{\n/); + expect(sdl).not.toContain("extend"); + }); + + it("only extends the schema definition", () => { + // A schema without root types has no schema definition; previously the + // output started with `extend type A` or `extend directive @x`. + for (const input of [ + "type A { a: Int }", + "directive @x on FIELD_DEFINITION type A { a: Int @x }", + ]) { + const schema = buildSchema(input); + const sdl = extractSDL(plugin(schema)); + expect(sdl).not.toContain("extend"); + expect(printSchema(buildSchema(sdl))).toBe(printSchema(schema)); + } + }); + + it("handles a schema definition with a description and directives", () => { + const schema = buildSchema(` + "The schema" + schema @meta { query: Root } + directive @meta on SCHEMA + type Root { a: Int } + `); + + // Extensions cannot have a description + const extended = extractSDL(plugin(schema)); + expect(extended).toMatch( + /^\textend schema @meta \{\n\t\tquery: Root\n\t\}/, + ); + expect(buildSchema(extended).getQueryType()?.name).toBe("Root"); + + const plain = extractSDL(plugin(schema, [], { extendSchema: false })); + expect(plain).toMatch(/^\t"""The schema"""\n\tschema @meta \{/); + expect(buildSchema(plain).description).toBe("The schema"); + }); +}); diff --git a/packages/typedefs/src/index.ts b/packages/typedefs/src/index.ts index 3325975..9c88ee1 100644 --- a/packages/typedefs/src/index.ts +++ b/packages/typedefs/src/index.ts @@ -1,5 +1,19 @@ -import { printSchemaWithDirectives } from "@graphql-tools/utils"; -import type { GraphQLSchema } from "graphql"; +import { getDocumentNodeFromSchema } from "@graphql-tools/utils"; +import { + type DefinitionNode, + type GraphQLSchema, + Kind, + print as printAST, + type SchemaExtensionNode, +} from "graphql"; + +export interface TypedefsPluginConfig { + /** + * Print the schema definition (`schema { query: Query ... }`) as an + * extension: `extend schema { ... }`. Defaults to `true`. + */ + extendSchema?: boolean; +} const print = (schema: string): string => { let escaped = schema.replace(/\\`/g, "\\\\`").replace(/`/g, "\\`"); @@ -24,11 +38,38 @@ const print = (schema: string): string => { import { gql } from "graphql-tag"; export const typeDefs = gql\` - extend ${escaped} +${escaped} \`; `.trim(); }; -export const plugin = (schema: GraphQLSchema): string => { - return print(printSchemaWithDirectives(schema)); +/** + * Converts the schema definition to a schema extension. Extensions cannot + * have a description, so that is dropped. + */ +const toSchemaExtension = (definition: DefinitionNode): DefinitionNode => { + if (definition.kind !== Kind.SCHEMA_DEFINITION) { + return definition; + } + const extension: SchemaExtensionNode = { + kind: Kind.SCHEMA_EXTENSION, + directives: definition.directives, + operationTypes: definition.operationTypes, + }; + return extension; +}; + +export const plugin = ( + schema: GraphQLSchema, + _documents?: unknown, + config: TypedefsPluginConfig = {}, +): string => { + // Same as `printSchemaWithDirectives` from @graphql-tools/utils + const document = getDocumentNodeFromSchema(schema); + const definitions = + config.extendSchema === false + ? document.definitions + : document.definitions.map(toSchemaExtension); + + return print(printAST({ ...document, definitions })); }; diff --git a/packages/typedefs/vitest.config.ts b/packages/typedefs/vitest.config.ts index a5ebfc5..e588c09 100644 --- a/packages/typedefs/vitest.config.ts +++ b/packages/typedefs/vitest.config.ts @@ -2,6 +2,14 @@ import path from "node:path"; import { defineConfig } from "vitest/config"; export default defineConfig({ + // graphql 17 exposes a separate "development" build; make sure the test + // files and the (externalized) graphql-tools utils share one instance. + ssr: { + resolve: { + conditions: ["module-sync", "node", "import", "default"], + externalConditions: ["module-sync", "node"], + }, + }, test: { coverage: { provider: "v8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4d1f270..133b216 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -209,9 +209,6 @@ importers: graphql: specifier: ^17.0.2 version: 17.0.2 - prettier: - specifier: ^3.9.10 - version: 3.9.10 publint: specifier: ^0.3.25 version: 0.3.25 @@ -236,13 +233,22 @@ importers: auto-bind: specifier: 5.0.1 version: 5.0.1 - graphql-tag: - specifier: ^2.0.0 - version: 2.12.7(graphql@17.0.2) devDependencies: + '@graphql-codegen/typescript-operations': + specifier: ^6.1.10 + version: 6.1.10(graphql@17.0.2) + '@types/k6': + specifier: ^2.3.0 + version: 2.3.0 + '@types/node': + specifier: ^26.6.5 + version: 26.6.5 graphql: specifier: ^17.0.2 version: 17.0.2 + graphql-tag: + specifier: ^2.0.0 + version: 2.12.7(graphql@17.0.2) publint: specifier: ^0.3.25 version: 0.3.25 @@ -444,6 +450,22 @@ packages: 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 + '@graphql-codegen/schema-ast@6.1.1': + resolution: {integrity: sha512-9THb+iRVIDdRBVBL/FIAXIVXx++XkGxynqVCco7F5lKY4NYy07fB4oRcLGIpKuLGNeGNcRkTaQ3x7DNyojbC+A==} + 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 || ^17.0.0 + + '@graphql-codegen/typescript-operations@6.1.10': + resolution: {integrity: sha512-hTnwAwQI+/lV0mAlWV0Jg7jBo2XCC++8+njr0meDHQbeAJSH6Pew1ZIcmLVvQUsQNSQwNoR4SKftHbDvq6KoNA==} + 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 || ^17.0.0 + graphql-sock: ^1.0.0 + peerDependenciesMeta: + graphql-sock: + optional: true + '@graphql-codegen/visitor-plugin-common@7.2.10': resolution: {integrity: sha512-o1a71JUeMNfEwxr7Vzokd3ufvrESL4+xhwJFzcBkc8Nurr5eAELi5gNUFQOUsEsU4169PEBIOzSL4iqO8OpBag==} engines: {node: '>=16'} @@ -689,6 +711,9 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/k6@2.3.0': + resolution: {integrity: sha512-kEiRQx9MRQZ0vAwWgZUyYvuEc1JC++OZbH9uHVLsSOIKFLwHz8UeIV1EuIlB4Fqn03A4ZM+E624zVsvQPAAp6Q==} + '@types/node@26.6.5': resolution: {integrity: sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==} @@ -1277,11 +1302,6 @@ packages: resolution: {integrity: sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==} engines: {node: ^10 || ^12 || >=14} - prettier@3.9.10: - resolution: {integrity: sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==} - engines: {node: '>=14'} - hasBin: true - publint@0.3.25: resolution: {integrity: sha512-WMMbn/d7JqRlfXQL/JUI4n5skC8cJ5WVr8/wT5mmiYWkUdENkZr1I5rgs2+dQPVRgoJd+JRYtB46MTsuDylw9g==} engines: {node: '>=18'} @@ -1806,6 +1826,22 @@ snapshots: import-from: 4.0.0 tslib: 2.8.1 + '@graphql-codegen/schema-ast@6.1.1(graphql@17.0.2)': + dependencies: + '@graphql-codegen/plugin-helpers': 7.4.2(graphql@17.0.2) + '@graphql-tools/utils': 12.0.3(graphql@17.0.2) + graphql: 17.0.2 + tslib: 2.8.1 + + '@graphql-codegen/typescript-operations@6.1.10(graphql@17.0.2)': + dependencies: + '@graphql-codegen/plugin-helpers': 7.4.2(graphql@17.0.2) + '@graphql-codegen/schema-ast': 6.1.1(graphql@17.0.2) + '@graphql-codegen/visitor-plugin-common': 7.2.10(graphql@17.0.2) + auto-bind: 5.0.1 + graphql: 17.0.2 + tslib: 2.8.1 + '@graphql-codegen/visitor-plugin-common@7.2.10(graphql@17.0.2)': dependencies: '@graphql-codegen/plugin-helpers': 7.4.2(graphql@17.0.2) @@ -2003,6 +2039,8 @@ snapshots: '@types/estree@1.0.9': {} + '@types/k6@2.3.0': {} + '@types/node@26.6.5': dependencies: undici-types: 8.9.0 @@ -2422,8 +2460,6 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.2 - prettier@3.9.10: {} - publint@0.3.25: dependencies: '@publint/pack': 0.1.7