diff --git a/README.md b/README.md index 074cb056..98a82913 100644 --- a/README.md +++ b/README.md @@ -260,6 +260,46 @@ Additionally, `GraphQlQueryResponseData` has been exposed to users: import type { GraphQlQueryResponseData } from "@octokit/graphql"; ``` +### Usage with graphql-codegen + +If your query is represented as a `String & DocumentTypeDecoration`, for example as [`TypedDocumentString` from graphql-codegen and its client preset](https://the-guild.dev/graphql/codegen/docs/guides/vanilla-typescript), then you can get type-safety for the query's parameters and return values. + +Assuming you have configured graphql-codegen, with [GitHub's GraphQL schema](https://docs.github.com/en/graphql/overview/public-schema), and an output under `./graphql`: + +```tsx +import * as octokit from "@octokit/graphql"; + +// The graphql query factory from graphql-codegen's output +import { graphql } from "./graphql/graphql.js"; + +const result = await octokit.graphql( + graphql` + query lastIssues($owner: String!, $repo: String!, $num: Int = 3) { + repository(owner: $owner, name: $repo) { + issues(last: $num) { + edges { + node { + title + } + } + } + } + } + `, + { + owner: "octokit", + repo: "graphql.js", + headers: { + authorization: `token secret123`, + }, + }, + // ^ parameters are required; owner and repo are type-checked +); + +type Return = typeof result; +// ^? { repository: {issues: {edges: Array<{node: {title: string}>}} } +``` + ## Errors In case of a GraphQL error, `error.message` is set to a combined message describing all errors returned by the endpoint. diff --git a/package-lock.json b/package-lock.json index 18a4a52b..d9970e7a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0-development", "license": "MIT", "dependencies": { + "@graphql-typed-document-node/core": "^3.2.0", "@octokit/request": "^9.0.0", "@octokit/types": "^13.0.0", "universal-user-agent": "^7.0.0" @@ -939,6 +940,14 @@ "node": ">=18" } }, + "node_modules/@graphql-typed-document-node/core": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", + "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -2687,6 +2696,15 @@ "dev": true, "license": "ISC" }, + "node_modules/graphql": { + "version": "16.9.0", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.9.0.tgz", + "integrity": "sha512-GGTKBX4SD7Wdb8mqeDLni2oaRGYQWjWHGKPQ24ZMnUtKfcsVoiv4uX8+LJr1K6U5VW2Lu1BwJnj7uiori0YtRw==", + "peer": true, + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", diff --git a/package.json b/package.json index 5c889368..2e96ff9d 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "author": "Gregor Martynus (https://github.com/gr2m)", "license": "MIT", "dependencies": { + "@graphql-typed-document-node/core": "^3.2.0", "@octokit/request": "^9.0.0", "@octokit/types": "^13.0.0", "universal-user-agent": "^7.0.0" diff --git a/src/types.ts b/src/types.ts index 21f151e5..133c9840 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,6 +4,8 @@ import type { EndpointInterface, } from "@octokit/types"; +import type { DocumentTypeDecoration } from "@graphql-typed-document-node/core"; + import type { graphql } from "./graphql.js"; export type GraphQlEndpointOptions = EndpointOptions & { @@ -33,6 +35,25 @@ export interface graphql { parameters?: RequestParameters, ): GraphQlResponse; + /** + * Sends a GraphQL query request based on endpoint options. The query parameters are type-checked + * + * @param {String & DocumentTypeDecoration} query GraphQL query. Example: `'query { viewer { login } }'`. + * @param {object} [parameters] URL, query or body parameters, as well as `headers`, `mediaType.{format|previews}`, `request`, or `baseUrl`. + */ + ( + query: String & DocumentTypeDecoration, + /** + * The tuple in rest parameters allows makes RequestParameters conditionally + * optional , if the query does not require any variables. + * + * @see https://github.com/Microsoft/TypeScript/pull/24897#:~:text=not%20otherwise%20observable).-,Optional%20elements%20in%20tuple%20types,-Tuple%20types%20now + */ + ...[parameters]: QueryVariables extends Record + ? [RequestParameters?] + : [QueryVariables & RequestParameters] + ): GraphQlResponse; + /** * Returns a new `endpoint` with updated route and parameters */ diff --git a/src/with-defaults.ts b/src/with-defaults.ts index 1fae2cb9..3e8a510e 100644 --- a/src/with-defaults.ts +++ b/src/with-defaults.ts @@ -5,6 +5,7 @@ import type { RequestParameters, } from "./types.js"; import { graphql } from "./graphql.js"; +import type { DocumentTypeDecoration } from "@graphql-typed-document-node/core"; export function withDefaults( request: typeof Request, @@ -12,10 +13,26 @@ export function withDefaults( ): ApiInterface { const newRequest = request.defaults(newDefaults); const newApi = ( - query: Query | RequestParameters, + query: + | Query + | (String & DocumentTypeDecoration) + | RequestParameters, options?: RequestParameters, ) => { - return graphql(newRequest, query, options); + const innerQuery = + typeof query === "string" + ? query + : // Allows casting String & DocumentTypeDecoration to + // string. This could be replaced with an instanceof check if we had + // access to a shared TypedDocumentString. Alternatively, we could use + // string & TypedDocumentDecoration as the external + // interface, and push `.toString()` onto the caller, which might not + // be the worst idea. + String.prototype.isPrototypeOf(query) + ? query.toString() + : (query as RequestParameters); + + return graphql(newRequest, innerQuery, options); }; return Object.assign(newApi, { diff --git a/test/graphql.test.ts b/test/graphql.test.ts index caa66746..41ad3844 100644 --- a/test/graphql.test.ts +++ b/test/graphql.test.ts @@ -5,9 +5,25 @@ import type * as OctokitTypes from "@octokit/types"; import { graphql } from "../src"; import { VERSION } from "../src/version"; import type { RequestParameters } from "../src/types"; +import type { DocumentTypeDecoration } from "@graphql-typed-document-node/core"; const userAgent = `octokit-graphql.js/${VERSION} ${getUserAgent()}`; +class TypedDocumentString + extends String + implements DocumentTypeDecoration +{ + __apiType?: DocumentTypeDecoration["__apiType"]; + + constructor(private value: string) { + super(value); + } + + toString(): string & DocumentTypeDecoration { + return this.value; + } +} + describe("graphql()", () => { it("is a function", () => { expect(graphql).toBeInstanceOf(Function); @@ -75,6 +91,72 @@ describe("graphql()", () => { }); }); + it("README TypedDocumentString example", () => { + const mockData = { + repository: { + issues: { + edges: [ + { + node: { + title: "Foo", + }, + }, + { + node: { + title: "Bar", + }, + }, + { + node: { + title: "Baz", + }, + }, + ], + }, + }, + }; + + const RepositoryDocument = new TypedDocumentString< + { + repository: { issues: { edges: Array<{ node: { title: string } }> } }; + }, + Record + >(/* GraphQL */ ` + { + repository(owner: "octokit", name: "graphql.js") { + issues(last: 3) { + edges { + node { + title + } + } + } + } + } + `); + + return graphql(RepositoryDocument, { + headers: { + authorization: `token secret123`, + }, + request: { + fetch: fetchMock.sandbox().post( + "https://api.github.com/graphql", + { data: mockData }, + { + headers: { + accept: "application/vnd.github.v3+json", + authorization: "token secret123", + "user-agent": userAgent, + }, + }, + ), + }, + }).then((result) => { + expect(JSON.stringify(result)).toStrictEqual(JSON.stringify(mockData)); + }); + }); + it("Variables", () => { const query = `query lastIssues($owner: String!, $repo: String!, $num: Int = 3) { repository(owner:$owner, name:$repo) { @@ -114,6 +196,54 @@ describe("graphql()", () => { }); }); + it("Variables with TypedDocumentString", () => { + const query = new TypedDocumentString< + { + repository: { issues: { edges: Array<{ node: { title: string } }> } }; + }, + { + owner: string; + repo: string; + num?: number; + } + >(`query lastIssues($owner: String!, $repo: String!, $num: Int = 3) { + repository(owner:$owner, name:$repo) { + issues(last:$num) { + edges { + node { + title + } + } + } + } + }`); + + return graphql(query, { + headers: { + authorization: `token secret123`, + }, + owner: "octokit", + repo: "graphql.js", + request: { + fetch: fetchMock + .sandbox() + .post( + "https://api.github.com/graphql", + (_url, options: OctokitTypes.RequestOptions) => { + const body = JSON.parse(options.body); + expect(body.query).toEqual(query.toString()); + expect(body.variables).toStrictEqual({ + owner: "octokit", + repo: "graphql.js", + }); + + return { data: {} }; + }, + ), + }, + }); + }); + it("Pass headers together with variables as 2nd argument", () => { const query = `query lastIssues($owner: String!, $repo: String!, $num: Int = 3) { repository(owner:$owner, name:$repo) { diff --git a/test/tsconfig.test.json b/test/tsconfig.test.json index cf1f2005..4d1db377 100644 --- a/test/tsconfig.test.json +++ b/test/tsconfig.test.json @@ -3,7 +3,14 @@ "compilerOptions": { "emitDeclarationOnly": false, "noEmit": true, - "allowImportingTsExtensions": true + "allowImportingTsExtensions": true, + // TODO: This setting is not compatible with the default definition of + // DocumentTypeDecoration from '@graphql-typed-document-node/core'. That + // definition includes `{__apiType?: ...}` as an optional, so assigning it + // to an explicit `undefined` is impossible under + // exactOptionalPropertyTypes. We only need to work around this in tests, + // since that is where we use concrete examples of DocumentTypeDecoration. + "exactOptionalPropertyTypes": false }, "include": ["src/**/*"] }