Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support TypedDocumentString as query argument #609

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Comment on lines +265 to +267
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm trying to balance giving a complete guide to graphql-codegen and the GitHub GraphQL API, but maybe we can be more descriptive here 💭


```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.
Expand Down
18 changes: 18 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
21 changes: 21 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 & {
Expand Down Expand Up @@ -33,6 +35,25 @@ export interface graphql {
parameters?: RequestParameters,
): GraphQlResponse<ResponseData>;

/**
* Sends a GraphQL query request based on endpoint options. The query parameters are type-checked
*
* @param {String & DocumentTypeDecoration<ResponseData, QueryVariables>} 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`.
*/
<ResponseData, QueryVariables>(
query: String & DocumentTypeDecoration<ResponseData, QueryVariables>,
/**
* 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<string, never>
? [RequestParameters?]
: [QueryVariables & RequestParameters]
): GraphQlResponse<ResponseData>;

/**
* Returns a new `endpoint` with updated route and parameters
*/
Expand Down
21 changes: 19 additions & 2 deletions src/with-defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,34 @@ 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,
newDefaults: RequestParameters,
): ApiInterface {
const newRequest = request.defaults(newDefaults);
const newApi = <ResponseData>(
query: Query | RequestParameters,
query:
| Query
| (String & DocumentTypeDecoration<unknown, unknown>)
| RequestParameters,
options?: RequestParameters,
) => {
return graphql<ResponseData>(newRequest, query, options);
const innerQuery =
typeof query === "string"
? query
: // Allows casting String & DocumentTypeDecoration<unknown, unknown> to
// string. This could be replaced with an instanceof check if we had
// access to a shared TypedDocumentString. Alternatively, we could use
// string & TypedDocumentDecoration<unknown, unknown> 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);
Comment on lines +22 to +33
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left some notes on this at: #609


return graphql<ResponseData>(newRequest, innerQuery, options);
};

return Object.assign(newApi, {
Expand Down
130 changes: 130 additions & 0 deletions test/graphql.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TResult, TVariables>
extends String
implements DocumentTypeDecoration<TResult, TVariables>
{
__apiType?: DocumentTypeDecoration<TResult, TVariables>["__apiType"];

constructor(private value: string) {
super(value);
}

toString(): string & DocumentTypeDecoration<TResult, TVariables> {
return this.value;
}
}

describe("graphql()", () => {
it("is a function", () => {
expect(graphql).toBeInstanceOf(Function);
Expand Down Expand Up @@ -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<string, never>
>(/* 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) {
Expand Down Expand Up @@ -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) {
Expand Down
9 changes: 8 additions & 1 deletion test/tsconfig.test.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/**/*"]
}