Skip to content

Commit acd85bf

Browse files
committed
feat(sdk): support typed documents in http adapters
1 parent 3c14218 commit acd85bf

6 files changed

Lines changed: 242 additions & 31 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@tailor-platform/sdk": minor
3+
---
4+
5+
Support `TypedDocumentNode` queries in HTTP adapters and infer `output` response data from them.

packages/sdk/docs/services/http-adapter.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ HTTP adapters expose REST-style HTTP endpoints on your application by translatin
77
Each HTTP adapter is a single file that declares:
88

99
- A `pathPattern` (which methods it handles is derived from the `input` keys)
10-
- An `input` object keyed by lowercase HTTP method (`get`, `post`, `put`, `patch`, `delete`) — each value is a function that converts an incoming HTTP request into a GraphQL request (`query`, `variables`, `operationName`)
10+
- An `input` object keyed by lowercase HTTP method (`get`, `post`, `put`, `patch`, `delete`) — each value is a function that converts an incoming HTTP request into a GraphQL request (`query`, `variables`, `operationName`). `query` can be a GraphQL string or a generated `TypedDocumentNode`.
1111
- An optional `output` function — **shared across all methods** — that converts the GraphQL response into an HTTP response (`statusCode`, `headers`, `body`)
1212

1313
Adapters are deployed together with your application. When a request arrives under the `/api/` prefix and matches an adapter, the handler for the request method runs server-side.
@@ -74,6 +74,21 @@ A request to `GET /api/users/abc-123` invokes the `get` handler, runs the result
7474

7575
If `output` is omitted, the raw GraphQL response is returned as JSON.
7676

77+
### Typed GraphQL Documents
78+
79+
`input` handlers can return generated `TypedDocumentNode` values instead of query strings. The SDK uses the document's variables type for `variables`, and passes the document's result type to `output` as `resp.data`.
80+
81+
```typescript
82+
import { GetUserDocument } from "../generated/graphql";
83+
84+
get: (req) => ({
85+
query: GetUserDocument,
86+
variables: { id: req.path.split("/")[2] ?? "" },
87+
});
88+
```
89+
90+
When multiple methods return different typed documents, `resp.data` is the union of those result types because `output` is shared across the adapter. If a method returns a plain string query, its result type is `unknown`. When extracting an input handler and annotating it separately, pass the document type to `HttpAdapterInputFn` so required variables stay typed.
91+
7792
### Optional fields
7893

7994
Beyond `name`, `pathPattern`, `input`, and `output`, two optional fields control deploy-time behavior:

packages/sdk/src/cli/services/http-adapter/bundler.test.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import * as fs from "node:fs";
2+
import { createRequire } from "node:module";
23
import * as os from "node:os";
34
import * as path from "pathe";
45
import { afterEach, describe, expect, test } from "vitest";
56
import { bundleHttpAdapters } from "./bundler";
67

8+
const nodeRequire = createRequire(import.meta.url);
9+
const graphqlWebModule = nodeRequire.resolve("@0no-co/graphql.web");
10+
711
describe("bundleHttpAdapters", () => {
812
let tmpDir: string | undefined;
913

@@ -27,13 +31,16 @@ describe("bundleHttpAdapters", () => {
2731
sourceFile,
2832
`
2933
import { createHttpAdapter } from "@tailor-platform/sdk";
34+
import { parse } from ${JSON.stringify(graphqlWebModule)};
35+
36+
const getUserDocument = parse("query U($id: ID!) { user(id: $id) { id name } }");
3037
3138
export default createHttpAdapter({
3239
name: "get-user",
3340
pathPattern: "/users/*",
3441
input: {
3542
get: (req) => ({
36-
query: "query U($id: ID!) { user(id: $id) { id name } }",
43+
query: getUserDocument,
3744
variables: { id: req.path.split("/")[2] },
3845
}),
3946
},
@@ -59,6 +66,18 @@ export default createHttpAdapter({
5966
// either double quotes or backticks for the case literal).
6067
expect(inputCode).toMatch(/case\s*[`"]GET[`"]/);
6168
expect(outputCode).toContain("globalThis.transform");
69+
70+
const runtime: {
71+
transform?: (req: { method: string; path: string }) => {
72+
query: string;
73+
variables: { id: string | undefined };
74+
};
75+
} = {};
76+
new Function("globalThis", inputCode!).call(runtime, runtime);
77+
const request = runtime.transform!({ method: "GET", path: "/users/user-1" });
78+
expect(request.query).toContain("query U");
79+
expect(request.query).toContain("user(id: $id)");
80+
expect(request.variables.id).toBe("user-1");
6281
});
6382

6483
test("dispatches to the matching method handler at runtime", async () => {

packages/sdk/src/cli/services/http-adapter/bundler.ts

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as fs from "node:fs";
2+
import { createRequire } from "node:module";
23
import { parseSync } from "oxc-parser";
34
import * as path from "pathe";
45
import { resolveTSConfig } from "pkg-types";
@@ -15,6 +16,8 @@ import type { LogLevel } from "#/configure/config/types";
1516

1617
const ADAPTER_BUNDLE_WARN_BYTES = 64 * 1024;
1718
const ADAPTER_BUNDLE_ERROR_BYTES = 256 * 1024;
19+
const nodeRequire = createRequire(import.meta.url);
20+
const GRAPHQL_WEB_MODULE = nodeRequire.resolve("@0no-co/graphql.web");
1821

1922
export interface HttpAdapterBundleInput {
2023
name: string;
@@ -101,7 +104,7 @@ async function bundleAdapterScript(
101104
tsconfig,
102105
inlineSourcemap: false,
103106
bundleLogLevel,
104-
prefix: kind,
107+
prefix: `${kind}:document-query-normalize-v1`,
105108
});
106109

107110
const code = await withCache({
@@ -114,12 +117,6 @@ async function bundleAdapterScript(
114117
const entryPath = path.join(outputDir, `${adapter.name}.${kind}.entry.js`);
115118
const absoluteSourcePath = path.resolve(adapter.sourceFile);
116119

117-
const entryContent =
118-
kind === "input"
119-
? buildInputEntry(absoluteSourcePath, adapter.methods)
120-
: buildOutputEntry(absoluteSourcePath);
121-
fs.writeFileSync(entryPath, entryContent);
122-
123120
const rejectNodeImports: rolldown.Plugin = {
124121
name: "http-adapter-reject-node-imports",
125122
resolveId(source) {
@@ -154,6 +151,11 @@ async function bundleAdapterScript(
154151

155152
let bundled: string;
156153
try {
154+
const entryContent =
155+
kind === "input"
156+
? buildInputEntry(absoluteSourcePath, adapter.methods, GRAPHQL_WEB_MODULE)
157+
: buildOutputEntry(absoluteSourcePath);
158+
fs.writeFileSync(entryPath, entryContent);
157159
const result = await rolldown.build({
158160
input: entryPath,
159161
write: false,
@@ -206,12 +208,28 @@ async function bundleAdapterScript(
206208
return [adapter.name, kind, code];
207209
}
208210

209-
function buildInputEntry(absoluteSourcePath: string, methods: HttpMethodKey[]): string {
211+
function buildInputEntry(
212+
absoluteSourcePath: string,
213+
methods: HttpMethodKey[],
214+
graphqlPrinterModule: string,
215+
): string {
210216
const cases = methods
211-
.map((method) => ` case "${HTTP_METHODS[method]}": return __adapter.input.${method}(req);`)
217+
.map((method) => {
218+
const result = `__adapter.input.${method}(req)`;
219+
const expression = `__normalizeHttpAdapterGraphQLRequest(${result})`;
220+
return ` case "${HTTP_METHODS[method]}": return ${expression};`;
221+
})
212222
.join("\n");
213223
const supported = methods.map((m) => HTTP_METHODS[m]).join(", ");
214-
return `import __adapter from ${JSON.stringify(absoluteSourcePath)};
224+
const documentNormalizer = `import { print as __printHttpAdapterDocument } from ${JSON.stringify(graphqlPrinterModule)};
225+
function __normalizeHttpAdapterGraphQLRequest(result) {
226+
if (typeof result.query === "string") {
227+
return result;
228+
}
229+
return { ...result, query: __printHttpAdapterDocument(result.query) };
230+
}
231+
`;
232+
return `${documentNormalizer}import __adapter from ${JSON.stringify(absoluteSourcePath)};
215233
globalThis.transform = function(req) {
216234
switch (req.method) {
217235
${cases}

packages/sdk/src/configure/services/http-adapter/http-adapter.test.ts

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
1-
import { describe, expect, test } from "vitest";
1+
import { describe, expect, expectTypeOf, test } from "vitest";
22
import { SDK_BRAND, isSdkBranded } from "#/utils/brand";
3-
import { createHttpAdapter } from "./http-adapter";
3+
import {
4+
createHttpAdapter,
5+
type HttpAdapterInputFn,
6+
type HttpAdapterTypedDocumentNode,
7+
} from "./http-adapter";
8+
import type { TypedQueryDocumentNode } from "graphql";
49

510
describe("createHttpAdapter", () => {
611
test("returns a branded HTTP adapter object", () => {
@@ -60,4 +65,75 @@ describe("createHttpAdapter", () => {
6065
expect(typeof adapter.input.post).toBe("function");
6166
expect(typeof adapter.input.delete).toBe("function");
6267
});
68+
69+
test("infers output data and variables from typed document nodes", () => {
70+
type GetUserData = { user: { id: string; name: string } | null };
71+
type GetUserVariables = { id: string };
72+
const getUserDocument = {} as HttpAdapterTypedDocumentNode<GetUserData, GetUserVariables>;
73+
const get: HttpAdapterInputFn<typeof getUserDocument> = (req) => ({
74+
query: getUserDocument,
75+
variables: { id: req.path.split("/")[2] ?? "" },
76+
});
77+
78+
createHttpAdapter({
79+
name: "typed-user",
80+
pathPattern: "/users/*",
81+
input: {
82+
get,
83+
},
84+
output: (resp) => {
85+
expectTypeOf(resp.data).toEqualTypeOf<GetUserData | null | undefined>();
86+
expectTypeOf(resp.data?.user?.name).toEqualTypeOf<string | undefined>();
87+
return { body: JSON.stringify(resp.data?.user ?? null) };
88+
},
89+
});
90+
91+
createHttpAdapter({
92+
name: "typed-missing-variables",
93+
pathPattern: "/users/*",
94+
input: {
95+
// @ts-expect-error - typed document variables require variables.id
96+
get: () => ({
97+
query: getUserDocument,
98+
}),
99+
},
100+
});
101+
102+
createHttpAdapter({
103+
name: "typed-wrong-variables",
104+
pathPattern: "/users/*",
105+
input: {
106+
// @ts-expect-error - typed document variables require an id string
107+
get: () => ({
108+
query: getUserDocument,
109+
variables: { slug: "alice" },
110+
}),
111+
},
112+
});
113+
});
114+
115+
test("unions output data from multiple typed document methods", () => {
116+
type GetData = { getUser: { id: string } | null };
117+
type PostData = { createUser: { id: string } };
118+
const getDocument = {} as HttpAdapterTypedDocumentNode<GetData>;
119+
const postDocument = {} as TypedQueryDocumentNode<PostData, { name: string }>;
120+
121+
createHttpAdapter({
122+
name: "typed-union",
123+
pathPattern: "/users/*",
124+
input: {
125+
get: () => ({
126+
query: getDocument,
127+
}),
128+
post: () => ({
129+
query: postDocument,
130+
variables: { name: "Alice" },
131+
}),
132+
},
133+
output: (resp) => {
134+
expectTypeOf(resp.data).toEqualTypeOf<GetData | PostData | null | undefined>();
135+
return { body: JSON.stringify(resp.data ?? null) };
136+
},
137+
});
138+
});
63139
});

0 commit comments

Comments
 (0)