Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/graphql-invocation-user-agent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@executor-js/plugin-graphql": patch
---

Set a default User-Agent on GraphQL query and mutation requests, matching introspection while preserving explicitly configured headers.
77 changes: 77 additions & 0 deletions e2e/scenarios/graphql-request-headers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { randomUUID } from "node:crypto";
import { expect } from "@effect/vitest";
import { Effect } from "effect";
import { composePluginApi } from "@executor-js/api/server";
import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";
import {
makeGreetingGraphqlSchema,
serveGraphqlTestServer,
} from "@executor-js/plugin-graphql/testing";
import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
import { scenario } from "../src/scenario";
import { Api, Target } from "../src/services";

const api = composePluginApi([graphqlHttpPlugin()] as const);

scenario(
"GraphQL · sandbox calls preserve default and custom request headers",
{},
Effect.scoped(
Effect.gen(function* () {
const target = yield* Target;
const { client: makeClient } = yield* Api;
const identity = yield* target.newIdentity();
const client = yield* makeClient(api, identity);
const upstream = yield* serveGraphqlTestServer({ schema: makeGreetingGraphqlSchema() });

for (const custom of [false, true]) {
const slug = IntegrationSlug.make(`graphql_headers_${randomUUID().replaceAll("-", "")}`);
yield* Effect.gen(function* () {
yield* client.graphql.addIntegration({
payload: {
endpoint: upstream.endpoint,
slug,
headers: {
"X-API-Version": "1",
...(custom ? { "uSeR-aGeNt": "example-client/2.0" } : {}),
},
},
});
yield* client.connections.create({
payload: {
owner: "org",
integration: slug,
name: ConnectionName.make("main"),
template: AuthTemplateSlug.make("none"),
value: "",
},
});
const executed = yield* client.executions.execute({
payload: {
code: `return await tools.${slug}.org.main.query.hello({name: "Ada"});`,
autoApprove: true,
},
});
expect(executed.status).toBe("completed");
expect(JSON.parse(executed.text)).toEqual({ ok: true, data: { hello: "Hello Ada" } });
const requests = yield* upstream.requests;
expect(requests.some((request) => request.payload.query?.includes("__schema"))).toBe(
true,
);
expect(requests.some((request) => request.payload.query?.startsWith("query Hello"))).toBe(
true,
);
for (const request of requests) {
expect(request.headers["user-agent"]).toBe(
custom ? "example-client/2.0" : "executor-graphql",
);
expect(request.headers["x-api-version"]).toBe("1");
}
}).pipe(
Effect.ensuring(client.integrations.remove({ params: { slug } }).pipe(Effect.ignore)),
);
yield* upstream.clearRequests;
}
}),
),
);
3 changes: 3 additions & 0 deletions packages/plugins/graphql/src/sdk/invoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,9 @@ export const invoke = Effect.fn("GraphQL.invoke")(function* (

let request = HttpClientRequest.post(requestEndpoint).pipe(
HttpClientRequest.setHeader("Content-Type", "application/json"),
// Match introspection even on runtimes that do not supply a User-Agent.
// Resolved headers below can override the default, case-insensitively.
HttpClientRequest.setHeader("User-Agent", "executor-graphql"),
HttpClientRequest.bodyJsonUnsafe({
query: operationString,
variables: Object.keys(variables).length > 0 ? variables : undefined,
Expand Down
60 changes: 60 additions & 0 deletions packages/plugins/graphql/src/sdk/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,66 @@ describe("graphqlPlugin real protocol server", () => {
}),
);

for (const headerName of [undefined, "User-Agent", "user-agent", "uSeR-aGeNt"]) {
it.effect(
`preserves request headers across introspection and invocation (${headerName ?? "default"})`,
() =>
Effect.gen(function* () {
const server = yield* serveGreetingServer;
const executor = yield* makeExecutor();
const userAgent = headerName ? "example-client/2.0" : "executor-graphql";
yield* executor.graphql.addIntegration({
endpoint: server.endpoint,
slug: "request_headers",
headers: {
Accept: "application/json",
"X-API-Version": "1",
...(headerName ? { [headerName]: userAgent } : {}),
},
authenticationTemplate: [
{
slug: "header",
kind: "apikey",
placements: [{ carrier: "header", name: "Authorization" }],
},
],
});
yield* createOrgConnection(executor, {
integration: "request_headers",
name: "main",
template: "header",
value: "test-credential",
});
const query = yield* executor.execute(
toolAddr("request_headers", "main", "query.hello"),
{ name: "Ada" },
);
const mutation = yield* executor.execute(
toolAddr("request_headers", "main", "mutation.setGreeting"),
{ message: "hi" },
);
expect(query).toEqual({ ok: true, data: { hello: "Hello Ada" } });
expect(mutation.ok).toBe(true);
const requests = yield* server.requests;
expect(requests.some((request) => request.payload.query?.includes("__schema"))).toBe(
true,
);
expect(requests.some((request) => request.payload.query?.startsWith("query Hello"))).toBe(
true,
);
expect(
requests.some((request) => request.payload.query?.startsWith("mutation SetGreeting")),
).toBe(true);
for (const request of requests) {
expect(request.headers["user-agent"]).toBe(userAgent);
expect(request.headers.accept).toBe("application/json");
expect(request.headers["x-api-version"]).toBe("1");
expect(request.headers.authorization).toBe("test-credential");
}
}),
);
}

it.effect("sends named operations derived from the field name", () =>
Effect.gen(function* () {
const server = yield* serveGreetingServer;
Expand Down
Loading