diff --git a/.changeset/improve-readme.md b/.changeset/improve-readme.md new file mode 100644 index 0000000..28ee380 --- /dev/null +++ b/.changeset/improve-readme.md @@ -0,0 +1,7 @@ +--- +"@labdigital/graphql-codegen-bruno": patch +"@labdigital/graphql-codegen-k6": patch +"@labdigital/graphql-codegen-typedefs": patch +--- + +Improve README diff --git a/.nvmrc b/.nvmrc deleted file mode 100644 index 2bd5a0a..0000000 --- a/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -22 diff --git a/README.md b/README.md new file mode 100644 index 0000000..1951566 --- /dev/null +++ b/README.md @@ -0,0 +1,86 @@ +# GraphQL Codegen Plugins + +Plugins for [GraphQL Code Generator](https://the-guild.dev/graphql/codegen), +maintained by [Lab Digital](https://www.labdigital.nl). + +## Packages + +| Package | Version | Description | +| ------- | ------- | ----------- | +| [`@labdigital/graphql-codegen-bruno`](./packages/bruno/README.md) | [![npm](https://img.shields.io/npm/v/@labdigital/graphql-codegen-bruno)](https://www.npmjs.com/package/@labdigital/graphql-codegen-bruno) | Generates a [Bruno](https://www.usebruno.com) request (`.bru` file) for every GraphQL operation in your codebase. | +| [`@labdigital/graphql-codegen-k6`](./packages/k6/README.md) | [![npm](https://img.shields.io/npm/v/@labdigital/graphql-codegen-k6)](https://www.npmjs.com/package/@labdigital/graphql-codegen-k6) | Generates a typed SDK for your GraphQL operations that you can use in [k6](https://k6.io) load tests. | +| [`@labdigital/graphql-codegen-typedefs`](./packages/typedefs/README.md) | [![npm](https://img.shields.io/npm/v/@labdigital/graphql-codegen-typedefs)](https://www.npmjs.com/package/@labdigital/graphql-codegen-typedefs) | Generates a TypeScript module that exports your GraphQL schema as a `typeDefs` document. | + +## Requirements + +- Node.js 22 or newer +- `graphql` `^16.0.0 || ^17.0.0` +- `@graphql-codegen/cli` 7.x + +All packages are published as ES modules only. + +## Usage + +Install the CLI and the plugin you need, then reference the plugin by its +package name in your `codegen.ts`: + +```sh +pnpm add -D @graphql-codegen/cli graphql @labdigital/graphql-codegen-typedefs +``` + +```ts +import type { CodegenConfig } from "@graphql-codegen/cli"; + +const config: CodegenConfig = { + schema: "schema.graphql", + generates: { + "src/generated/typedefs.ts": { + plugins: ["@labdigital/graphql-codegen-typedefs"], + }, + }, +}; + +export default config; +``` + +```sh +pnpm graphql-codegen --config codegen.ts +``` + +See the README of each package for its configuration options and example +output. + +## Development + +This is a [pnpm](https://pnpm.io) workspace (pnpm 12, see `packageManager` in +`package.json`) using [Turborepo](https://turborepo.com) for builds, +[tsdown](https://tsdown.dev) for bundling, [Vitest](https://vitest.dev) for +tests and [Biome](https://biomejs.dev) for formatting and linting. + +```sh +pnpm install # install dependencies +pnpm build # build all packages (packages/*/dist) +pnpm test # run the tests +pnpm check # biome ci + typecheck, as run in CI +pnpm format # apply biome formatting and safe fixes +``` + +## Releasing + +Releases are managed with [Changesets](https://github.com/changesets/changesets). + +1. Add a changeset to every pull request that changes a published package: + + ```sh + pnpm changeset + ``` + +2. When the pull request is merged into `main`, the release workflow opens (or + updates) a "Release new version" pull request that bumps the versions and + updates the changelogs. +3. Merging that pull request publishes the new versions to npm (with + provenance, via npm trusted publishing). + +## License + +[MIT](./LICENSE) © Lab Digital diff --git a/packages/bruno/README.md b/packages/bruno/README.md index 746a612..51cc699 100644 --- a/packages/bruno/README.md +++ b/packages/bruno/README.md @@ -1,42 +1,51 @@ -## GraphQL Codegen Bruno Plugin +# @labdigital/graphql-codegen-bruno [![npm](https://img.shields.io/npm/v/@labdigital/graphql-codegen-bruno)](https://www.npmjs.com/package/@labdigital/graphql-codegen-bruno) -This plugin generates a directory with -[Bruno](https://github.com/usebruno/bruno) files with all GraphQL queries in -your codebase +A [GraphQL Code Generator](https://the-guild.dev/graphql/codegen) plugin that +generates a [Bruno](https://www.usebruno.com) request (`.bru` file) for every +named GraphQL operation in your codebase. + +Use it to keep an API collection in sync with the queries and mutations your +application actually sends: every request contains the operation, the +fragments it uses and example variables derived from the schema, so you can +run it from Bruno right away. + +## Installation -# Installation ```sh -pnpm add -d @labdigital/graphql-codegen-bruno +pnpm add -D @labdigital/graphql-codegen-bruno @graphql-codegen/cli graphql ``` -# Usage -Create a `codegen.ts` file in the root of your project with the following content: +## Usage + +Add an output to your `codegen.ts`. The plugin writes the `.bru` files to the +**directory** of the output file; the output file itself gets a JSON index of +the generated requests. ```ts import type { CodegenConfig } from "@graphql-codegen/cli"; const config: CodegenConfig = { - schema: - "schema.graphql", - documents: [ - "src/**/*.{ts,tsx}" - ], + schema: "schema.graphql", + documents: ["src/**/*.graphql"], generates: { - // Generate Bruno files in the api-collection directory - "api-collection/generated.json": { + // The .bru files are written to the directory of this output file. + // The file itself contains an index of the generated requests. + "api-collection/generated/index.json": { plugins: ["@labdigital/graphql-codegen-bruno"], config: { - - // Clean the output directory before generating the files - clean: true - - // Set default values for the generated Bruno variables + // Empty the output directory before generating + clean: true, + // Headers added to every request + headers: { + Authorization: "Bearer {{token}}", + }, + // Override the generated example value of top-level variables defaults: { locale: "nl-NL", - currency: "EUR" - } + }, + }, }, }, }; @@ -44,14 +53,154 @@ const config: CodegenConfig = { export default config; ``` -Then run the following command: +Then run: ```sh -pnpx graphql-codegen --config codegen.ts +pnpm graphql-codegen --config codegen.ts +``` + +This results in: + +```text +api-collection/ +├── bruno.json # your Bruno collection (not generated) +└── generated/ + ├── index.json + ├── mutations/ + │ └── AddToCart.bru + └── queries/ + ├── GetProduct.bru + └── ListProducts.bru +``` + +The requests are sent to `{{graphql-gateway}}/graphql`, so define a +`graphql-gateway` variable (for example `http://localhost:4000`) in your Bruno +environment. + +## Configuration + +| Option | Type | Default | Description | +| ------ | ---- | ------- | ----------- | +| `clean` | `boolean` | `false` | Remove everything in the output directory (the directory of the output file) before generating. | +| `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. | + +## Example output + +Given this schema and operation (with the `ProductFields` fragment defined in +another file): + +```graphql +type Query { + product(id: ID!, locale: String): Product +} + +type Product { + id: ID! + name: String! + price: Float! +} ``` -# Notes -The `api-collection/generated.json` seems to be needed for GraphQL codegen -to work (we need to generate a file, not a directory). If you know a way to -generate a directory instead of a file, please us know +```graphql +query GetProduct($id: ID!, $locale: String) { + product(id: $id, locale: $locale) { + ...ProductFields + } +} +``` + +the configuration above generates `queries/GetProduct.bru`: + +```text +meta { + name: GetProduct + type: graphql +} + +post { + url: {{graphql-gateway}}/graphql + body: graphql + auth: none +} + +headers { + Authorization: Bearer {{token}} +} + +body:graphql { + query GetProduct($id: ID!, $locale: String) { + product(id: $id, locale: $locale) { + ...ProductFields + } + } + fragment ProductFields on Product { + id + name + price + } + +} + +body:graphql:vars { + { + "id": "example-id", + "locale": "nl-NL" + } +} +``` + +and `index.json`, which maps each operation to its file and source: + +```json +{ + "AddToCart": { + "filename": "AddToCart.bru", + "source": "../../src/graphql/add-to-cart.graphql" + }, + "GetProduct": { + "filename": "GetProduct.bru", + "source": "../../src/graphql/get-product.graphql" + }, + "ListProducts": { + "filename": "ListProducts.bru", + "source": "../../src/graphql/list-products.graphql" + } +} +``` + +### Example variables + +Variables get an example value based on their type: + +| Type | Example value | +| ---- | ------------- | +| `Int` | `123` | +| `Float` | `123.45` | +| `String` | `"example"` | +| `Boolean` | `true` | +| `ID` | `"example-id"` | +| Custom scalar | `"example"` | +| Enum | The first enum value | +| Input object | An object with an example value for every field | +| List | A list with one example value | + +## Notes and limitations +- 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. +- 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. +- `defaults` only applies to top-level variables; it replaces the whole value, + it does not merge into input objects. diff --git a/packages/k6/README.md b/packages/k6/README.md index 1d3096e..ca367dc 100644 --- a/packages/k6/README.md +++ b/packages/k6/README.md @@ -2,5 +2,194 @@ [![npm](https://img.shields.io/npm/v/@labdigital/graphql-codegen-k6)](https://www.npmjs.com/package/@labdigital/graphql-codegen-k6) -This is a fork of https://github.com/dotansimha/graphql-code-generator-community/tree/main/packages/plugins/typescript/generic-sdk -to generate a SDK for k6 to test GraphQL APIs. +A [GraphQL Code Generator](https://the-guild.dev/graphql/codegen) plugin that +generates a typed SDK for your GraphQL operations, to use in +[k6](https://k6.io) load tests. + +The SDK has a method per named operation that takes typed variables and +returns the k6 `RefinedResponse`, so load test scripts can reuse the operations +of your application instead of copy-pasted query strings. + +This plugin is a fork of the +[`typescript-generic-sdk`](https://github.com/dotansimha/graphql-code-generator-community/tree/main/packages/plugins/typescript/generic-sdk) +plugin, adapted to the k6 HTTP API. + +## Installation + +```sh +pnpm add -D @labdigital/graphql-codegen-k6 @graphql-codegen/cli @graphql-codegen/typescript-operations graphql graphql-tag +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`. + +## Usage + +```ts +import type { CodegenConfig } from "@graphql-codegen/cli"; + +const config: CodegenConfig = { + schema: "schema.graphql", + documents: ["src/**/*.graphql"], + generates: { + "k6/sdk.ts": { + plugins: ["typescript-operations", "@labdigital/graphql-codegen-k6"], + config: { + // Embed the operations as DocumentNode objects (see "Notes") + documentMode: "documentNode", + useTypeImports: true, + }, + }, + }, +}; + +export default config; +``` + +```sh +pnpm graphql-codegen --config codegen.ts +``` + +`getSdk()` takes a *requester*: a function that receives the operation, its +variables and your options, and performs the HTTP request with k6: + +```ts +import { check } from "k6"; +import http, { type Params } from "k6/http"; +import { type DocumentNode, print } from "graphql"; +import { getSdk } from "./sdk"; + +const endpoint = __ENV.GRAPHQL_ENDPOINT ?? "http://localhost:4000/graphql"; + +const sdk = getSdk((doc: DocumentNode, variables, options) => + http.post( + endpoint, + JSON.stringify({ query: print(doc), variables }), + { + ...options, + headers: { "Content-Type": "application/json", ...options?.headers }, + }, + ), +); + +export default function () { + const res = sdk.GetProduct({ id: "123" }); + check(res, { + "status is 200": (r) => r.status === 200, + "no errors": (r) => !r.json("errors"), + }); +} +``` + +The second argument of every SDK method is passed to the requester as +`options`, so you can, for example, set tags or headers per request: +`sdk.GetProduct({ id: "123" }, { tags: { name: "GetProduct" } })`. + +k6 does not resolve npm packages, so bundle the script (and `graphql`) before +running it, with any bundler that keeps the `k6` and `k6/*` imports external, +for example esbuild: + +```sh +esbuild k6/script.ts --bundle --format=esm --external:k6 --external:'k6/*' --outfile=dist/script.js +k6 run dist/script.js +``` + +## Configuration + +The plugin accepts the configuration of the client-side plugins of GraphQL +Code Generator (`RawClientSideBasePluginConfig` from +[`@graphql-codegen/visitor-plugin-common`](https://github.com/dotansimha/graphql-code-generator/tree/master/packages/plugins/other/visitor-plugin-common)). +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. | +| `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`. | +| `documentNodeImport` | `string` | `"graphql#DocumentNode"` | Module (and export) to import the `DocumentNode` type from. | +| `useTypeImports` | `boolean` | `false` | Use `import type` for type-only imports, such as `DocumentNode`. | +| `noExport` | `boolean` | `false` | Generate the operation documents without `export`. | +| `documentVariablePrefix` | `string` | `""` | Prefix for the names of the generated operation documents. | +| `documentVariableSuffix` | `string` | `"Document"` | Suffix for the names of the generated operation documents. | +| `fragmentVariablePrefix` | `string` | `""` | Prefix for the names of the generated fragment documents. | +| `fragmentVariableSuffix` | `string` | `"FragmentDoc"` | Suffix for the names of the generated fragment documents. | +| `optimizeDocumentNode` | `boolean` | `true` | Apply document optimizations when `documentMode` is `documentNode` or `documentNodeImportFragments`. | +| `pureMagicComment` | `boolean` | `false` | Add a `/*#__PURE__*/` comment to the generated documents, for tree shaking. | +| `experimentalFragmentVariables` | `boolean` | `false` | Enable support for variables on fragments, when `documentMode` is `graphQLTag`. | +| `dedupeOperationSuffix` | `boolean` | `false` | Avoid duplicate operation suffixes in type names (for example `GetProductQueryQuery`). Use the same value as for `typescript-operations`. | +| `omitOperationSuffix` | `boolean` | `false` | Omit the `Query`/`Mutation`/`Subscription` suffix in type names. Use the same value as for `typescript-operations`. | +| `operationResultSuffix` | `string` | `""` | Suffix for the operation result type names. Use the same value as for `typescript-operations`. | +| `namingConvention` | `NamingConvention` | `"change-case-all#pascalCase"` | Naming convention for the generated names. Use the same value as for `typescript-operations`. | +| `typesPrefix` | `string` | `""` | Prefix for the referenced type names. Use the same value as for `typescript-operations`. | +| `typesSuffix` | `string` | `""` | Suffix for the referenced type names. Use the same value as for `typescript-operations`. | +| `externalFragments` | `LoadedFragment[]` | `[]` | Fragments that are not part of the documents but can be referenced by them. Usually set by presets. | + +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. + +## Example output + +For this operation: + +```graphql +query GetProduct($id: ID!, $locale: String) { + product(id: $id, locale: $locale) { + ...ProductFields + } +} +``` + +the configuration above generates (trimmed; the operation types come from +`typescript-operations`, the document objects are shortened): + +```ts +import { type RefinedResponse, type ResponseType } from "k6/http"; +import type { DocumentNode } from 'graphql'; + +export type GetProductQueryVariables = Exact<{ + id: string | number; + locale?: string | null | undefined; +}>; + +export type GetProductQuery = { product: { id: string, name: string, price: number } | null }; + +export const GetProductDocument = {"kind":"Document","definitions":[ /* ... */ ]} as unknown as DocumentNode; + + export type Requester = (doc: DocumentNode, vars?: V, options?: C) => RefinedResponse + + export function getSdk(requester: Requester) { + return { + AddToCart(variables: AddToCartMutationVariables, options?: C): RefinedResponse { + return requester(AddToCartDocument, variables, options); + }, + GetProduct(variables: GetProductQueryVariables, options?: C): RefinedResponse { + return requester(GetProductDocument, variables, options); + }, + ListProducts(variables?: ListProductsQueryVariables, options?: C): RefinedResponse { + return requester(ListProductsDocument, variables, options); + } + }; + } + export type Sdk = ReturnType; +``` + +The `variables` argument is optional when the operation has no required +variables (such as `ListProducts`). + +## Notes and limitations + +- 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. diff --git a/packages/typedefs/README.md b/packages/typedefs/README.md index 67d36b0..814554a 100644 --- a/packages/typedefs/README.md +++ b/packages/typedefs/README.md @@ -2,4 +2,125 @@ [![npm](https://img.shields.io/npm/v/@labdigital/graphql-codegen-typedefs)](https://www.npmjs.com/package/@labdigital/graphql-codegen-typedefs) -An implementation of https://github.com/dotansimha/graphql-code-generator/issues/3899 +A [GraphQL Code Generator](https://the-guild.dev/graphql/codegen) plugin that +generates a TypeScript module exporting your GraphQL schema as a `typeDefs` +document (parsed with `graphql-tag`). + +This lets a GraphQL server import its schema as code instead of reading +`.graphql` files from disk at runtime, which is convenient when the server is +bundled or deployed without its source files. It implements the feature +requested in +[dotansimha/graphql-code-generator#3899](https://github.com/dotansimha/graphql-code-generator/issues/3899). + +## Installation + +```sh +pnpm add -D @labdigital/graphql-codegen-typedefs @graphql-codegen/cli graphql +pnpm add graphql-tag +``` + +The generated file imports `gql` from `graphql-tag`, so `graphql-tag` must be +a (runtime) dependency of your project. + +## Usage + +```ts +import type { CodegenConfig } from "@graphql-codegen/cli"; + +const config: CodegenConfig = { + schema: "schema.graphql", + generates: { + "src/generated/typedefs.ts": { + plugins: ["@labdigital/graphql-codegen-typedefs"], + }, + }, +}; + +export default config; +``` + +```sh +pnpm graphql-codegen --config codegen.ts +``` + +Then use the generated `typeDefs` to build your schema, for example with +`@graphql-tools/schema`: + +```ts +import { makeExecutableSchema } from "@graphql-tools/schema"; +import { typeDefs } from "./generated/typedefs"; + +const schema = makeExecutableSchema({ typeDefs, resolvers }); +``` + +## Configuration + +This plugin has no configuration options. + +## Example output + +For this schema: + +```graphql +type Query { + product(id: ID!): Product +} + +type Mutation { + addToCart(productId: ID!, quantity: Int!): Cart! +} + +type Product { + id: ID! + name: String! +} + +type Cart { + id: ID! + totalItems: Int! +} +``` + +the plugin generates `src/generated/typedefs.ts`: + +```ts +// 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 Cart { + id: ID! + totalItems: Int! + } + + type Mutation { + addToCart(productId: ID!, quantity: Int!): Cart! + } + + type Product { + id: ID! + name: String! + } + + type Query { + product(id: ID!): Product + } +`; +``` + +## Notes + +- 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. +- 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.