diff --git a/.changeset/pre.json b/.changeset/pre.json index 4d628cc4a6c..1b96d1c29c4 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -4,7 +4,9 @@ "initialVersions": { "@apollo/client": "3.12.2", "@apollo/client-codemod-migrate-3-to-4": "0.0.0", - "@apollo/client-graphql-codegen": "0.0.0" + "@apollo/client-graphql-codegen": "0.0.0", + "@apollo/client-ai": "0.0.0", + "@apollo/client-ai-vercel-adapter": "0.0.0" }, "changesets": [ "afraid-grapes-call", diff --git a/eslint.config.mjs b/eslint.config.mjs index 12d6ae0a0ed..016869f5d7b 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -153,6 +153,8 @@ export default [ parserOptions: { project: [ "./tsconfig.json", + "./packages/ai/tsconfig.json", + "./packages/ai-vercel-adapter/tsconfig.json", "./codegen/tsconfig.json", "./config/tsconfig.json", "./docs/tsconfig.json", diff --git a/package-lock.json b/package-lock.json index 796da78298b..26bcf54d00c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,9 @@ "workspaces": [ ".", "codegen", - "scripts/codemods/ac3-to-ac4" + "scripts/codemods/ac3-to-ac4", + "packages/ai", + "packages/ai-vercel-adapter" ], "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", @@ -145,6 +147,37 @@ } } }, + "ai": { + "name": "@apollo/client-ai", + "version": "0.1.0-alpha.0", + "extraneous": true, + "license": "MIT", + "dependencies": { + "@apollo/client": "*" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.6.3", + "@types/jest": "^29.5.12", + "jest": "^29.7.0", + "rimraf": "^5.0.9", + "ts-jest": "^29.2.3", + "typescript": "^5.8.3" + } + }, + "ai-vercel-adapter": { + "name": "@apollo/client-ai-vercel-adapter", + "version": "0.1.0-alpha.0", + "extraneous": true, + "license": "MIT", + "dependencies": { + "@apollo/client": "*", + "@apollo/client-ai": "*", + "ai": "^5.0.17" + }, + "devDependencies": { + "typescript": "^5.8.3" + } + }, "codegen": { "name": "@apollo/client-graphql-codegen", "version": "1.0.0-rc.0", @@ -316,6 +349,49 @@ "dev": true, "license": "MIT" }, + "node_modules/@ai-sdk/gateway": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-1.0.8.tgz", + "integrity": "sha512-yiHYz0bAHEvhL+fSUBI2dNmyj0LOI8zw5qrYpa4gp1ojPgZq/7T1WXoIWRmVdjQwvT4PzSmRKLtbMPfz+umgfw==", + "dependencies": { + "@ai-sdk/provider": "2.0.0", + "@ai-sdk/provider-utils": "3.0.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-2.0.0.tgz", + "integrity": "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA==", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/provider-utils": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.4.tgz", + "integrity": "sha512-/3Z6lfUp8r+ewFd9yzHkCmPlMOJUXup2Sx3aoUyrdXLhOmAfHRl6Z4lDbIdV0uvw/QYoBcVLJnvXN7ncYeS3uQ==", + "dependencies": { + "@ai-sdk/provider": "2.0.0", + "@standard-schema/spec": "^1.0.0", + "eventsource-parser": "^3.0.3", + "zod-to-json-schema": "^3.24.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4" + } + }, "node_modules/@ampproject/remapping": { "version": "2.2.1", "license": "Apache-2.0", @@ -335,6 +411,14 @@ "resolved": "", "link": true }, + "node_modules/@apollo/client-ai": { + "resolved": "packages/ai", + "link": true + }, + "node_modules/@apollo/client-ai-vercel-adapter": { + "resolved": "packages/ai-vercel-adapter", + "link": true + }, "node_modules/@apollo/client-codemod-migrate-3-to-4": { "resolved": "scripts/codemods/ac3-to-ac4", "link": true @@ -5470,6 +5554,14 @@ "@octokit/openapi-types": "^20.0.0" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "dev": true, @@ -6078,6 +6170,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==" + }, "node_modules/@testing-library/dom": { "version": "10.4.0", "dev": true, @@ -7399,6 +7496,23 @@ "node": ">=8" } }, + "node_modules/ai": { + "version": "5.0.17", + "resolved": "https://registry.npmjs.org/ai/-/ai-5.0.17.tgz", + "integrity": "sha512-DLZikqZZJdwSkRhFikw6Mt7pUmPZ7Ue38TjdOcw2U6iZtBbuiyWGIhHyJXlUpLcZrtBE5yqPTozyZri1lRjduw==", + "dependencies": { + "@ai-sdk/gateway": "1.0.8", + "@ai-sdk/provider": "2.0.0", + "@ai-sdk/provider-utils": "3.0.4", + "@opentelemetry/api": "1.9.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4" + } + }, "node_modules/ajv": { "version": "8.17.1", "dev": true, @@ -10207,6 +10321,15 @@ "eslint": ">=7" } }, + "node_modules/eslint-plugin-react-compiler/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/eslint-plugin-react-hooks": { "version": "5.1.0", "dev": true, @@ -10580,6 +10703,14 @@ "dev": true, "license": "MIT" }, + "node_modules/eventsource-parser": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.5.tgz", + "integrity": "sha512-bSRG85ZrMdmWtm7qkF9He9TNRzc/Bm99gEJMaQoHJ9E6Kv9QBbsldh2oMj7iXmYNEAVvNgvv5vPorG6W+XtBhQ==", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/execa": { "version": "5.1.1", "dev": true, @@ -13364,6 +13495,30 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jest-extended": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/jest-extended/-/jest-extended-6.0.0.tgz", + "integrity": "sha512-SM249N/q33YQ9XE8E06qZSnFuuV4GQFx7WrrmIj4wQUAP43jAo6budLT482jdBhf8ASwUiEEfJNjej0UusYs5A==", + "dev": true, + "dependencies": { + "jest-diff": "^29.0.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || ^22.11.0 || >=23.0.0" + }, + "peerDependencies": { + "jest": ">=27.2.5", + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "jest": { + "optional": true + }, + "typescript": { + "optional": false + } + } + }, "node_modules/jest-get-type": { "version": "29.6.3", "dev": true, @@ -13978,6 +14133,11 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==" + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "dev": true, @@ -14232,6 +14392,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/knip/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/leven": { "version": "3.1.0", "dev": true, @@ -17182,6 +17351,15 @@ "node": ">=20" } }, + "node_modules/query-registry/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/query-string": { "version": "9.1.0", "dev": true, @@ -21248,9 +21426,9 @@ } }, "node_modules/zod": { - "version": "3.23.8", - "dev": true, - "license": "MIT", + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.0.17.tgz", + "integrity": "sha512-1PHjlYRevNxxdy2JZ8JcNAw7rX8V9P1AKkP+x/xZfxB0K5FYfuV+Ug6P/6NVSR2jHQ+FzDDoDHS04nYUsOIyLQ==", "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -21266,6 +21444,23 @@ "node": ">=20" } }, + "node_modules/zod-package-json/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.24.6", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz", + "integrity": "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==", + "peerDependencies": { + "zod": "^3.24.1" + } + }, "node_modules/zod-validation-error": { "version": "3.3.0", "dev": true, @@ -21303,6 +21498,68 @@ "@types/node": ">=20" } }, + "packages/ai": { + "name": "@apollo/client-ai", + "version": "0.1.0-alpha.0", + "license": "MIT", + "dependencies": { + "@apollo/client": "*" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.6.3", + "@types/jest": "^29.5.12", + "jest": "^29.7.0", + "jest-extended": "^6.0.0", + "rimraf": "^5.0.9", + "ts-jest": "^29.2.3", + "typescript": "^5.8.3" + } + }, + "packages/ai-vercel-adapter": { + "name": "@apollo/client-ai-vercel-adapter", + "version": "0.1.0-alpha.0", + "license": "MIT", + "dependencies": { + "@apollo/client": "*", + "@apollo/client-ai": "*", + "ai": "^5.0.17", + "zod": "^4.0.17" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.6.3", + "@types/jest": "^29.5.12", + "jest": "^29.7.0", + "rimraf": "^5.0.9", + "ts-jest": "^29.2.3", + "typescript": "^5.8.3" + } + }, + "packages/ai-vercel-adapter/node_modules/typescript": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "packages/ai/node_modules/typescript": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "scripts/codemods/ac3-to-ac4": { "name": "@apollo/client-codemod-migrate-3-to-4", "version": "1.0.0-rc.3", diff --git a/package.json b/package.json index c7a073c255a..286bb04c441 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,8 @@ "scripts": { "prebuild": "npm run clean", "build": "node config/build.ts", + "build:ai": "npm run build -w packages/ai", + "build:ai-vercel-adapter": "npm run build -w packages/ai-vercel-adapter", "typecheck": "tsc --project config/tsconfig.json; tsc --noEmit --project tsconfig.json", "postinstall": "patch-package", "extract-api": "npm run clean && node config/build.ts --step=prepareDist --step=addExports --step=typescript --step=inlineInheritDoc --step=deprecateInternals && npm run extract-api:only", @@ -96,6 +98,8 @@ "test:debug": "node --inspect-brk node_modules/.bin/jest --config ./config/jest.config.ts --runInBand --testTimeout 99999 --logHeapUsage", "test:ci": "TEST_ENV=ci npm run test:coverage -- --logHeapUsage", "test:codegen": "npm run build -w codegen && graphql-codegen --config ./tests.codegen.ts", + "test:ai": "npm run test -w packages/ai", + "test:ai-vercel-adapter": "npm run test -w packages/ai-vercel-adapter", "test:watch": "jest --config ./config/jest.config.ts --watch", "test:memory": "npm i && npm run build && cd scripts/memory && npm i && npm test", "test:coverage": "npm run coverage -- --ci --runInBand --reporters=default --reporters=jest-junit", @@ -275,6 +279,8 @@ "workspaces": [ ".", "codegen", - "scripts/codemods/ac3-to-ac4" + "scripts/codemods/ac3-to-ac4", + "packages/ai", + "packages/ai-vercel-adapter" ] } diff --git a/packages/ai-vercel-adapter/config/jest/setup.ts b/packages/ai-vercel-adapter/config/jest/setup.ts new file mode 100644 index 00000000000..3356011a229 --- /dev/null +++ b/packages/ai-vercel-adapter/config/jest/setup.ts @@ -0,0 +1,44 @@ +//@ts-ignore +globalThis.__DEV__ = true; + +import { TextDecoder, TextEncoder } from "util"; +import "@testing-library/jest-dom"; + +global.TextEncoder ??= TextEncoder; +// @ts-ignore +global.TextDecoder ??= TextDecoder; + +function fail(reason = "fail was called in a test.") { + expect(reason).toBe(undefined); +} + +// @ts-ignore +globalThis.fail = fail; + +if (!Symbol.dispose) { + Object.defineProperty(Symbol, "dispose", { + value: Symbol("dispose"), + }); +} +if (!Symbol.asyncDispose) { + Object.defineProperty(Symbol, "asyncDispose", { + value: Symbol("asyncDispose"), + }); +} + +// not available in JSDOM 🙄 +global.structuredClone = (val) => JSON.parse(JSON.stringify(val)); +global.ReadableStream ||= require("stream/web").ReadableStream; +global.TransformStream ||= require("stream/web").TransformStream; + +AbortSignal.timeout = (ms) => { + const controller = new AbortController(); + setTimeout( + () => + controller.abort( + new DOMException("The operation timed out.", "TimeoutError") + ), + ms + ); + return controller.signal; +}; diff --git a/packages/ai-vercel-adapter/jest.config.ts b/packages/ai-vercel-adapter/jest.config.ts new file mode 100644 index 00000000000..9795f5be0d3 --- /dev/null +++ b/packages/ai-vercel-adapter/jest.config.ts @@ -0,0 +1,40 @@ +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +export default { + rootDir: "src", + preset: "ts-jest/presets/default-esm", + extensionsToTreatAsEsm: [".ts"], + testEnvironment: fileURLToPath( + import.meta.resolve("../../config/FixJSDOMEnvironment.js") + ), + setupFilesAfterEnv: ["/../config/jest/setup.ts"], + testEnvironmentOptions: { + url: "http://localhost", + }, + snapshotFormat: { + escapeString: true, + printBasicPrototype: true, + }, + transform: { + "\\.tsx?$": [ + "ts-jest", + { + isolatedModules: true, + tsconfig: join(import.meta.dirname, "tsconfig.json"), + useESM: true, + }, + ], + }, + resolver: fileURLToPath( + import.meta.resolve("../../src/config/jest/resolver.ts") + ), + moduleNameMapper: { + "^@apollo/client-ai$": join(import.meta.dirname, "../ai/src/index.ts"), + }, + transformIgnorePatterns: ["/node_modules/(?!(rxjs)/)"], + prettierPath: null, + testMatch: ["**/__tests__/**/*.test.ts", "**/__tests__/**/*.test.tsx"], + displayName: "AI Vercel Adapter Tests", + testPathIgnorePatterns: [".d.ts$"], +}; diff --git a/packages/ai-vercel-adapter/package.json b/packages/ai-vercel-adapter/package.json new file mode 100644 index 00000000000..721058d970f --- /dev/null +++ b/packages/ai-vercel-adapter/package.json @@ -0,0 +1,57 @@ +{ + "name": "@apollo/client-ai-vercel-adapter", + "version": "0.1.0-alpha.0", + "description": "Apollo Client AI Tools Vercel Adapter", + "keywords": [ + "apollo", + "client", + "ai", + "mocking", + "vercel" + ], + "author": "packages@apollographql.com", + "license": "MIT", + "type": "module", + "sideEffects": false, + "repository": { + "type": "git", + "url": "git+https://github.com/apollographql/apollo-client.git", + "directory": "ai-vercel-adapter" + }, + "bugs": { + "url": "https://github.com/apollographql/apollo-client/issues" + }, + "homepage": "https://www.apollographql.com/docs/react/", + "exports": { + ".": "./dist/index.js" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "prebuild": "npm run clean", + "clean": "rimraf dist", + "build": "tsc", + "prepack": "npm run build", + "publint": "publint run --strict .", + "test": "node --expose-gc --experimental-import-meta-resolve --disable-warning=ExperimentalWarning ../../node_modules/jest/bin/jest.js --config jest.config.ts", + "test:watch": "npm run test -- --watch", + "test:coverage": "npm run test -- --coverage --watchAll=false" + }, + "dependencies": { + "@apollo/client": "*", + "@apollo/client-ai": "*", + "ai": "^5.0.17", + "zod": "^4.0.17" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.6.3", + "@types/jest": "^29.5.12", + "jest": "^29.7.0", + "rimraf": "^5.0.9", + "ts-jest": "^29.2.3", + "typescript": "^5.8.3" + } +} diff --git a/packages/ai-vercel-adapter/src/VercelAIAdapter.ts b/packages/ai-vercel-adapter/src/VercelAIAdapter.ts new file mode 100644 index 00000000000..77be26649a7 --- /dev/null +++ b/packages/ai-vercel-adapter/src/VercelAIAdapter.ts @@ -0,0 +1,54 @@ +import { type LanguageModel, generateObject } from "ai"; +import { AIAdapter } from "@apollo/client-ai"; +import { isFormattedExecutionResult } from "@apollo/client/utilities"; + +namespace VercelAIAdapter { + export interface Options extends AIAdapter.Options { + model: LanguageModel; + } +} + +type GenerateObjectOptions = { + model: LanguageModel; + mode: "json"; + prompt: string; + system?: string; + output: "no-schema"; +}; + +export class VercelAIAdapter extends AIAdapter { + public model: LanguageModel; + + constructor(options: VercelAIAdapter.Options) { + super(options); + + this.model = options.model; + } + + public async generateObject( + prompt: string, + systemPrompt: string + ): Promise { + const promptOptions: GenerateObjectOptions = { + mode: "json", + model: this.model, + prompt, + system: systemPrompt, + output: "no-schema", + }; + + return generateObject(promptOptions).then( + ({ object: result, finishReason, usage, warnings }) => { + if (!result || typeof result !== "object") { + return { data: null }; + } + // Type guard to ensure result is a valid FormattedExecutionResult + if (isFormattedExecutionResult(result)) { + return result; + } + // Fallback: wrap in data property if not a valid execution result + return { data: result }; + } + ); + } +} diff --git a/packages/ai-vercel-adapter/src/__tests__/VercelAIAdapter.test.ts b/packages/ai-vercel-adapter/src/__tests__/VercelAIAdapter.test.ts new file mode 100644 index 00000000000..a52bbf27fe1 --- /dev/null +++ b/packages/ai-vercel-adapter/src/__tests__/VercelAIAdapter.test.ts @@ -0,0 +1,10 @@ +import { VercelAIAdapter } from "../VercelAIAdapter.js"; + +describe("VercelAIAdapter", () => { + it("should be able to be instantiated", () => { + const adapter = new VercelAIAdapter({ + model: "gpt-4o-mini", + }); + expect(adapter).toBeInstanceOf(VercelAIAdapter); + }); +}); diff --git a/packages/ai-vercel-adapter/tsconfig.json b/packages/ai-vercel-adapter/tsconfig.json new file mode 100644 index 00000000000..022b8d9c99f --- /dev/null +++ b/packages/ai-vercel-adapter/tsconfig.json @@ -0,0 +1,46 @@ +{ + "compilerOptions": { + "noImplicitAny": true, + "strictNullChecks": true, + "noUnusedParameters": false, + "noUnusedLocals": true, + "skipLibCheck": true, + "moduleResolution": "NodeNext", + "importHelpers": true, + "sourceMap": true, + "inlineSources": true, + "declaration": true, + "declarationMap": true, + "target": "ESNext", + "module": "NodeNext", + "esModuleInterop": true, + "experimentalDecorators": true, + "outDir": "./dist", + "rootDir": "./src", + "lib": ["DOM", "ES2023"], + "types": [ + "jest", + "node" + // "./src/testing/matchers/index.d.ts", + // "./src/testing/internal/declarations.d.ts", + // "@testing-library/react-render-stream/expect" + ], + "jsx": "react", + "strict": true, + "paths": { + // This entry point is not part of our public API, so we point it directly to the source. + "@apollo/client/testing/internal": ["./src/testing/internal/index.ts"], + "@apollo/client-ai": ["../ai/dist"] + } + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + // "references": [ + // { + // "path": "../tsconfig.json" + // } + // ], + "mdx": { + // Enable strict type checking in MDX files. + "checkMdx": true + } +} diff --git a/packages/ai/config/jest/setup.ts b/packages/ai/config/jest/setup.ts new file mode 100644 index 00000000000..b9f894c181d --- /dev/null +++ b/packages/ai/config/jest/setup.ts @@ -0,0 +1,47 @@ +//@ts-ignore +globalThis.__DEV__ = true; + +import * as matchers from "jest-extended"; +expect.extend(matchers); + +import { TextDecoder, TextEncoder } from "util"; +import "@testing-library/jest-dom"; + +global.TextEncoder ??= TextEncoder; +// @ts-ignore +global.TextDecoder ??= TextDecoder; + +function fail(reason = "fail was called in a test.") { + expect(reason).toBe(undefined); +} + +// @ts-ignore +globalThis.fail = fail; + +if (!Symbol.dispose) { + Object.defineProperty(Symbol, "dispose", { + value: Symbol("dispose"), + }); +} +if (!Symbol.asyncDispose) { + Object.defineProperty(Symbol, "asyncDispose", { + value: Symbol("asyncDispose"), + }); +} + +// not available in JSDOM 🙄 +global.structuredClone = (val) => JSON.parse(JSON.stringify(val)); +global.ReadableStream ||= require("stream/web").ReadableStream; +global.TransformStream ||= require("stream/web").TransformStream; + +AbortSignal.timeout = (ms) => { + const controller = new AbortController(); + setTimeout( + () => + controller.abort( + new DOMException("The operation timed out.", "TimeoutError") + ), + ms + ); + return controller.signal; +}; diff --git a/packages/ai/jest.config.ts b/packages/ai/jest.config.ts new file mode 100644 index 00000000000..d1ae6e6d881 --- /dev/null +++ b/packages/ai/jest.config.ts @@ -0,0 +1,39 @@ +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +export default { + rootDir: "src", + preset: "ts-jest", + testEnvironment: fileURLToPath( + import.meta.resolve("../../config/FixJSDOMEnvironment.js") + ), + setupFilesAfterEnv: ["/../config/jest/setup.ts"], + testEnvironmentOptions: { + url: "http://localhost", + }, + snapshotFormat: { + escapeString: true, + printBasicPrototype: true, + }, + transform: { + "\\.tsx?$": [ + "ts-jest", + { + isolatedModules: true, + tsconfig: join(import.meta.dirname, "tsconfig.json"), + }, + ], + }, + resolver: fileURLToPath( + import.meta.resolve("../../src/config/jest/resolver.ts") + ), + transformIgnorePatterns: ["/node_modules/(?!(rxjs)/)"], + prettierPath: null, + testMatch: ["**/__tests__/**/*.test.ts", "**/__tests__/**/*.test.tsx"], + moduleNameMapper: { + "^@apollo/client/testing/internal$": + "/../../src/testing/internal/index.ts", + }, + displayName: "AI Workspace Tests", + testPathIgnorePatterns: [".d.ts$"], +}; diff --git a/packages/ai/package.json b/packages/ai/package.json new file mode 100644 index 00000000000..401972c803c --- /dev/null +++ b/packages/ai/package.json @@ -0,0 +1,54 @@ +{ + "name": "@apollo/client-ai", + "version": "0.1.0-alpha.0", + "description": "Apollo Client AI Tools", + "keywords": [ + "apollo", + "client", + "ai", + "mocking" + ], + "author": "packages@apollographql.com", + "license": "MIT", + "type": "module", + "sideEffects": false, + "repository": { + "type": "git", + "url": "git+https://github.com/apollographql/apollo-client.git", + "directory": "ai" + }, + "bugs": { + "url": "https://github.com/apollographql/apollo-client/issues" + }, + "homepage": "https://www.apollographql.com/docs/react/", + "exports": { + ".": "./dist/index.js" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "prebuild": "npm run clean", + "clean": "rimraf dist", + "build": "tsc", + "prepack": "npm run build", + "publint": "publint run --strict .", + "test": "node --expose-gc --experimental-import-meta-resolve --disable-warning=ExperimentalWarning ../../node_modules/jest/bin/jest.js --config jest.config.ts", + "test:watch": "npm run test -- --watch", + "test:coverage": "npm run test -- --coverage --watchAll=false" + }, + "dependencies": { + "@apollo/client": "*" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.6.3", + "@types/jest": "^29.5.12", + "jest": "^29.7.0", + "jest-extended": "^6.0.0", + "rimraf": "^5.0.9", + "ts-jest": "^29.2.3", + "typescript": "^5.8.3" + } +} diff --git a/packages/ai/src/global.d.ts b/packages/ai/src/global.d.ts new file mode 100644 index 00000000000..3b47093f482 --- /dev/null +++ b/packages/ai/src/global.d.ts @@ -0,0 +1 @@ +import "jest-extended"; diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts new file mode 100644 index 00000000000..91a2cb7a138 --- /dev/null +++ b/packages/ai/src/index.ts @@ -0,0 +1,3 @@ +export * from "./mocking/AIAdapter.js"; +export * from "./mocking/AIMockLink.js"; +export * from "./mocking/AIMockProvider.js"; diff --git a/packages/ai/src/mocking/AIAdapter.ts b/packages/ai/src/mocking/AIAdapter.ts new file mode 100644 index 00000000000..34145d6e4cc --- /dev/null +++ b/packages/ai/src/mocking/AIAdapter.ts @@ -0,0 +1,22 @@ +import { ApolloLink } from "@apollo/client"; + +export declare namespace AIAdapter { + export interface Options { + systemPrompt?: string; + } + + export type Result = ApolloLink.Result; +} + +export abstract class AIAdapter { + public systemPrompt?: string; + + constructor(options?: AIAdapter.Options) { + this.systemPrompt = options?.systemPrompt; + } + + public abstract generateObject( + prompt: string, + systemPrompt: string + ): Promise; +} diff --git a/packages/ai/src/mocking/AIMockLink.ts b/packages/ai/src/mocking/AIMockLink.ts new file mode 100644 index 00000000000..38a876fdde2 --- /dev/null +++ b/packages/ai/src/mocking/AIMockLink.ts @@ -0,0 +1,46 @@ +import { ApolloLink, Observable } from "@apollo/client"; +import { AIAdapter } from "./AIAdapter.js"; +import { BaseAIAdapter } from "./BaseAIAdapter.js"; + +export declare namespace AIMockLink { + export type DefaultOptions = {}; + + export interface Options { + adapter: AIAdapter; + showWarnings?: boolean; + schema?: string; + defaultOptions?: DefaultOptions; + } +} + +export class AIMockLink extends ApolloLink { + private adapter: BaseAIAdapter; + public showWarnings: boolean = true; + public schema: string | undefined; + public static defaultOptions: AIMockLink.DefaultOptions = {}; + + constructor(options: AIMockLink.Options) { + super(); + + this.schema = options.schema; + this.adapter = new BaseAIAdapter(options.adapter, { schema: this.schema }); + this.showWarnings = options.showWarnings ?? true; + } + + public request( + operation: ApolloLink.Operation + ): Observable { + return new Observable((observer) => { + try { + this.adapter.performQuery(operation).then((result) => { + // Notify the observer with the generated response + observer.next(result); + observer.complete(); + }); + } catch (error) { + observer.error(error); + observer.complete(); + } + }); + } +} diff --git a/packages/ai/src/mocking/AIMockProvider.tsx b/packages/ai/src/mocking/AIMockProvider.tsx new file mode 100644 index 00000000000..e8601b2c373 --- /dev/null +++ b/packages/ai/src/mocking/AIMockProvider.tsx @@ -0,0 +1,88 @@ +import * as React from "react"; + +import { ApolloClient } from "@apollo/client"; +import type { ApolloCache } from "@apollo/client/cache"; +import { InMemoryCache as Cache } from "@apollo/client/cache"; +import type { ApolloLink } from "@apollo/client/link"; +import type { LocalState } from "@apollo/client/local-state"; +import { ApolloProvider } from "@apollo/client/react"; +import { MockLink } from "@apollo/client/testing"; +import { AIAdapter } from "./AIAdapter.js"; +import { AIMockLink } from "./AIMockLink.js"; + +export interface AIMockedProviderProps { + adapter: AIAdapter; + systemPrompt?: string; + schema?: string; + defaultOptions?: ApolloClient.DefaultOptions; + cache?: ApolloCache; + localState?: LocalState; + childProps?: object; + children?: any; + link?: ApolloLink; + showWarnings?: boolean; + mockLinkDefaultOptions?: MockLink.DefaultOptions; + devtools?: ApolloClient.Options["devtools"]; +} + +interface AIMockedProviderState { + client: ApolloClient; +} + +export class AIMockedProvider extends React.Component< + AIMockedProviderProps, + AIMockedProviderState +> { + constructor(props: AIMockedProviderProps) { + super(props); + + const { + adapter, + schema, + defaultOptions, + cache, + localState, + link, + showWarnings, + mockLinkDefaultOptions, + devtools, + } = this.props; + + const client = new ApolloClient({ + cache: cache || new Cache(), + defaultOptions, + link: + link || + new AIMockLink({ + adapter, + schema, + showWarnings, + defaultOptions: mockLinkDefaultOptions, + }), + localState, + devtools, + }); + + this.state = { + client, + }; + } + + public render() { + const { children, childProps } = this.props; + const { client } = this.state; + + return React.isValidElement(children) ? + + {React.cloneElement(React.Children.only(children), { ...childProps })} + + : null; + } + + public componentWillUnmount() { + // Since this.state.client was created in the + // constructor, it's this MockedProvider's responsibility + // to terminate it. + this.state.client.stop(); + } +} diff --git a/packages/ai/src/mocking/BaseAIAdapter.ts b/packages/ai/src/mocking/BaseAIAdapter.ts new file mode 100644 index 00000000000..92b7c61e8ff --- /dev/null +++ b/packages/ai/src/mocking/BaseAIAdapter.ts @@ -0,0 +1,103 @@ +import { ApolloLink } from "@apollo/client"; +import { AIAdapter } from "./AIAdapter.js"; +import { print } from "graphql"; +import { BASE_SYSTEM_PROMPT } from "./consts.js"; +import { GrowingSchema } from "./GrowingSchema/index.js"; + +export declare namespace BaseAIAdapter { + export interface Options { + schema?: string; + } +} + +export class BaseAIAdapter { + private static baseSystemPrompt = BASE_SYSTEM_PROMPT; + private schema: GrowingSchema; + + constructor( + private implementation: AIAdapter, + options: BaseAIAdapter.Options + ) { + this.schema = new GrowingSchema({ schema: options.schema }); + } + + /** + * Performs a query using the implementation adapter. + * @param operation - The operation to perform. + * @returns The result of the query. + */ + public async performQuery( + operation: ApolloLink.Operation + ): Promise { + const systemPrompt = BaseAIAdapter.createSystemPrompt( + this.implementation.systemPrompt + ); + + const prompt = BaseAIAdapter.createPrompt(operation); + + const result = await this.implementation.generateObject( + prompt, + systemPrompt + ); + + // Add the operation to the schema. + await this.schema.add(operation, result); + + return result; + } + + /** + * Creates a system prompt from the base system prompt and the provided prompt. + * @param prompt - The prompt to add to the base system prompt. + * @returns The system prompt. + */ + private static createSystemPrompt(prompt?: string) { + return [BaseAIAdapter.baseSystemPrompt, prompt] + .filter(Boolean) + .join("\n\n"); + } + + /** + * Creates a prompt from the operation. + * @param operation - The operation to create a prompt from. + * @returns The prompt. + */ + private static createPrompt(operation: ApolloLink.Operation): string { + const { query, variables } = operation; + const providedPrompt = operation.getContext().prompt; + + // Try to get the GraphQL query document string + // from the AST location if available, otherwise + // use the `print` function to get the query string. + // + // The AST location may not be available if the query + // was parsed with the `noLocation: true` option. + // + // If the query document string is available through + // the AST location, it will save some processing time + // over the `print` function. + const queryString = query?.loc?.source?.body ?? print(query); + + const promptParts = [ + "Give me mock data that fulfills this query:", + "```graphql", + queryString, + "```", + ]; + + if (variables) { + promptParts.push( + "\n", + "```json", + JSON.stringify(variables, null, 2), + "```" + ); + } + + if (providedPrompt) { + promptParts.push("\n", "Additional instructions:", providedPrompt); + } + + return promptParts.join("\n"); + } +} diff --git a/packages/ai/src/mocking/GrowingSchema/GrowingSchema.ts b/packages/ai/src/mocking/GrowingSchema/GrowingSchema.ts new file mode 100644 index 00000000000..ad3890b3072 --- /dev/null +++ b/packages/ai/src/mocking/GrowingSchema/GrowingSchema.ts @@ -0,0 +1,572 @@ +import { + buildASTSchema, + DocumentNode, + execute, + FieldDefinitionNode, + FormattedExecutionResult, + GraphQLError, + GraphQLInputObjectType, + GraphQLObjectType, + GraphQLSchema, + GraphQLUnionType, + InputObjectTypeDefinitionNode, + InputValueDefinitionNode, + Kind, + NamedTypeNode, + ObjectTypeDefinitionNode, + parse, + printSchema, + UnionTypeDefinitionNode, + visit, +} from "graphql"; +import { AIAdapter } from "../AIAdapter.js"; +import { + BuiltInScalarType, + GraphQLOperation, + OperationSchema, +} from "./OperationSchema.js"; +import { + graphQLInputObjectTypeToInputObjectDefinitionNode, + graphQLObjectTypeToObjectTypeDefinitionNode, + graphQLUnionTypeToUnionTypeDefinitionNode, + RootTypeName, + sortASTNodes, +} from "../../utils.js"; +import { PLACEHOLDER_QUERY_NAME } from "../consts.js"; + +/** + * A schema that is progressively built as operations are added. + */ +export class GrowingSchema { + /** + * The schema that is progressively built as operations are added. + * + * We start with a schema containing an empty query type. + * We will build the schema up as we go. + */ + public schema = new GraphQLSchema({}); + + /** + * The base schema that is used to build the schema. + */ + private baseSchema: DocumentNode | null = null; + + // We need to track the seen queries with their variables to + // accommodate changes to the input objects defined via the + // variables. + // + // This will likely result in extra schema building attempts + // that are mostly "skipped" but are necessary to ensure that + // the input objects are correct. + private seenQueries = new WeakSet(); + + /** + * The queue of schema merge tasks. + * They must be treated as a queue to avoid race conditions. + */ + private mergeTaskQueue: { + operationSchema: OperationSchema; + resolve: (value: void) => void; + reject: (error: Error) => void; + }[] = []; + + /** + * Whether an operation is currently being merged into the schema. + */ + private mergingOperation = false; + + constructor(options: { schema?: string } = {}) { + if (options.schema) { + this.baseSchema = parse(options.schema); + } + } + + /** + * Adds an operation to the schema. + * @param operationDocument — The operation to add to the schema. + * @param response — The response to the operation. + */ + public async add( + operationDocument: GraphQLOperation, + response: AIAdapter.Result + ): Promise { + if (!this.seenQueries.has(operationDocument)) { + // Return a promise that will be resolved when the operation is merged + // into the schema + return this.mergeOperationIntoSchema(operationDocument, response); + } + // If the operation has already been seen, resolve immediately + return Promise.resolve(); + } + + /** + * Merges an operation into the schema. + * @param operationDocument — The operation to merge into the schema. + * @param response — The response to the operation. + */ + private async mergeOperationIntoSchema( + operationDocument: GraphQLOperation, + response: AIAdapter.Result + ): Promise { + // Create a schema for the operation + const operationSchema = new OperationSchema( + operationDocument, + response, + this.baseSchema + ); + + return new Promise((resolve, reject) => { + // Add to the merge task queue with its own promise handlers + this.mergeTaskQueue.push({ + operationSchema, + resolve, + reject, + }); + + // Start processing the merge task queue if it is not already running + this.processMergeTaskQueue(); + }); + } + + /** + * Processes the merge task queue. + */ + private processMergeTaskQueue() { + // If already processing, return. + // the queue will continue to be processed automatically. + if (this.mergingOperation) { + return; + } + + // Process the next merge task in the queue + const nextMergeTask = this.mergeTaskQueue.shift(); + if (!nextMergeTask) { + // If the queue is empty, return + return; + } + + // Start merging the operation into the schema + this.mergingOperation = true; + this.mergeAndUpdateSchema(nextMergeTask.operationSchema) + .then(() => { + // Merging the operation into the schema succeeded :) + nextMergeTask.resolve(); + }) + .catch((error) => { + // Merging the operation into the schema failed :( + nextMergeTask.reject(error); + }) + .finally(() => { + // Move on to the next merge task + this.mergingOperation = false; + + // Process the next merge task in the queue + if (this.mergeTaskQueue.length > 0) { + this.processMergeTaskQueue(); + } + }); + } + + /** + * Merges an operation schema into the main schema. + * @param operationSchema + */ + private async mergeAndUpdateSchema( + operationSchema: OperationSchema + ): Promise { + // Save the previous schema to restore it if the operation fails + const previousSchema = this.schema; + + try { + // Merge the operation schema into the main schema + const finalAst = visit(operationSchema.ast, { + [Kind.OBJECT_TYPE_DEFINITION]: (node) => { + const updatedNode = this.mergeObjectTypeDefinition(node); + return updatedNode; + }, + [Kind.UNION_TYPE_DEFINITION]: (node) => { + return this.mergeUnionTypeDefinition(node); + }, + [Kind.INPUT_OBJECT_TYPE_DEFINITION]: (node) => { + return this.mergeInputObjectTypeDefinition(node); + }, + }); + + // Update the main schema with the merged operation schema + this.schema = buildASTSchema(finalAst); + + // Validate that the operation and response are valid against the schema + this.validateOperationAndResponseAgainstSchema( + operationSchema.operationDocument, + operationSchema.response + ); + + // Mark the operation as seen + this.seenQueries.add(operationSchema.operationDocument); + } catch (e) { + // Restore the previous schema if the operation fails + this.schema = previousSchema; + throw e; + } + } + + /** + * Merges a field definition into the schema. + * + * A field has been updated if: + * + * 1. There is a new field + * 2. The arguments of the field have changed + * + * A change to a field return type should be skipped. + * @param newField — The field definition to merge into the schema. + * @param fields — The fields to merge into the schema. + */ + private mergeFieldDefinition( + newField: FieldDefinitionNode, + fields: Map + ) { + const existingField = fields.get(newField.name.value); + + // If the field is new, add it to the fields map + if (!existingField) { + fields.set(newField.name.value, newField); + return; + } + + // Merge the arguments of the field + const existingArgs = new Map( + existingField.arguments?.map((arg) => [arg.name.value, arg]) || [] + ); + const mergedArgs = + newField.arguments?.reduce((acc, arg) => { + acc.set(arg.name.value, arg); + return acc; + }, new Map(existingArgs)) || existingArgs; + + if (mergedArgs.size > existingArgs.size) { + // If the field has new arguments, update the field definition + fields.set(newField.name.value, { + ...existingField, + arguments: [...mergedArgs.values()], + } as FieldDefinitionNode); + } + + // Return the updated fields map + return fields; + } + + /** + * Merges an input value definition into the schema. + * @param newField — The input value definition to merge into the schema. + * @param fields — The input value definitions to merge into the schema. + */ + private mergeInputValueDefinition( + newField: InputValueDefinitionNode, + fields: Map + ) { + // A field is updated if it is a new field. A change to a field + // return type should be skipped. + const existingField = fields.get(newField.name.value); + + // If the field is new, add it to the fields map + if (!existingField) { + fields.set(newField.name.value, newField); + return; + } + + // Return the updated fields map + return fields; + } + + /** + * Merges an input object type definition into the schema. + * @param node — The input object type definition to merge into the schema. + * @returns The merged input object type definition. + */ + private mergeInputObjectTypeDefinition(node: InputObjectTypeDefinitionNode) { + const existingType = this.schema.getType(node.name.value); + + // If the type does not exist, return the node + if (!existingType) { + return node; + } + + // If the type is not an input object type, throw an error + if (!(existingType instanceof GraphQLInputObjectType)) { + throw new Error( + `Expected ${ + node.name.value + } to be an input object type. encountered ${existingType.toString()}` + ); + } + + // Get the existing input object type definition AST node + const existingTypeAstNode = + existingType.astNode || + graphQLInputObjectTypeToInputObjectDefinitionNode(existingType); + const updatedFields = this.collectUpdatedInputValueDefinitions( + node, + existingType + ); + + // If there are no updated fields, return the existing input object type + // definition AST node + if (updatedFields.length === 0) { + return existingTypeAstNode; + } + + // Return the updated input object type definition AST node + return { + ...existingTypeAstNode, + fields: [...updatedFields], + }; + } + + /** + * Merges an object type definition into the schema. + * @param node — The object type definition to merge into the schema. + * @returns The merged object type definition. + */ + private mergeObjectTypeDefinition(node: ObjectTypeDefinitionNode) { + const existingType = this.schema.getType(node.name.value); + + // If the type does not exist, return the node + if (!existingType) { + return node; + } + + // If the type is not an object type definition, throw an error + if (!(existingType instanceof GraphQLObjectType)) { + throw new Error( + `Expected ${ + node.name.value + } to be an object type. encountered ${existingType.toString()}` + ); + } + + // Get the existing object type definition AST node + const existingTypeAstNode = + existingType.astNode || + graphQLObjectTypeToObjectTypeDefinitionNode(existingType); + const updatedFields = this.collectUpdatedFieldDefinitions( + node, + existingType + ); + + // If there are no updated fields, return the existing object type + // definition AST node + if (updatedFields.length === 0) { + return existingTypeAstNode; + } + + // Return the updated object type definition AST node + return { + ...existingTypeAstNode, + fields: [...updatedFields], + }; + } + + /** + * Merges a union type definition into the schema. + * @param node — The union type definition to merge into the schema. + * @returns The merged union type definition. + */ + private mergeUnionTypeDefinition(node: UnionTypeDefinitionNode) { + const existingType = this.schema.getType(node.name.value); + + // If the type does not exist, return the node + if (!existingType) { + return node; + } + + // If the type is not a union type, throw an error + if (!(existingType instanceof GraphQLUnionType)) { + throw new Error( + `Expected ${ + node.name.value + } to be a union type. encountered ${existingType.toString()}` + ); + } + + // Get the existing union type definition AST node + const existingTypeAstNode = + existingType.astNode || + graphQLUnionTypeToUnionTypeDefinitionNode(existingType); + const updatedMembers = this.collectUpdatedUnionMembers(node, existingType); + + // Return the updated union type definition AST node + return { + ...existingTypeAstNode, + types: [...updatedMembers], + }; + } + + /** + * Collects the updated field definitions for an object type definition. + * @param node — The object type definition to collect the updated field + * definitions for. + * @param existingType — The existing object type definition. + * @returns The updated field definitions. + */ + private collectUpdatedFieldDefinitions( + node: ObjectTypeDefinitionNode, + existingType: GraphQLObjectType + ): FieldDefinitionNode[] { + // Create a map of the existing fields + let fields = new Map( + Object.values(existingType.getFields()).map((field) => [ + field.name, + field.astNode as FieldDefinitionNode, + ]) + ); + + // Merge the field definitions from the new node with the existing fields + node.fields?.forEach((field) => { + this.mergeFieldDefinition(field, fields); + }); + + if (node.name.value === RootTypeName.QUERY) { + // Remove the placeholder query field from the fields map if there are + // real query fields present. + // + // The placeholder query field is only necessary when there are no real + // query fields in the schema. + const fieldsWithoutPlaceholder = new Map(fields); + fieldsWithoutPlaceholder.delete(PLACEHOLDER_QUERY_NAME); + if (fieldsWithoutPlaceholder.size > 0) { + fields = fieldsWithoutPlaceholder; + } + } + + // Return the updated field definitions + return sortASTNodes([...fields.values()]) as FieldDefinitionNode[]; + } + + /** + * Collects the updated input value definitions for an input object type + * definition. + * @param node — The input object type definition to collect the updated input + * value definitions for. + * @param existingType — The existing input object type definition. + * @returns The updated input value definitions. + */ + private collectUpdatedInputValueDefinitions( + node: InputObjectTypeDefinitionNode, + existingType: GraphQLInputObjectType + ): InputValueDefinitionNode[] { + // Create a map of the existing fields + const fields = new Map( + Object.values(existingType.getFields()).map((field) => [ + field.name, + field.astNode as InputValueDefinitionNode, + ]) + ); + + // Merge the input value definitions from the new node with the existing + // fields + node.fields?.forEach((field) => { + this.mergeInputValueDefinition(field, fields); + }); + + // Return the updated input value definitions + return sortASTNodes([...fields.values()]) as InputValueDefinitionNode[]; + } + + /** + * Collects the updated union members for a union type definition. + * @param node — The union type definition to collect the updated union + * members for. + * @param existingType — The existing union type definition. + * @returns The updated union members. + */ + private collectUpdatedUnionMembers( + node: UnionTypeDefinitionNode, + existingType: GraphQLUnionType + ): NamedTypeNode[] { + // Return the updated union members + return sortASTNodes([ + ...new Set([ + ...(existingType.astNode?.types || []), + ...(node.types || []), + ]), + ]); + } + + /** + * Validates an operation and response against the schema. + * @param operation — The operation to validate. + * @param response — The response to the operation. + */ + private validateOperationAndResponseAgainstSchema( + operation: GraphQLOperation, + response: FormattedExecutionResult, Record> + ) { + // Execute the operation against the schema + const result = execute({ + schema: this.schema, + document: operation.query, + variableValues: operation.variables, + fieldResolver: (source, args, context, info) => { + const value = source[info.fieldName]; + + // We use field resolvers to be more strict with the value types that + // were returned by the AI. + switch (info.returnType.toString()) { + case BuiltInScalarType.STRING: + if (typeof value !== "string") { + throw new TypeError( + `Value for scalar type ${BuiltInScalarType.STRING} is not string: ${value}` + ); + } + break; + case BuiltInScalarType.FLOAT: + if (typeof value !== "number") { + throw new TypeError( + `Value for scalar type ${BuiltInScalarType.FLOAT} is not number: ${value}` + ); + } + break; + case BuiltInScalarType.INT: + if (typeof value !== "number") { + throw new TypeError( + `Value for scalar type ${BuiltInScalarType.INT} is not number: ${value}` + ); + } + break; + case BuiltInScalarType.BOOLEAN: + if (typeof value !== "boolean") { + throw new TypeError( + `Value for scalar type ${BuiltInScalarType.BOOLEAN} is not boolean: ${value}` + ); + } + break; + } + + return value; + }, + rootValue: response.data, + }) as FormattedExecutionResult; + + if (result.errors?.length) { + const operationName = operation.query.definitions.find( + (def) => def.kind === Kind.OPERATION_DEFINITION + )?.name?.value; + throw new GraphQLError( + `Error executing query \`${ + operationName ? operationName : "unnamed query" + }\` against grown schema: ${result.errors + .map((e) => e.message) + .join(", ")}` + ); + } + } + + /** + * Returns a string representation of the schema. + * @returns The string representation of the schema. + */ + public toString(): string { + return printSchema(this.schema); + } +} diff --git a/packages/ai/src/mocking/GrowingSchema/OperationSchema.ts b/packages/ai/src/mocking/GrowingSchema/OperationSchema.ts new file mode 100644 index 00000000000..ba1bc7fa370 --- /dev/null +++ b/packages/ai/src/mocking/GrowingSchema/OperationSchema.ts @@ -0,0 +1,1320 @@ +import { + DocumentNode, + OperationDefinitionNode, + OperationTypeNode, + Kind, + GraphQLError, + GraphQLErrorOptions, + TypeNode, + ObjectTypeDefinitionNode, + UnionTypeDefinitionNode, + InputObjectTypeDefinitionNode, + SelectionSetNode, + FieldDefinitionNode, + InputValueDefinitionNode, + GraphQLSchema, + buildASTSchema, + printSchema, + ValueNode, + VariableDefinitionNode, + NamedTypeNode, + ObjectFieldNode, + FieldNode, + InlineFragmentNode, + FragmentDefinitionNode, + visit, + ScalarTypeDefinitionNode, + DirectiveDefinitionNode, +} from "graphql"; +import { AIAdapter } from "../AIAdapter.js"; +import { + deepMerge, + isFloat, + RootTypeName, + singularize, + sortASTNodes, + sortObjectASTNodes, + sortUnionMembers, + ucFirst, +} from "../../utils.js"; +import { PLACEHOLDER_QUERY_NAME } from "../consts.js"; + +/** + * The mapping of the operation field name to the root type name. + */ +const OPERATION_FIELD_TO_TYPE_NAME = { + [OperationTypeNode.MUTATION]: RootTypeName.MUTATION, + [OperationTypeNode.QUERY]: RootTypeName.QUERY, + [OperationTypeNode.SUBSCRIPTION]: RootTypeName.SUBSCRIPTION, +}; + +export type GraphQLOperation = { + query: DocumentNode; + variables?: Record; +}; + +export enum BuiltInScalarType { + ID = "ID", + INT = "Int", + FLOAT = "Float", + BOOLEAN = "Boolean", + STRING = "String", +} + +enum FieldReturnType { + OBJECT = "object", + UNION = "union", + SCALAR = "scalar", +} + +type ObjectReturnType = { + type: TypeNode; + kind: FieldReturnType.OBJECT; + typeName: string; +}; + +type UnionReturnType = { + type: TypeNode; + kind: FieldReturnType.UNION; + typeNames: string[]; +}; + +type ScalarReturnType = { + type: TypeNode; + kind: FieldReturnType.SCALAR; + scalarType: BuiltInScalarType; +}; + +type ReturnType = ObjectReturnType | UnionReturnType | ScalarReturnType; + +export const BUILT_IN_SCALAR_TYPES = Object.values(BuiltInScalarType); + +/** + * A schema that is built from an operation and response. + */ +export class OperationSchema { + /** + * The type of the operation. + */ + public readonly type: OperationTypeNode; + /** + * The name of the type of the operation. + */ + public readonly typeName: string; + /** + * The name of the operation. + */ + public readonly operationName: string; + /** + * The operation document's AST node. + */ + private readonly operationNode: OperationDefinitionNode; + /** + * The variable definitions extracted from the operation document. + */ + private readonly variableDefinitions = new Map< + string, + VariableDefinitionNode + >(); + /** + * The operation's variables. + */ + private readonly variables: Record; + /** + * A map of paths to return types metadata. + */ + private paths = new Map(); + /** + * A map of fragment definitions extracted from the operation document. + */ + private readonly fragmentDefinitions = new Map< + string, + FragmentDefinitionNode + >(); + /** + * A map of object type definitions extracted from the operation document. + */ + public objectTypeDefinitions = new Map(); + /** + * A map of union type definitions extracted from the operation document. + */ + public unionTypeDefinitions = new Map(); + /** + * A map of input object type definitions extracted from the operation + * document. + */ + public inputObjectTypeDefinitions = new Map< + string, + InputObjectTypeDefinitionNode + >(); + /** + * A map of scalar type definitions extracted from the operation document. + */ + public scalarTypeDefinitions = new Map(); + /** + * A map of directive definitions extracted from the operation document. + */ + public directiveDefinitions = new Map(); + /** + * The schema that has been built for the operation. + */ + private _schema: GraphQLSchema | undefined; + /** + * The AST of the built schema. + */ + private _ast: DocumentNode | undefined; + /** + * The string representation of the built schema. + */ + private _schemaString: string | undefined; + + constructor( + public readonly operationDocument: GraphQLOperation, + public readonly response: AIAdapter.Result, + baseSchema?: DocumentNode | null + ) { + const { query: queryDocument, variables } = operationDocument; + const operationNodes = queryDocument.definitions.filter( + (definition) => definition.kind === Kind.OPERATION_DEFINITION + ); + + if (operationNodes.length === 0) { + this.throwError("No operation definition found in operation document", { + nodes: queryDocument, + }); + } + + if (operationNodes.length > 1) { + this.throwError( + "No operation definition found in operation document. Only single operation definitions are supported.", + { + nodes: queryDocument, + } + ); + } + + if (baseSchema) { + this.seedSchema(baseSchema); + } + + this.operationNode = operationNodes[0]; + this.variableDefinitions = new Map( + this.operationNode.variableDefinitions?.map((variable) => [ + variable.variable.name.value, + variable, + ]) + ); + this.variables = variables || {}; + this.type = this.operationNode.operation; + this.typeName = OPERATION_FIELD_TO_TYPE_NAME[this.type]; + this.operationName = this.operationNode.name?.value ?? "Unnamed Operation"; + + // Collect all the fragment definitions + queryDocument.definitions.forEach((selection) => { + if (selection.kind === Kind.FRAGMENT_DEFINITION) { + this.fragmentDefinitions.set(selection.name.value, selection); + } + }); + + // Crawl the variables, response, and selection set + // to create the schema + this.crawlVariables(); + this.crawlResponse(); + this.crawlSelectionSet( + [this.typeName], + this.typeName, + this.operationNode.selectionSet + ); + this.ensureQueryExists(); + } + + /** + * Seeds the schema with the base schema. + * @param baseSchema + */ + private seedSchema(baseSchema: DocumentNode) { + visit(baseSchema, { + [Kind.OBJECT_TYPE_DEFINITION]: (node) => { + this.objectTypeDefinitions.set(node.name.value, node); + }, + [Kind.UNION_TYPE_DEFINITION]: (node) => { + this.unionTypeDefinitions.set(node.name.value, node); + }, + [Kind.INPUT_OBJECT_TYPE_DEFINITION]: (node) => { + this.inputObjectTypeDefinitions.set(node.name.value, node); + }, + [Kind.SCALAR_TYPE_DEFINITION]: (node) => { + this.scalarTypeDefinitions.set(node.name.value, node); + }, + [Kind.DIRECTIVE_DEFINITION]: (node) => { + this.directiveDefinitions.set(node.name.value, node); + }, + }); + } + + private ensureQueryExists() { + if (!this.objectTypeDefinitions.has(RootTypeName.QUERY)) { + this.objectTypeDefinitions.set(RootTypeName.QUERY, { + kind: Kind.OBJECT_TYPE_DEFINITION, + name: { kind: Kind.NAME, value: RootTypeName.QUERY }, + fields: [ + { + type: { + kind: Kind.NAMED_TYPE, + name: { kind: Kind.NAME, value: BuiltInScalarType.BOOLEAN }, + }, + kind: Kind.FIELD_DEFINITION, + name: { kind: Kind.NAME, value: PLACEHOLDER_QUERY_NAME }, + }, + ], + }); + } + } + + /** + * Crawl the variable definitions to create the input objects. + */ + private crawlVariables() { + // Create all input objects from the operation's variable definitions. + // By doing this here, we _may_ create unused input objects, but this + // helps us avoid complexity in tying input objects to field definitions. + this.variableDefinitions.forEach((variableDefinition, variableName) => { + // Find the variable value that is related to the variable definition. + const relatedVariableValue = this.variables[variableName]; + + if (relatedVariableValue === undefined) { + // If the variable value is not defined, then we have an error. + this.throwError(`Variable '${variableName}' is not defined`); + } + + // Get the leaf type of the variable definition. + const variableType = OperationSchema.getLeafType(variableDefinition.type); + + // If the variable type is not a built-in scalar type, then we need to + // create an input object for it. + // + // The user _may_ mean to use a custom scalar, but we don't support that + // yet. + if ( + !BUILT_IN_SCALAR_TYPES.includes( + variableType.name.value as BuiltInScalarType + ) + ) { + // Create the input object for this variable and any other + // input objects from its fields. + this.getInputObjectsForVariableValue( + variableType.name.value, + relatedVariableValue + ); + } + }); + } + + /** + * Crawl the response to create the return types. + */ + private crawlResponse() { + if (!this.response.data) { + throw new GraphQLError("No 'data' found in operation response"); + } + Object.entries(this.response.data).forEach(([key, value]) => { + this.crawlValue([this.typeName], key, value); + }); + } + + /** + * Crawl the value to create the return types. + * @param previousPath — The path to the parent of the current value. + * @param name — The name of the value. + * @param value — The value to crawl. + * @returns The return type. + */ + private crawlValue(previousPath: string[], name: string, value: any) { + if (name === "__typename") { + // Skip __typename fields. They're implicit in the schema. + return; + } + // Create the path to the current value. + const currentPath = [...previousPath, name]; + + // Add the path the paths map along with its return type metadata. + this.addPath(currentPath, name, value); + + if (typeof value === "object") { + // If the value is an object, then we need to crawl it. + if (Array.isArray(value)) { + // If the value is an array, then we need to crawl it. + this.crawlResponseArray(currentPath, name, value); + return; + } + // Otherwise, this is an object and we need to crawl it another way. + this.crawlResponseObject(currentPath, value); + } + } + + /** + * Crawl the array to create the return types. + * @param currentPath — The path to the parent of the current value. + * @param name — The name of the current value. + * @param value — The array to crawl. + */ + private crawlResponseArray( + currentPath: string[], + name: string, + value: any[] + ) { + // Get the type of all the items in the array so we can handle divergence. + value.forEach((member) => { + // Get the return type name for the member. + this.getFieldReturnTypeName(name, member); + if (Array.isArray(member)) { + // If the member is an array, then we need to crawl it. + this.crawlResponseArray(currentPath, name, member); + return; + } + if (typeof member === "object") { + // If the member is an object, then we need to crawl it. + this.crawlResponseObject(currentPath, member); + return; + } + }); + } + + /** + * Crawl the object to create the return types. + * @param currentPath — The path to the parent of the current value. + * @param value — The object to crawl. + */ + private crawlResponseObject(currentPath: string[], value: any) { + Object.entries(value).forEach(([childKey, childValue]) => + this.crawlValue(currentPath, childKey, childValue) + ); + } + + /** + * Crawl the selection set to create the return types. + * @param previousPath — The path to the parent of the current value. + * @param currentTypeName — The name of the current type. + * @param selectionSet — The selection set to crawl. + */ + private crawlSelectionSet( + previousPath: string[], + currentTypeName: string, + selectionSet: SelectionSetNode + ) { + // Either get the existing object type definition so we can update it if + // it already exists, or create a new one. + let objectTypeDefinition = this.objectTypeDefinitions.get( + currentTypeName + ) || { + kind: Kind.OBJECT_TYPE_DEFINITION, + name: { kind: Kind.NAME, value: currentTypeName }, + fields: [], + }; + + // Get the fields from the object type definition. + const fields = new Map( + objectTypeDefinition.fields?.map((field) => [field.name.value, field]) + ); + + // Crawl the selection set. + selectionSet.selections.forEach((selection) => { + switch (selection.kind) { + case Kind.FIELD: + const field = this.handleFieldSelection( + selection, + previousPath, + objectTypeDefinition + ); + if (field) { + fields.set(selection.name.value, field); + } + break; + case Kind.INLINE_FRAGMENT: + this.handleInlineFragmentSelection(selection, previousPath); + break; + case Kind.FRAGMENT_SPREAD: + const fragmentDefinition = this.fragmentDefinitions.get( + selection.name.value + ); + if (fragmentDefinition) { + this.crawlSelectionSet( + previousPath, + fragmentDefinition.typeCondition.name.value, + fragmentDefinition.selectionSet + ); + } + break; + } + }); + + if (fields.size === 0) { + // If there are no fields, then we don't need to add anything to the schema + return; + } + + // Add or update the object type definition + this.objectTypeDefinitions.set(objectTypeDefinition.name.value, { + ...objectTypeDefinition, + fields: sortASTNodes([...fields.values()]), + }); + } + + /** + * Handle the field selection. + * @param selection — The field selection. + * @param previousPath — The path to the parent of the current value. + * @param objectTypeDefinition — The object type definition. + * @returns The field definition. + */ + private handleFieldSelection( + selection: FieldNode, + previousPath: string[], + objectTypeDefinition: ObjectTypeDefinitionNode + ): FieldDefinitionNode | undefined { + // skip __typename fields. They're implicit in the schema. + if (selection.name.value === "__typename") { + return; + } + + // Get the existing field definition + const existingField = objectTypeDefinition?.fields?.find( + (field) => field.name.value === selection.name.value + ); + + // Get the existing field arguments or create an empty map for tracking + // the field's arguments + const fieldArgs = new Map( + existingField?.arguments?.map((obj) => [obj.name.value, obj]) + ); + + // Collect the arguments from the field selection + if (selection.arguments?.length) { + selection.arguments?.forEach((arg) => { + if (fieldArgs.has(arg.name.value)) { + return; + } + fieldArgs.set(arg.name.value, { + kind: Kind.INPUT_VALUE_DEFINITION, + name: { + kind: Kind.NAME, + value: arg.name.value, + }, + type: this.getArgumentTypeNode(arg.name.value, arg.value), + }); + }); + } + + // Get the return type for the field + let returnType = existingField?.type; + + // Create the path to the current value. + const currentPath = [...previousPath, selection.name.value]; + + // Get the return type from the paths map. + let matchedReturnType = this.paths.get(currentPath.join(".")); + + // If there is no return type for the field, we have an error + if (!matchedReturnType) { + this.throwError( + `No return type found for field '${selection.name.value}'`, + { + extensions: { + name: selection.name.value, + matchedReturnType, + }, + } + ); + } + + // If the return type is an array, we need to create a union type for it. + // + // This happens when a field is nested in a list — a field on a type in + // the list — that has inconsistent return types. For example: + // [ + // { + // __typename: "User", + // name: "John", + // occupation: { __typename: "Doctor", type: "Dentist"} + // }, + // { + // __typename: "User", + // name: "Jane", + // occupation: { __typename: "Finance", type: "Banker"} + // }, + // ] + // This would handle the "occupation" field on the "User" type. + if (Array.isArray(matchedReturnType)) { + if (selection.selectionSet) { + const typeNames = [ + ...new Set( + matchedReturnType.flatMap((type) => { + if (type.kind === FieldReturnType.UNION) { + return type.typeNames; + } + if (type.kind === FieldReturnType.OBJECT) { + return [type.typeName]; + } + return []; + }) + ), + ]; + matchedReturnType = this.createUnionReturnType(typeNames); + } + } + + if (Array.isArray(matchedReturnType)) { + // If the return type is still an array, we have an error. + this.throwError("Matched return type is an array.", { + extensions: { + matchedReturnType, + }, + }); + } + + if ( + matchedReturnType.kind === FieldReturnType.UNION && + selection.selectionSet + ) { + // If the field return type is a union, we need to crawl its selection set + // to add any additional fields to the schema. + matchedReturnType.typeNames.forEach((typeName) => { + this.crawlSelectionSet(currentPath, typeName, selection.selectionSet!); + }); + } + + if ( + matchedReturnType.kind === FieldReturnType.OBJECT && + selection.selectionSet + ) { + // If the field return type is an object, we need to crawl its + // selection set to add any additional fields to the schema + this.crawlSelectionSet( + currentPath, + matchedReturnType.typeName, + selection.selectionSet + ); + } + + // Get the return type from the matched return type. + returnType = matchedReturnType.type; + + // Create the field definition. + return { + kind: Kind.FIELD_DEFINITION, + name: { kind: Kind.NAME, value: selection.name.value }, + type: returnType, + arguments: sortASTNodes([...fieldArgs.values()]), + }; + } + + /** + * Handle the inline fragment selection. + * @param selection — The inline fragment selection. + * @param previousPath — The path to the parent of the current value. + * @returns The field definition. + */ + private handleInlineFragmentSelection( + selection: InlineFragmentNode, + previousPath: string[] + ): FieldDefinitionNode | undefined { + if (!selection.typeCondition) { + this.throwError("Inline fragment must have a type condition", { + extensions: { + selection, + }, + }); + } + const typeCondition = selection.typeCondition.name.value; + this.crawlSelectionSet(previousPath, typeCondition, selection.selectionSet); + return; + } + + /** + * Add a path to the paths map. + * @param path — The path to the parent of the current value. + * @param name — The name of the current value. + * @param value — The value to add to the paths map. + * @returns The field definition. + */ + private addPath(path: string[], name: string, value: any) { + const typeNode = this.getFieldReturnTypeNode(name, value); + const pathString = path.join("."); + const existingPathEntry = this.paths.get(pathString); + if (!existingPathEntry || typeNode.kind === FieldReturnType.SCALAR) { + // If there is no existing path entry or the type node is a scalar, + // then we can just set the path entry to the type node. + this.paths.set(pathString, typeNode); + return; + } + if (Array.isArray(existingPathEntry)) { + // If the existing path entry is an array, then we can just push the type + // node to the array. + existingPathEntry.push(typeNode); + return; + } + // If there is an existing path entry, then we can create a new array with + // the existing path entry and the new type node. + this.paths.set(pathString, [existingPathEntry, typeNode]); + } + + /** + * Get the return type node for the field. + * @param name — The name of the field. + * @param value — The value of the field. + * @returns The return type node. + */ + private getFieldReturnTypeNode(name: string, value: any): ReturnType { + if (typeof value === "undefined") { + this.throwError(`No value provided for ${name}`, { + extensions: { + name, + value, + }, + }); + } + + if (typeof value === "object") { + // If the value is an object, we need to determine the return type node. + if (Array.isArray(value)) { + // If the value is an array, we need to determine whether the return + // type is a consistent list or a union of types. + const uniqueMembers = this.getUniqueMembers(name, value); + + // If there are multiple unique members, we need to create a union type. + if (uniqueMembers.size > 1) { + const unionName = this.createUnionTypeDefinition([ + ...uniqueMembers.keys(), + ]); + const unionReturnType = this.createUnionReturnType( + [...uniqueMembers.keys()], + unionName + ); + return { + ...unionReturnType, + type: { + kind: Kind.LIST_TYPE, + type: unionReturnType.type, + }, + }; + } + // If there are no multiple unique members, we can just return the + // return type for the first member. + return { + type: { + kind: Kind.LIST_TYPE, + type: this.getFieldReturnTypeNode(name, value[0]).type, + }, + kind: FieldReturnType.OBJECT, + typeName: value[0].__typename, + }; + } + + if (!value.__typename) { + // If the object does not have a __typename, we have an error. + this.throwError("Objects must include a __typename", { + extensions: { + value, + }, + }); + } + + // Create the named type (object reference) return type. + return { + type: { + kind: Kind.NAMED_TYPE, + name: { kind: Kind.NAME, value: value.__typename }, + }, + kind: FieldReturnType.OBJECT, + typeName: value.__typename, + }; + } + + if (name === "id") { + // If the name is "id", we need to return the ID scalar type. + return { + type: { + kind: Kind.NON_NULL_TYPE, + type: { + kind: Kind.NAMED_TYPE, + name: { kind: Kind.NAME, value: BuiltInScalarType.ID }, + }, + }, + kind: FieldReturnType.SCALAR, + scalarType: BuiltInScalarType.ID, + }; + } + + // Determine the return type based on the value type. + switch (typeof value) { + case "number": + const scalarType = + isFloat(value) ? BuiltInScalarType.FLOAT : BuiltInScalarType.INT; + return { + type: { + kind: Kind.NAMED_TYPE, + name: { kind: Kind.NAME, value: scalarType }, + }, + kind: FieldReturnType.SCALAR, + scalarType, + }; + case "boolean": + return { + type: { + kind: Kind.NAMED_TYPE, + name: { kind: Kind.NAME, value: BuiltInScalarType.BOOLEAN }, + }, + kind: FieldReturnType.SCALAR, + scalarType: BuiltInScalarType.BOOLEAN, + }; + case "string": + return { + type: { + kind: Kind.NAMED_TYPE, + name: { kind: Kind.NAME, value: BuiltInScalarType.STRING }, + }, + kind: FieldReturnType.SCALAR, + scalarType: BuiltInScalarType.STRING, + }; + default: + this.throwError( + `Custom scalar responses are not supported for field ${name}`, + { + extensions: value, + } + ); + } + } + + /** + * Get the return type name for the field. + * @param name — The name of the field. + * @param value — The value of the field. + * @returns The return type name. + */ + private getFieldReturnTypeName(name: string, value: any): string { + if (!value) { + this.throwError("No value provided"); + } + if (typeof value === "object") { + // If the value is an object, we need to determine the return type name. + if (Array.isArray(value)) { + // If the value is an array, we have an error. + this.throwError("Array of objects is not supported", { + extensions: { + name, + value, + }, + }); + } + if (!value.__typename) { + // If the object does not have a __typename, we have an error. + this.throwError("Objects must include a __typename", { + extensions: { + name, + value, + }, + }); + } + // Return the __typename of the object. + return value.__typename; + } + if (name === "id") { + // If the name is "id", we need to return the ID scalar type. + return BuiltInScalarType.ID; + } + + // Determine the return type name based on the value type. + switch (typeof value) { + case "number": + return isFloat(value) ? BuiltInScalarType.FLOAT : BuiltInScalarType.INT; + case "boolean": + return BuiltInScalarType.BOOLEAN; + case "string": + return BuiltInScalarType.STRING; + default: + this.throwError( + `Custom scalar responses are not supported for field ${name}`, + { + extensions: { + value, + }, + } + ); + } + } + + /** + * Get the argument type node for the field. + * @param name — The name of the field. + * @param valueNode — The value node. + * @returns The argument type node. + */ + private getArgumentTypeNode(name: string, valueNode: ValueNode): TypeNode { + if (!valueNode) { + this.throwError("No value provided"); + } + + // Determine the argument type based on the value node kind. + switch (valueNode.kind) { + case Kind.LIST: + // If the value node is a list, we need to create a list type node for + // the argument. + let typeNode; + + // It's possible for each value in the list to be an input object of the + // same type, but with different fields. To accommodate this, we need to + // generate the type node for each value in the list to ensure we add + // all the fields to the input object. We only use the last type node + // generated because they will all be the same (a named type node or a + // scalar type node). + valueNode.values.forEach((value) => { + typeNode = this.getArgumentTypeNode(name, value); + }); + + if (!typeNode) { + this.throwError(`No type node created for list argument ${name}`, { + nodes: valueNode, + extensions: { + name, + }, + }); + } + + // Create the list type node. + return { + kind: Kind.LIST_TYPE, + type: typeNode, + }; + case Kind.OBJECT: + // If the value node is an object, we need to create an input object + // type node for the argument. + const inputObjectName = OperationSchema.createInputObjectName(name); + this.createInputObject( + inputObjectName, + this.getInputValueDefinitionsFromObjectFieldNodes( + inputObjectName, + valueNode.fields + ) + ); + return { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: inputObjectName, + }, + }; + case Kind.ENUM: + // We can't tell the difference between an enum and a string, so we + // have to throw an error. + this.throwError("Enums are not supported for argument", { + extensions: { + name, + valueNode, + }, + }); + case Kind.VARIABLE: + // If the value node is a variable, we need to get the related variable + // definition so we can use it as the argument type. + const variableDefinition = this.variableDefinitions.get( + valueNode.name.value + ); + if (!variableDefinition) { + this.throwError( + `No definition found for variable "${valueNode.name.value}.` + ); + } + return variableDefinition.type; + // Handle all the scalar types + case Kind.BOOLEAN: + return { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: BuiltInScalarType.BOOLEAN, + }, + }; + case Kind.FLOAT: + return { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: BuiltInScalarType.FLOAT, + }, + }; + case Kind.INT: + return { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: BuiltInScalarType.INT, + }, + }; + case Kind.STRING: + case Kind.NULL: + return { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: + name === "id" ? BuiltInScalarType.ID : BuiltInScalarType.STRING, + }, + }; + default: + this.throwError( + `Custom scalar responses are not supported for field ${name}`, + { + nodes: valueNode, + } + ); + } + } + + /** + * Get the unique members of a value array. + * @param name — The name of the field. + * @param value — The value array. + * @returns The unique members of the value array. + */ + private getUniqueMembers(name: string, value: any[]): Map { + return new Map( + value.map((item) => [this.getFieldReturnTypeName(name, item), item]) + ); + } + + /** + * Create a union type definition. + * @param memberTypeNames — The names of the member types. + * @returns The name of the union type definition. + */ + private createUnionTypeDefinition(memberTypeNames: string[]): string { + const name = OperationSchema.createUnionTypeDefinitionName(memberTypeNames); + this.unionTypeDefinitions.set(name, { + kind: Kind.UNION_TYPE_DEFINITION, + name: { kind: Kind.NAME, value: name }, + types: sortASTNodes( + memberTypeNames.map((item) => ({ + kind: Kind.NAMED_TYPE, + name: { kind: Kind.NAME, value: item }, + })) + ), + }); + return name; + } + + /** + * Create a union return type. + * @param memberTypeNames — The names of the member types. + * @param unionName — The name of the union type definition. If not provided, + * a union name will be generated from the member types. + * @returns The union return type. + */ + private createUnionReturnType( + memberTypeNames: string[], + unionName?: string + ): UnionReturnType { + return { + type: { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: unionName || this.createUnionTypeDefinition(memberTypeNames), + }, + }, + kind: FieldReturnType.UNION, + typeNames: memberTypeNames, + }; + } + + /** + * Create a name for the union type definition. + * @param memberTypes — The names of the member types. + * @returns The name of the union type definition. + */ + private static createUnionTypeDefinitionName(memberTypes: string[]) { + return `${sortUnionMembers([...new Set(memberTypes)]).join("")}Union`; + } + + /** + * Create a name for the input object based on the singular form of the + * argument name + "Input". + * @param argName — The argument name + * @returns The input object name + */ + private static createInputObjectName(argName: string) { + return `${ucFirst(singularize(argName))}Input`; + } + + /** + * Get the input objects for a variable value. + * @param name — The name of the input object. + * @param variableValue — The variable value. + * @returns The input objects for the variable value. + */ + private getInputObjectsForVariableValue( + name: string, + variableValue: any + ): void { + this.createInputObject( + name, + this.getInputValueDefinitionsFromVariables(name, variableValue) + ); + } + + /** + * Create an input object type definition. + * @param name — The name of the input object. + * @param fields — The fields of the input object. + * @returns The input object type definition. + */ + private createInputObject( + name: string, + fields: InputValueDefinitionNode[] + ): void { + this.inputObjectTypeDefinitions.set(name, { + kind: Kind.INPUT_OBJECT_TYPE_DEFINITION, + name: { kind: Kind.NAME, value: name }, + fields: sortASTNodes(fields), + }); + } + + /** + * Get the input value definitions from variables. + * @param inputObjectName — The name of the input object. + * @param valuesInScope — The values in scope. + * @returns The input value definitions. + */ + getInputValueDefinitionsFromVariables( + inputObjectName: string, + valuesInScope: any + ): InputValueDefinitionNode[] { + // Get the existing input object type definition. + const existingInputObject = + this.inputObjectTypeDefinitions.get(inputObjectName); + + // Get the fields from the existing input object type definition. + const fields = new Map( + existingInputObject?.fields?.map((field) => [field.name.value, field]) + ); + + // Initialize the values to handle. + let valuesToHandle = valuesInScope; + if (Array.isArray(valuesInScope)) { + // If the values in scope is an array, then we need to merge the values + // into a single object. + valuesToHandle = valuesInScope.reduce((acc, item) => { + return deepMerge(acc, item); + }, {}); + } + + // Iterate over the values to handle and create the input value definitions. + Object.entries(valuesToHandle).forEach( + ([fieldName, fieldVariableValue]) => { + let valueType: TypeNode; + + // Determine the value type based on the field variable value type. + switch (typeof fieldVariableValue) { + case "object": + // If the field variable value is an object, then we need to create + // a named type node for the input object. + if (fieldVariableValue === null) { + valueType = { + kind: Kind.NAMED_TYPE, + name: { kind: Kind.NAME, value: "String" }, + }; + } else { + // Create the input object name. + const inputObjectName = + OperationSchema.createInputObjectName(fieldName); + + // Create a type node for the input object. + valueType = { + kind: Kind.NAMED_TYPE, + name: { kind: Kind.NAME, value: inputObjectName }, + }; + + // If the field value is an array, then we need to create a list + // type node for the input object and merge the array items + // into a single object for creating the input object. + let variableValueToHandle = fieldVariableValue; + if (Array.isArray(fieldVariableValue)) { + valueType = { + kind: Kind.LIST_TYPE, + type: valueType, + }; + variableValueToHandle = fieldVariableValue.reduce( + (acc, item) => { + return deepMerge(acc, item); + }, + {} + ); + } + + // Create the input object and any other input objects from its + // fields. + this.getInputObjectsForVariableValue( + inputObjectName, + variableValueToHandle + ); + } + break; + case "string": + valueType = { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: + fieldName === "id" ? + BuiltInScalarType.ID + : BuiltInScalarType.STRING, + }, + }; + break; + case "number": + valueType = { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: + isFloat(fieldVariableValue) ? + BuiltInScalarType.FLOAT + : BuiltInScalarType.INT, + }, + }; + break; + case "boolean": + valueType = { + kind: Kind.NAMED_TYPE, + name: { kind: Kind.NAME, value: BuiltInScalarType.BOOLEAN }, + }; + break; + default: + this.throwError( + `Scalar responses are not supported for field ${fieldName}`, + { + extensions: { + fieldVariableValue, + }, + } + ); + } + fields.set(fieldName, { + kind: Kind.INPUT_VALUE_DEFINITION, + name: { kind: Kind.NAME, value: fieldName }, + type: valueType, + }); + } + ); + return [...fields.values()]; + } + + /** + * Get the input value definitions from object field nodes. + * @param inputObjectName — The name of the input object. + * @param objectFieldNodes — The object field nodes. + * @returns The input value definitions. + */ + getInputValueDefinitionsFromObjectFieldNodes( + inputObjectName: string, + objectFieldNodes: readonly ObjectFieldNode[] + ): InputValueDefinitionNode[] { + // Get the existing input object type definition. + const existingInputObject = + this.inputObjectTypeDefinitions.get(inputObjectName); + + // Get the fields from the existing input object type definition. + const fields = new Map( + existingInputObject?.fields?.map((field) => [field.name.value, field]) + ); + + // Iterate over the object field nodes and create the input value + // definitions. + objectFieldNodes.forEach((node) => { + const name = node.name.value; + fields.set(name, { + kind: Kind.INPUT_VALUE_DEFINITION, + name: { kind: Kind.NAME, value: name }, + type: this.getArgumentTypeNode(node.name.value, node.value), + }); + }); + return [...fields.values()]; + } + + /** + * Get the leaf type of a type node. + * + * @param typeNode - The type node to get the leaf type of. + * @returns The leaf type of the type node. + */ + private static getLeafType(typeNode: TypeNode): NamedTypeNode { + return typeNode.kind === Kind.NAMED_TYPE ? + typeNode + : OperationSchema.getLeafType(typeNode.type); + } + + /** + * Throw a GraphQL error. + * @param message — The error message. + * @param options — The error options. + */ + private throwError( + message: string, + options: GraphQLErrorOptions = {} + ): never { + const nodes = options.nodes || this.operationNode; + throw new GraphQLError(message, { + ...options, + nodes, + extensions: { + ...(options.extensions || {}), + variables: this.variables, + response: this.response, + }, + }); + } + + /** + * Get the GraphQL schema. + * @returns The GraphQL schema. + */ + public get schema(): GraphQLSchema { + if (this._schema) { + return this._schema; + } + this._schema = buildASTSchema(this.ast); + return this._schema; + } + + /** + * Get the GraphQL AST. + * @returns The GraphQL AST. + */ + public get ast(): DocumentNode { + if (this._ast) { + return this._ast; + } + this._ast = { + kind: Kind.DOCUMENT, + definitions: sortObjectASTNodes([ + ...this.objectTypeDefinitions.values(), + ...this.unionTypeDefinitions.values(), + ...this.inputObjectTypeDefinitions.values(), + ...this.scalarTypeDefinitions.values(), + ...this.directiveDefinitions.values(), + ]), + }; + return this._ast; + } + + /** + * Get the GraphQL schema string. + * @returns The GraphQL schema string. + */ + public get schemaString(): string { + if (this._schemaString) { + return this._schemaString; + } + this._schemaString = printSchema(this.schema); + return this._schemaString; + } +} diff --git a/packages/ai/src/mocking/GrowingSchema/__tests__/GrowingSchema.test.ts b/packages/ai/src/mocking/GrowingSchema/__tests__/GrowingSchema.test.ts new file mode 100644 index 00000000000..afd5a77184d --- /dev/null +++ b/packages/ai/src/mocking/GrowingSchema/__tests__/GrowingSchema.test.ts @@ -0,0 +1,1277 @@ +import { gql } from "@apollo/client"; +import { GrowingSchema } from "../GrowingSchema.js"; + +describe("GrowingSchema", () => { + it("creates an empty base schema when instantiated", () => { + const expectedSchema = /* GraphQL */ ``; + const schema = new GrowingSchema(); + expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); + }); + + describe(".add()", () => { + it("creates a query schema with the correct fields", async () => { + const query = gql` + query GetUser { + user { + __typename + id + name + emails { + __typename + id + kind + value + } + aliases + } + } + `; + const response = { + data: { + user: { + __typename: "User", + id: "1", + name: "John Doe", + emails: [ + { __typename: "Email", id: "1", kind: "work", value: "qd" }, + { __typename: "Email", id: "2", kind: "personal", value: "qwe" }, + ], + aliases: ["John Smith", "Who Knows"], + }, + }, + }; + const expectedSchema = /* GraphQL */ ` + type Query { + user: User + } + + type Email { + id: ID! + kind: String + value: String + } + + type User { + aliases: [String] + emails: [Email] + id: ID! + name: String + } + `; + const schema = new GrowingSchema(); + await schema.add({ query }, response); + expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); + }); + + it("creates a query schema with the correct fields from a base schema", async () => { + const baseSchema = /* GraphQL */ ` + scalar DateTime + + type Query { + now: DateTime + } + `; + const query = gql` + query GetUser { + user { + __typename + id + name + emails { + __typename + id + kind + value + } + aliases + } + } + `; + const response = { + data: { + user: { + __typename: "User", + id: "1", + name: "John Doe", + emails: [ + { __typename: "Email", id: "1", kind: "work", value: "qd" }, + { __typename: "Email", id: "2", kind: "personal", value: "qwe" }, + ], + aliases: ["John Smith", "Who Knows"], + }, + }, + }; + const expectedSchema = /* GraphQL */ ` + type Query { + now: DateTime + user: User + } + + scalar DateTime + + type Email { + id: ID! + kind: String + value: String + } + + type User { + aliases: [String] + emails: [Email] + id: ID! + name: String + } + `; + const schema = new GrowingSchema({ schema: baseSchema }); + await schema.add({ query }, response); + expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); + }); + + it("creates a mutation schema with the correct fields", async () => { + const query = gql` + mutation CreateUser { + createUser { + __typename + id + name + emails { + __typename + id + kind + value + } + } + } + `; + const response = { + data: { + createUser: { + __typename: "User", + id: "1", + name: "John Doe", + emails: [ + { __typename: "Email", id: "1", kind: "work", value: "qd" }, + { __typename: "Email", id: "2", kind: "personal", value: "qwe" }, + ], + }, + }, + }; + const expectedSchema = /* GraphQL */ ` + type Query { + _placeholder_query_: Boolean + } + + type Mutation { + createUser: User + } + + type Email { + id: ID! + kind: String + value: String + } + + type User { + emails: [Email] + id: ID! + name: String + } + `; + const schema = new GrowingSchema(); + await schema.add({ query }, response); + expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); + }); + + it("extends an existing schema based on a new query", async () => { + const query = gql` + query GetUser { + user { + __typename + id + name + emails { + __typename + id + kind + value + } + } + } + `; + const response = { + data: { + user: { + __typename: "User", + id: "1", + name: "John Doe", + emails: [ + { __typename: "Email", id: "1", kind: "work", value: "qd" }, + { __typename: "Email", id: "2", kind: "personal", value: "qwe" }, + ], + }, + }, + }; + const query2 = gql` + query GetUser2 { + user { + __typename + lastName + emails { + __typename + foo + } + } + } + `; + const response2 = { + data: { + user: { + __typename: "User", + lastName: "John Doe", + emails: [{ __typename: "Email", foo: 1 }], + }, + }, + }; + const expectedSchema = /* GraphQL */ ` + type Query { + user: User + } + + type Email { + foo: Int + id: ID! + kind: String + value: String + } + + type User { + emails: [Email] + id: ID! + lastName: String + name: String + } + `; + const schema = new GrowingSchema(); + await schema.add({ query }, response); + schema.add({ query: query2 }, response2); + expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); + }); + + it("avoids errors due to race conditions when adding multiple queries simultaneously", async () => { + const query = gql` + query GetUser { + user { + __typename + id + name + emails { + __typename + id + kind + value + } + } + } + `; + const response = { + data: { + user: { + __typename: "User", + id: "1", + name: "John Doe", + emails: [ + { __typename: "Email", id: "1", kind: "work", value: "qd" }, + { __typename: "Email", id: "2", kind: "personal", value: "qwe" }, + ], + }, + }, + }; + const query2 = gql` + query GetUser2 { + user { + __typename + lastName + emails { + __typename + foo + } + } + } + `; + const response2 = { + data: { + user: { + __typename: "User", + lastName: "John Doe", + emails: [{ __typename: "Email", foo: 1 }], + }, + }, + }; + const expectedSchema = /* GraphQL */ ` + type Query { + user: User + } + + type Email { + foo: Int + id: ID! + kind: String + value: String + } + + type User { + emails: [Email] + id: ID! + lastName: String + name: String + } + `; + const schema = new GrowingSchema(); + const promises = [ + schema.add({ query: query2 }, response2), + schema.add({ query }, response), + ]; + await Promise.all(promises); + expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); + }); + + it("throws an error when a query that is incompatible with previous queries is added", async () => { + const query = gql` + query GetUsers { + users(limit: 2) { + __typename + id + name + } + } + `; + const response = { + data: { + users: [ + { __typename: "User", id: "1", name: "John Smith" }, + { __typename: "User", id: "2", name: "Sarah Jane Smith" }, + ], + }, + }; + const expectedSchema = /* GraphQL */ ` + type Query { + users(limit: Int): [User] + } + + type User { + id: ID! + name: String + } + `; + const query2 = gql` + query GetUser2 { + users(first: 2, after: "ASDF") { + __typename + edges { + __typename + node { + __typename + lastName + } + } + pageInfo { + __typename + hasNextPage + nextCursor + } + } + } + `; + const response2 = { + data: { + users: { + __typename: "UserConnection", + edges: [ + { + __typename: "UserEdge", + node: { __typename: "User", lastName: "Smith" }, + }, + { + __typename: "UserEdge", + node: { __typename: "User", lastName: "Smith" }, + }, + ], + pageInfo: { + __typename: "PageInfo", + hasNextPage: true, + nextCursor: "QWERTY", + }, + }, + }, + }; + + const schema = new GrowingSchema(); + + // Add the initial schema + await schema.add({ query }, response); + expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); + + // Attempt to add the incompatible schema + let error: Error | undefined; + try { + await schema.add({ query: query2 }, response2); + } catch (err) { + error = err as Error; + } + + expect(error).toBeInstanceOf(Error); + expect(error?.message).toEqual( + 'Error executing query `GetUser2` against grown schema: Expected Iterable, but did not find one for field "Query.users".' + ); + }); + + it("handles inline arguments", async () => { + const query = gql` + query Search { + book( + id: "asdf" + nullArg: null + stringArg: "Hi" + boolArg: false + intArg: 2 + floatArg: 3.6 + listArg: ["string1", "string2"] + nestedListArg: [["nested1"], ["nested2, nested3"]] + objectArg: { + prop1: true, + prop2: 5, + prop3: 9.7, + prop4: "Hello", + prop5: null, + prop6: {value: "Yep"} + prop7: ["Yep"], + prop8: [[7]], + prop9: [{value1: "Nope"}, {value2: true}, {value2: false}] + } + objectArgs: [{prop1: true}, {prop1: false}, {prop2: 5}] + nestedObjectArgs: [[{prop1: true}], [{prop1: false}], [{prop2: 5}]] + ) { + __typename + title + anotherField(number: 1, bool: true) + } + } + `; + const response = { + data: { + book: { + __typename: "Book", + title: "Moby Dick", + anotherField: true, + }, + }, + }; + const expectedSchema = /* GraphQL */ ` + type Query { + book( + boolArg: Boolean, + floatArg: Float, + id: ID, + intArg: Int, + listArg: [String], + nestedListArg: [[String]], + nestedObjectArgs: [[NestedObjectArgInput]], + nullArg: String, + objectArg: ObjectArgInput, + objectArgs: [ObjectArgInput], + stringArg: String + ): Book + } + + type Book { + anotherField(bool: Boolean, number: Int): Boolean + title: String + } + + input NestedObjectArgInput { + prop1: Boolean + prop2: Int + } + + input ObjectArgInput { + prop1: Boolean + prop2: Int + prop3: Float + prop4: String + prop5: String + prop6: Prop6Input + prop7: [String] + prop8: [[Int]] + prop9: [Prop9Input] + } + + input Prop6Input { + value: String + } + + input Prop9Input { + value1: String + value2: Boolean + } + `; + + const schema = new GrowingSchema(); + await schema.add({ query }, response); + expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); + }); + + it("handles scalar variables", async () => { + const query = gql` + query Search($bookId: ID!, $arg: String!, $nullable: String) { + book(id: $bookId) { + __typename + title + anotherField(arg: $arg, nullable: $nullable) + } + } + `; + const variables = { + bookId: "ASDF", + arg: "QWERTY", + nullable: null, + }; + const response = { + data: { + book: { + __typename: "Book", + title: "Moby Dick", + anotherField: true, + }, + }, + }; + const expectedSchema = /* GraphQL */ ` + type Query { + book(id: ID!): Book + } + + type Book { + anotherField(arg: String!, nullable: String): Boolean + title: String + } + `; + + const schema = new GrowingSchema(); + await schema.add({ query, variables }, response); + expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); + }); + + it("handles input object variables", async () => { + const query = gql` + query SearchByAuthor($author: AuthorInput!, $arg: SomeArgInput!) { + bookByAuthor(author: $author) { + __typename + title + anotherField(arg: $arg) + } + } + `; + const variables = { + author: { + name: "John Smith", + }, + arg: { + foo: null, + }, + }; + const response = { + data: { + bookByAuthor: { + __typename: "Book", + title: "The Tardis", + anotherField: true, + }, + }, + }; + const expectedSchema = /* GraphQL */ ` + type Query { + bookByAuthor(author: AuthorInput!): Book + } + + input AuthorInput { + name: String + } + + type Book { + anotherField(arg: SomeArgInput!): Boolean + title: String + } + + input SomeArgInput { + foo: String + } + `; + + const schema = new GrowingSchema(); + await schema.add({ query, variables }, response); + expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); + }); + + it("handles nested input object variables", async () => { + const query = gql` + query SearchByAuthor($author: AuthorInput!) { + bookByAuthor(author: $author) { + __typename + title + } + } + `; + const variables = { + author: { + name: { + firstName: "John", + lastName: "Smith", + nickName: { + full: "The Doctor", + short: "Dr.", + }, + age: 2000, + }, + }, + }; + const response = { + data: { + bookByAuthor: { + __typename: "Book", + title: "The Tardis", + }, + }, + }; + const expectedSchema = /* GraphQL */ ` + type Query { + bookByAuthor(author: AuthorInput!): Book + } + + input AuthorInput { + name: NameInput + } + + type Book { + title: String + } + + input NameInput { + age: Int + firstName: String + lastName: String + nickName: NickNameInput + } + + input NickNameInput { + full: String + short: String + } + `; + + const schema = new GrowingSchema(); + schema.add({ query, variables }, response); + expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); + }); + + it("handles repeated input object variables for a single query", async () => { + const query = gql` + query SearchByAuthor($author: AuthorInput!) { + bookByAuthor(author: $author) { + __typename + title + anotherField(author: $author) + } + } + `; + const variables = { + author: { + name: { + firstName: "John", + lastName: "Smith", + nickName: { + full: "The Doctor", + short: "Dr.", + }, + age: 2000, + }, + }, + }; + const response = { + data: { + bookByAuthor: { + __typename: "Book", + title: "The Tardis", + anotherField: true, + }, + }, + }; + const expectedSchema = /* GraphQL */ ` + type Query { + bookByAuthor(author: AuthorInput!): Book + } + + input AuthorInput { + name: NameInput + } + + type Book { + anotherField(author: AuthorInput!): Boolean + title: String + } + + input NameInput { + age: Int + firstName: String + lastName: String + nickName: NickNameInput + } + + input NickNameInput { + full: String + short: String + } + `; + + const schema = new GrowingSchema(); + await schema.add({ query, variables }, response); + expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); + }); + + it("handles repeated input object variables across multiple queries", async () => { + const firstQuery = gql` + query SearchByAuthor($author: AuthorInput!) { + bookByAuthor(author: $author) { + __typename + title + } + } + `; + const firstVariables = { + author: { + name: { + nickName: { + full: "The Doctor", + }, + }, + }, + }; + const firstResponse = { + data: { + bookByAuthor: { + __typename: "Book", + title: "The Tardis", + }, + }, + }; + const firstExpectedSchema = /* GraphQL */ ` + type Query { + bookByAuthor(author: AuthorInput!): Book + } + + input AuthorInput { + name: NameInput + } + + type Book { + title: String + } + + input NameInput { + nickName: NickNameInput + } + + input NickNameInput { + full: String + } + `; + const secondQuery = gql` + query SearchByAuthor($author: AuthorInput!) { + bookByAuthor(author: $author) { + __typename + title + } + } + `; + const secondVariables = { + author: { + name: { + firstName: "John", + lastName: "Smith", + nickName: { + short: "Dr.", + }, + }, + }, + }; + const secondResponse = { + data: { + bookByAuthor: { + __typename: "Book", + title: "The Tardis", + }, + }, + }; + const secondExpectedSchema = /* GraphQL */ ` + type Query { + bookByAuthor(author: AuthorInput!): Book + } + + input AuthorInput { + name: NameInput + } + + type Book { + title: String + } + + input NameInput { + firstName: String + lastName: String + nickName: NickNameInput + } + + input NickNameInput { + full: String + short: String + } + `; + + const schema = new GrowingSchema(); + await schema.add( + { query: firstQuery, variables: firstVariables }, + firstResponse + ); + expect(schema.toString()).toEqualIgnoringWhitespace(firstExpectedSchema); + + await schema.add( + { query: secondQuery, variables: secondVariables }, + secondResponse + ); + expect(schema.toString()).toEqualIgnoringWhitespace(secondExpectedSchema); + }); + + it("handles list variables", async () => { + const query = gql` + query SearchByAuthor($authors: [AuthorInput!]!) { + bookByAuthor(authors: $authors) { + __typename + title + } + } + `; + const variables = { + authors: [ + { + name: { + nickNames: [ + { + full: "The Doctor", + }, + ], + }, + }, + { + name: { + firstName: "Sarah", + middleName: "Jane", + lastName: "Smith", + }, + }, + ], + }; + const response = { + data: { + bookByAuthor: { + __typename: "Book", + title: "The Tardis", + }, + }, + }; + const expectedSchema = /* GraphQL */ ` + type Query { + bookByAuthor(authors: [AuthorInput!]!): Book + } + + input AuthorInput { + name: NameInput + } + + type Book { + title: String + } + + input NameInput { + firstName: String + lastName: String + middleName: String + nickNames: [NickNameInput] + } + + input NickNameInput { + full: String + } + `; + + const schema = new GrowingSchema(); + await schema.add({ query, variables }, response); + expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); + }); + + it("handles a single inline fragment as a type, not a union", async () => { + const query = gql` + query Search { + book { + ... on Book { + __typename + title + } + } + } + `; + const response = { + data: { + book: { + __typename: "Book", + title: "Moby Dick", + }, + }, + }; + const expectedSchema = /* GraphQL */ ` + type Query { + book: Book + } + + type Book { + title: String + } + `; + + const schema = new GrowingSchema(); + await schema.add({ query }, response); + expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); + }); + + it("handles a single inline fragment as a type, not a union, when the return data is a list of matching types", async () => { + const query = gql` + query Search { + books { + ... on Book { + __typename + title + } + } + } + `; + const response = { + data: { + books: [ + { + __typename: "Book", + title: "Moby Dick", + }, + { + __typename: "Book", + title: "The Martian", + }, + ], + }, + }; + const expectedSchema = /* GraphQL */ ` + type Query { + books: [Book] + } + + type Book { + title: String + } + `; + + const schema = new GrowingSchema(); + await schema.add({ query }, response); + expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); + }); + + it("handles union types with inline fragments", async () => { + const query = gql` + query Search { + search(term: "Smith", first: 2, after: "ASDF") { + __typename + # The inline fragments imply that this is a union + ... on Author { + __typename + name + } + ... on Book { + __typename + title + } + } + } + `; + const response = { + data: { + search: [ + // The inconsistent `__typename` values + // imply that this is a union. + { + __typename: "Book", + title: "The Art of Blacksmithing", + }, + { + __typename: "Author", + name: "John Smith", + }, + ], + }, + }; + const expectedSchema = /* GraphQL */ ` + type Query { + search(after: String, first: Int, term: String): [AuthorBookUnion] + } + + type Author { + name: String + } + + union AuthorBookUnion = Author | Book + + type Book { + title: String + } + `; + + const schema = new GrowingSchema(); + await schema.add({ query }, response); + expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); + }); + + it("handles a selection set with root fields and inline fragments as a union, contributing the root fields to all union members", async () => { + const query = gql` + query Search { + search { + __typename + pageInfo { + __typename + hasNextPage + nextCursor + } + edges { + __typename + node { + __typename + # The root field should imply that this + # is an interface, not a union. + title + ... on Movie { + __typename + someField + } + ... on Book { + __typename + someOtherField + } + } + } + } + } + `; + const response = { + data: { + search: { + __typename: "SearchConnection", + pageInfo: { + __typename: "PageInfo", + hasNextPage: true, + nextCursor: "eyJvZmZzZXQiOjJ9", + }, + edges: [ + // The inconsistent `__typename` values + // could imply that this is a union, but when + // paired with the root `id` field, it instead + // implies that this is an interface. + { + __typename: "SearchEdge", + node: { + __typename: "Movie", + title: "The Matrix", + someField: true, + }, + }, + { + __typename: "SearchEdge", + node: { + __typename: "Book", + title: "The Art of Blacksmithing", + someOtherField: false, + }, + }, + ], + }, + }, + }; + const expectedSchema = /* GraphQL */ ` + type Query { + search: SearchConnection + } + + type Book { + someOtherField: Boolean + title: String + } + + union BookMovieUnion = Book | Movie + + type Movie { + someField: Boolean + title: String + } + + type PageInfo { + hasNextPage: Boolean + nextCursor: String + } + + type SearchConnection { + edges: [SearchEdge] + pageInfo: PageInfo + } + + type SearchEdge { + node: BookMovieUnion + } + `; + + const schema = new GrowingSchema(); + await schema.add({ query }, response); + expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); + }); + + it("handles named fragments on a type", async () => { + const query = gql` + query Search { + book { + ...BookFragment + } + } + + fragment BookFragment on Book { + __typename + title + } + `; + const response = { + data: { + book: { + __typename: "Book", + title: "Moby Dick", + }, + }, + }; + const expectedSchema = /* GraphQL */ ` + type Query { + book: Book + } + + type Book { + title: String + } + `; + + const schema = new GrowingSchema(); + await schema.add({ query }, response); + expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); + }); + + it("handles union types with named fragments", async () => { + const query = gql` + query Search { + search(term: "Smith", first: 2, after: "ASDF") { + __typename + pageInfo { + __typename + hasNextPage + nextCursor + } + edges { + __typename + node { + ... AuthorFragment + ... BookFragment + } + } + } + } + + fragment AuthorFragment on Author { + __typename + name + } + + fragment BookFragment on Book { + __typename + title + } + `; + const response = { + data: { + search: { + __typename: "SearchConnection", + pageInfo: { + __typename: "PageInfo", + hasNextPage: true, + nextCursor: "eyJvZmZzZXQiOjJ9", + }, + edges: [ + { + __typename: "SearchEdge", + node: { + __typename: "Author", + name: "John Smith", + }, + }, + { + __typename: "SearchEdge", + node: { + __typename: "Book", + title: "The Art of Blacksmithing", + }, + }, + ], + }, + }, + }; + const expectedSchema = /* GraphQL */ ` + type Query { + search(after: String, first: Int, term: String): SearchConnection + } + + type Author { + name: String + } + + union AuthorBookUnion = Author | Book + + type Book { + title: String + } + + type PageInfo { + hasNextPage: Boolean + nextCursor: String + } + + type SearchConnection { + edges: [SearchEdge] + pageInfo: PageInfo + } + + type SearchEdge { + node: AuthorBookUnion + } + `; + + const schema = new GrowingSchema(); + await schema.add({ query }, response); + expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); + }); + }); +}); diff --git a/packages/ai/src/mocking/GrowingSchema/index.ts b/packages/ai/src/mocking/GrowingSchema/index.ts new file mode 100644 index 00000000000..250c1403e12 --- /dev/null +++ b/packages/ai/src/mocking/GrowingSchema/index.ts @@ -0,0 +1 @@ +export * from "./GrowingSchema.js"; diff --git a/packages/ai/src/mocking/__tests__/AIAdapter.test.ts b/packages/ai/src/mocking/__tests__/AIAdapter.test.ts new file mode 100644 index 00000000000..1c08de621ae --- /dev/null +++ b/packages/ai/src/mocking/__tests__/AIAdapter.test.ts @@ -0,0 +1,23 @@ +import { AIAdapter } from "../AIAdapter.js"; + +class DerivedAdapter extends AIAdapter { + constructor() { + super({}); + } + + public generateObject(prompt: string): Promise { + return Promise.resolve({ + data: null, + }); + } +} + +describe("AIAdapter derived class", () => { + it("should be able to generate an object", async () => { + const adapter = new DerivedAdapter(); + const result = await adapter.generateObject("Hello, world!"); + expect(result).toEqual({ + data: null, + }); + }); +}); diff --git a/packages/ai/src/mocking/consts.ts b/packages/ai/src/mocking/consts.ts new file mode 100644 index 00000000000..baf4ae3e9c0 --- /dev/null +++ b/packages/ai/src/mocking/consts.ts @@ -0,0 +1,22 @@ +export const BASE_SYSTEM_PROMPT = ` +You are returning mock data for a GraphQL API. + +When generating image URLs, use these reliable placeholder services with unique identifiers: +- https://picsum.photos/[width]/[height]?random=[unique_identifier] (e.g., https://picsum.photos/400/300?random=asdf, ?random=ytal, etc.) +- https://via.placeholder.com/[width]x[height]/[color]/[text_color]?text=[context] (e.g., ?text=Product+asdf) +- https://placehold.co/[width]x[height]/[color]/[text_color]?text=[context] (e.g, ?text=User+Avatar) + +For list items, increment the random number or use contextual text to ensure unique images. + +Avoid using numbers for unique identifiers. Unique identifier and typename combinations should result in consistent data. + +For example, say something is named "Foobar", you should use a unique identifier like "foobar" and not a number. + +Remember context and data based on the unique identifier and typename so that data is consistent. +`; + +/** + * This is a special field name that is used to provide a placeholder query + * field when the root query type has no fields. + */ +export const PLACEHOLDER_QUERY_NAME = "_placeholder_query_"; diff --git a/packages/ai/src/utils.ts b/packages/ai/src/utils.ts new file mode 100644 index 00000000000..f357da84a1f --- /dev/null +++ b/packages/ai/src/utils.ts @@ -0,0 +1,311 @@ +import { + GraphQLInputObjectType, + GraphQLInputType, + GraphQLObjectType, + GraphQLOutputType, + GraphQLUnionType, + InputObjectTypeDefinitionNode, + isListType, + isWrappingType, + Kind, + ObjectTypeDefinitionNode, + TypeNode, + UnionTypeDefinitionNode, +} from "graphql"; + +export type NamedNode = { name: { value: string } }; + +/** + * The names of the root types in the GraphQLSchema. + */ +export enum RootTypeName { + MUTATION = "Mutation", + QUERY = "Query", + SUBSCRIPTION = "Subscription", +} + +/** + * The sort order of the root types in the schema. + */ +const ROOT_TYPE_ORDER: { [key: string]: number } = { + [RootTypeName.QUERY]: 0, + [RootTypeName.MUTATION]: 1, + [RootTypeName.SUBSCRIPTION]: 2, +}; + +/** + * Check if a number is a float (i.e. 9.5). + * + * @param num - The number to check. + * @returns True if the number is a float, false otherwise. + */ +export function isFloat(num: number) { + return typeof num === "number" && !Number.isInteger(num); +} + +/** + * Convert a plural word to its singular form. + * + * @param str - The plural word to convert. + * @returns The singular form of the word. + */ +export function singularize(str: string) { + if (!str) { + return ""; + } + + // Handle common pluralization patterns + if (str.endsWith("ies")) { + return str.slice(0, -3) + "y"; + } else if (str.endsWith("ves")) { + return str.slice(0, -3) + "f"; + } else if (str.endsWith("es")) { + // Special cases for -es endings + if (str.endsWith("ches") || str.endsWith("shes") || str.endsWith("xes")) { + return str.slice(0, -2); + } else if (str.endsWith("ses")) { + return str.slice(0, -2); + } else { + return str.slice(0, -1); + } + } else if (str.endsWith("s") && str.length > 1) { + return str.slice(0, -1); + } + + return str; +} + +/** + * Convert the first letter of a string to uppercase. + * + * @param str - The string to convert. + * @returns The string with the first letter capitalized. + */ +export function ucFirst(str: string) { + if (!str) { + return ""; + } + return str.charAt(0).toUpperCase() + str.slice(1); +} + +/** + * Sorts object-level AST nodes by their name. + * + * AST Nodes named after a root type (like Query, Mutation, Subscription) are + * sorted to the beginning of the list based on their defined order. + * + * All other AST Nodes are sorted alphabetically regardless of kind. + * @param list — The list of AST Nodes to sort. + * @returns The sorted list of AST Nodes. + */ +export function sortObjectASTNodes(list: T[]): T[] { + return list.sort((a, b) => { + const aName = a.name.value; + const bName = b.name.value; + + const aOrder = ROOT_TYPE_ORDER[aName]; + const bOrder = ROOT_TYPE_ORDER[bName]; + + // If both are root types, sort by their defined order + if (aOrder !== undefined && bOrder !== undefined) { + return aOrder - bOrder; + } + + // If only a is a root type, it goes first + if (aOrder !== undefined) { + return -1; + } + + // If only b is a root type, it goes first + if (bOrder !== undefined) { + return 1; + } + + // Neither is a root type, sort alphabetically + return aName.localeCompare(bName); + }); +} + +/** + * Sorts union member names alphabetically. + * @param members — The list of union member names to sort. + * @returns The sorted list of union member names. + */ +export function sortUnionMembers(members: string[]): string[] { + return members.sort((a, b) => { + return a.localeCompare(b); + }); +} + +/** + * Sorts AST nodes by their name alphabetically. + * + * Unlike `sortObjectASTNodes`, this function sorts alphabetically regardless + * of kind or name. + * @param nodes — The list of AST nodes to sort. + * @returns The sorted list of AST nodes. + */ +export function sortASTNodes(nodes: T[]): T[] { + return nodes.sort((a, b) => { + return a.name.value.localeCompare(b.name.value); + }); +} + +/** + * Transforms a GraphQL Input/Output type to an AST TypeNode. + * @param type — The GraphQL Input/Output type to transform + * @returns The AST TypeNode + */ +export function graphQLTypeToTypeNode( + type: GraphQLOutputType | GraphQLInputType +): TypeNode { + if (isWrappingType(type)) { + // Recursively transform the wrapped type + const returnType = graphQLTypeToTypeNode(type.ofType); + + // If the type is a non-null type, return the non-null type to avoid + // unnecessary wrapping (but this also shouldn't actually happen) + if (returnType.kind === Kind.NON_NULL_TYPE) { + return returnType; + } + + // Return the wrapped type + return { + kind: isListType(type) ? Kind.LIST_TYPE : Kind.NON_NULL_TYPE, + type: returnType, + }; + } + return { + kind: Kind.NAMED_TYPE, + name: { kind: Kind.NAME, value: type.name }, + }; +} + +/** + * Transforms a GraphQL Object type to an AST ObjectTypeDefinitionNode. + * @param type — The GraphQL Object type to transform + * @returns The AST ObjectTypeDefinitionNode + */ +export function graphQLObjectTypeToObjectTypeDefinitionNode( + type: GraphQLObjectType +): ObjectTypeDefinitionNode { + return { + kind: Kind.OBJECT_TYPE_DEFINITION, + name: { kind: Kind.NAME, value: type.name }, + fields: sortASTNodes( + Object.values(type.getFields()).map((field) => ({ + kind: Kind.FIELD_DEFINITION, + name: { kind: Kind.NAME, value: field.name }, + type: graphQLTypeToTypeNode(field.type), + })) + ), + }; +} + +/** + * Transforms a GraphQL Input type to an AST InputObjectTypeDefinitionNode. + * @param type — The GraphQL Input type to transform + * @returns The AST InputObjectTypeDefinitionNode + */ +export function graphQLInputObjectTypeToInputObjectDefinitionNode( + type: GraphQLInputObjectType +): InputObjectTypeDefinitionNode { + return { + kind: Kind.INPUT_OBJECT_TYPE_DEFINITION, + name: { kind: Kind.NAME, value: type.name }, + fields: sortASTNodes( + Object.values(type.getFields()).map((field) => ({ + kind: Kind.INPUT_VALUE_DEFINITION, + name: { kind: Kind.NAME, value: field.name }, + type: graphQLTypeToTypeNode(field.type), + })) + ), + }; +} + +/** + * Transforms a GraphQL Union type to an AST UnionTypeDefinitionNode. + * @param type — The GraphQL Union type to transform + * @returns The AST UnionTypeDefinitionNode + */ +export function graphQLUnionTypeToUnionTypeDefinitionNode( + type: GraphQLUnionType +): UnionTypeDefinitionNode { + return { + kind: Kind.UNION_TYPE_DEFINITION, + name: { kind: Kind.NAME, value: type.name }, + types: sortASTNodes( + Object.values(type.getTypes()).map((memberType) => ({ + kind: Kind.NAMED_TYPE, + name: { kind: Kind.NAME, value: memberType.name }, + })) + ), + }; +} + +/** + * Deep merge utility function to preserve nested properties. + * + * @param target - The target object to merge into. + * @param source - The source object to merge from. + * @returns The merged object. + */ +export function deepMerge(target: any, source: any): any { + if (source === null || typeof source !== "object") { + return source; + } + + if (Array.isArray(source)) { + return source; + } + + if (target === null || typeof target !== "object" || Array.isArray(target)) { + target = {}; + } + + const result = { ...target }; + + for (const key in source) { + if (source.hasOwnProperty(key)) { + if ( + typeof source[key] === "object" && + source[key] !== null && + !Array.isArray(source[key]) + ) { + result[key] = deepMerge(result[key], source[key]); + } else { + result[key] = source[key]; + } + } + } + + return result; +} + +/** + * Converts a value to a formattedJSON string. + * This handles Map and Set instances by converting them to objects/arrays. + * @param value — The value to convert to a JSON string + * @returns The JSON string + */ +export function toJSON(value: any) { + return JSON.stringify( + value, + (key: string, value: any) => { + if (value instanceof Map) { + return { + dataType: "Map", + value: Object.fromEntries(value.entries()), + }; + } else if (value instanceof Set) { + return { + dataType: "Set", + value: [...value], + }; + } else { + return value; + } + }, + 2 + ); +} diff --git a/packages/ai/tsconfig.json b/packages/ai/tsconfig.json new file mode 100644 index 00000000000..3e8a9432bc8 --- /dev/null +++ b/packages/ai/tsconfig.json @@ -0,0 +1,35 @@ +{ + "compilerOptions": { + "noImplicitAny": true, + "strictNullChecks": true, + "noUnusedParameters": false, + "noUnusedLocals": true, + "skipLibCheck": true, + "moduleResolution": "NodeNext", + "importHelpers": true, + "sourceMap": true, + "inlineSources": true, + "declaration": true, + "declarationMap": true, + "target": "ESNext", + "module": "NodeNext", + "esModuleInterop": true, + "experimentalDecorators": true, + "outDir": "./dist", + "rootDir": "./src", + "lib": ["DOM", "ES2023"], + "types": ["jest", "node"], + "jsx": "react", + "strict": true, + "paths": { + // This entry point is not part of our public API, so we point it directly to the source. + "@apollo/client/testing/internal": ["./src/testing/internal/index.ts"] + } + }, + "files": ["src/global.d.ts"], + "include": ["src/**/*.ts", "src/**/*.tsx"], + "mdx": { + // Enable strict type checking in MDX files. + "checkMdx": true + } +}