Skip to content
Merged
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
57 changes: 57 additions & 0 deletions documentation/docs/integrations/adapter-graphql/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,63 @@ This will pass the `name` variable to the `AddUser` mutation.

---

## URL path per operation

By default, `endpoint` holds the GraphQL document and every operation goes to `client.url`. When gateways, WAF/CDN
rules, APM or access logs group the traffic by URL, pass the document separately with the `document` option. The
`endpoint` then becomes a URL path appended to `client.url`, the same way as in the `HttpAdapter`.

```ts
const getUser = client.createRequest<{ response: User; payload: { id: number } }>()({
endpoint: "/User", // URL path, supports params like "/tenant/:tenantId/User"
document: gql`
query User($id: ID!) {
user(id: $id) {
username
}
}
`, // string | DocumentNode, sent as `query`
});

await getUser.send({ payload: { id: 1 } });
// POST {client.url}/User
// body: { query, variables: { id: 1 } }
```

- Without `document` nothing changes: `endpoint` is the document and the URL is `client.url`.
- The cache, query and abort keys contain the operation name from `document` and the path from `endpoint`, so the same
operation sent to two paths is not shared in the cache.
- Devtools display the operation name from `document`.
- Variables are typed with the `payload` generic, the same as without the `document` option.

---

## Query params

Query params are appended to the URL, with or without the `document` option. This lets access logs and APM filter
requests without parsing the body.

```ts
await getUser.setQueryParams({ source: "app", build: "1.2.3" }).send({ payload: { id: 1 } });
// POST {client.url}/User?source=app&build=1.2.3
// body: { query, variables: { id: 1 } }
```

For the `GET` method they are merged with `query` and `variables` in the query string. If a key collides, `query` and
`variables` take precedence.

They go through the query params mapper of the adapter, so you can change the format with the mapper config or replace
the mapper. It can return a query string or an object.

```ts
import { stringifyDefaultOptions } from "@hyper-fetch/core";

// The config replaces the defaults, so spread them to keep the encoding
const adapter = GraphqlAdapter().setQueryParamsMapperConfig({ ...stringifyDefaultOptions, arrayFormat: "comma" });
```

---

## Next Steps

Now that you have a basic understanding of the GraphQL adapter, you can explore more advanced topics:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export type GraphQLServerOptions = {
export const createE2EGraphQLServer = (options: GraphQLServerOptions) => {
let server: http.Server | null = null;
let port = 0;
let lastRequestUrl = "";

const schema: GraphQLSchema = buildSchema(options.typeDefs);
const rootValue = options.resolvers;
Expand All @@ -33,6 +34,7 @@ export const createE2EGraphQLServer = (options: GraphQLServerOptions) => {
};

const handleRequest = async (req: http.IncomingMessage, res: http.ServerResponse) => {
lastRequestUrl = req.url || "";
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");

Expand Down Expand Up @@ -131,5 +133,7 @@ export const createE2EGraphQLServer = (options: GraphQLServerOptions) => {

const getUrl = () => `http://127.0.0.1:${port}`;

return { startServer, stopServer, getUrl };
const getLastRequestUrl = () => lastRequestUrl;

return { startServer, stopServer, getUrl, getLastRequestUrl };
};
63 changes: 62 additions & 1 deletion packages/adapter-graphql/__tests__/e2e/e2e.graphql.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ type GqlResponse<T> = { response: T; error: { message: string }[] };

// ─── Server Setup ────────────────────────────────────────────────────

const { startServer, stopServer } = createE2EGraphQLServer({ typeDefs, resolvers });
const { startServer, stopServer, getLastRequestUrl } = createE2EGraphQLServer({ typeDefs, resolvers });

let baseUrl: string;

Expand Down Expand Up @@ -484,3 +484,64 @@ describe("E2E GraphQL [ Multiple Operations ]", () => {
expect(result2.data!.user.username).toBe("jane");
});
});

describe("E2E GraphQL [ Document and query params ]", () => {
const USER_QUERY = "query User($id: ID!) { user(id: $id) { id username } }";
type UserResponse = GqlResponse<{ user: { id: string; username: string } }> & {
payload: { id: string };
};

it("should send POST to the path with query params and the variables in the body", async () => {
const client = new Client({ url: `${baseUrl}/graphql` }).setAdapter(GraphqlAdapter());
const request = client.createRequest<UserResponse>()({ endpoint: "/User", document: USER_QUERY });

const { data, error } = await request
.setQueryParams({ source: "app", build: "1.2.3" })
.send({ payload: { id: "1" } });

expect(error).toBeNull();
expect(data).toStrictEqual({ user: { id: "1", username: "john" } });
expect(getLastRequestUrl()).toBe("/graphql/User?source=app&build=1.2.3");
});

it("should send GET to the path with everything in the query string", async () => {
const client = new Client({ url: `${baseUrl}/graphql` }).setAdapter(GraphqlAdapter());
const request = client.createRequest<UserResponse>()({
endpoint: "/User",
document: USER_QUERY,
method: GraphqlMethod.GET,
});

const { data, error } = await request
.setQueryParams({ source: "app", query: "ignored", variables: "ignored" })
.send({ payload: { id: "2" } });

expect(error).toBeNull();
expect(data).toStrictEqual({ user: { id: "2", username: "jane" } });

const { pathname, searchParams } = new URL(getLastRequestUrl(), baseUrl);
expect(pathname).toBe("/graphql/User");
expect(searchParams.get("source")).toBe("app");
expect(searchParams.getAll("query")).toStrictEqual([USER_QUERY]);
expect(searchParams.getAll("variables")).toStrictEqual([JSON.stringify({ id: "2" })]);
});

it("should keep the client url when document is omitted", async () => {
const client = new Client({ url: `${baseUrl}/graphql` }).setAdapter(GraphqlAdapter());
const request = client.createRequest<UserResponse>()({ endpoint: USER_QUERY });

const { data } = await request.send({ payload: { id: "1" } } as any);

expect(data).toStrictEqual({ user: { id: "1", username: "john" } });
expect(getLastRequestUrl()).toBe("/graphql");
});

it("should apply query params when document is omitted", async () => {
const client = new Client({ url: `${baseUrl}/graphql` }).setAdapter(GraphqlAdapter());
const request = client.createRequest<UserResponse>()({ endpoint: USER_QUERY });

await request.setQueryParams({ source: "app" }).send({ payload: { id: "1" } } as any);

expect(getLastRequestUrl()).toBe("/graphql?source=app");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -256,4 +256,107 @@ describe("Graphql Adapter [ Browser ]", () => {

global.fetch = originalFetch;
});

describe("when using the document option", () => {
const getFetchUrl = (spy: { mock: { calls: unknown[][] } }) => String(spy.mock.calls[0][0]);

it("should send the request to the path of the operation", async () => {
const operation = client.createRequest<{ response: GetUserQueryResponse }>()({
endpoint: "/User",
document: getUserQuery,
});
const expected = mockRequest(operation, { data: { username: "prc", firstName: "Maciej" } });
const spy = vi.spyOn(globalThis, "fetch");

const { data, error, status } = await operation.setQueryParams({ source: "app", build: "1.2.3" }).send();

expect(expected.data).toStrictEqual(data);
expect(error).toBe(null);
expect(status).toBe(200);
expect(getFetchUrl(spy)).toBe("https://shared-base-url/graphql/User?source=app&build=1.2.3");
});

it("should not reach the handler of a different path", async () => {
const operation = client.createRequest<{ response: GetUserQueryResponse }>()({
endpoint: "/User",
document: getUserQuery,
});
const other = client.createRequest<{ response: GetUserQueryResponse }>()({
endpoint: "/Other",
document: getUserQuery,
});
mockRequest(other, { data: { username: "other", firstName: "Other" } });
const expected = mockRequest(operation, { data: { username: "prc", firstName: "Maciej" } });

const { data } = await operation.send();

expect(data).toStrictEqual(expected.data);
});

it("should send mutations", async () => {
const operation = client.createRequest<{ response: GetUserQueryResponse; payload: LoginMutationVariables }>()({
endpoint: "/Login",
document: loginMutation,
});
const expected = mockRequest(operation, { data: { username: "prc", firstName: "Maciej" } });

const { data, error } = await operation.send({ payload: { username: "Kacper", password: "Kacper1234" } });

expect(expected.data).toStrictEqual(data);
expect(error).toBe(null);
});

it("should run parallel requests against different paths without changing the client url", async () => {
const first = client.createRequest<{ response: GetUserQueryResponse }>()({
endpoint: "/First",
document: getUserQuery,
});
const second = client.createRequest<{ response: GetUserQueryResponse }>()({
endpoint: "/Second",
document: getUserQuery,
});
const firstExpected = mockRequest(first, { data: { username: "first", firstName: "First" } });
const secondExpected = mockRequest(second, { data: { username: "second", firstName: "Second" } });

const [firstResponse, secondResponse] = await Promise.all([first.send(), second.send()]);

expect(firstResponse.data).toStrictEqual(firstExpected.data);
expect(secondResponse.data).toStrictEqual(secondExpected.data);
expect(client.url).toBe("https://shared-base-url/graphql");
});

it("should not share the cache between paths", async () => {
const cacheClient = new Client({ url: "https://shared-base-url/graphql", mode: "client" }).setAdapter(
GraphqlAdapter(),
);
const first = cacheClient.createRequest<{ response: GetUserQueryResponse }>()({
endpoint: "/First",
document: getUserQuery,
});
const second = cacheClient.createRequest<{ response: GetUserQueryResponse }>()({
endpoint: "/Second",
document: getUserQuery,
});
const firstExpected = mockRequest(first, { data: { username: "first", firstName: "First" } });
const secondExpected = mockRequest(second, { data: { username: "second", firstName: "Second" } });

await first.send();
await second.send();

expect(first.read()?.data).toStrictEqual(firstExpected.data);
expect(second.read()?.data).toStrictEqual(secondExpected.data);
});
});

describe("when using query params without the document option", () => {
it("should append them to the client url", async () => {
const expected = mockRequest(request, { data: { username: "prc", firstName: "Maciej" } });
const spy = vi.spyOn(globalThis, "fetch");

const { data } = await request.setQueryParams({ source: "app" }).send();

expect(expected.data).toStrictEqual(data);
expect(String(spy.mock.calls[0][0])).toBe("https://shared-base-url/graphql?source=app");
});
});
});
Loading
Loading