1671 lines · 58.0 KB · TypeScript
RawDownload
import { expect, it } from "@effect/vitest";import { Effect, Option, Schema } from "effect";import { buildToolTypeScriptPreview } from "@executor-js/sdk/core"; import {  convertGoogleDiscoveryBundleToOpenApi,  convertGoogleDiscoveryToOpenApi,  isGoogleDiscoveryUrl,  normalizeGoogleDiscoveryUrl,} from "./discovery";import { googleOAuthConsentScopesForPreset } from "./service-policy";import { extract, parse } from "@executor-js/plugin-openapi"; const ConvertedOperation = Schema.Struct({  operationId: Schema.String,  "x-executor-toolPath": Schema.String,  parameters: Schema.Array(    Schema.Struct({      name: Schema.String,      in: Schema.String,      required: Schema.Boolean,      description: Schema.optional(Schema.String),      schema: Schema.Unknown,      style: Schema.optional(Schema.String),      explode: Schema.optional(Schema.Boolean),      allowReserved: Schema.optional(Schema.Boolean),    }),  ),  servers: Schema.optional(Schema.Array(Schema.Struct({ url: Schema.String }))),  security: Schema.optional(    Schema.Array(Schema.Record(Schema.String, Schema.Array(Schema.String))),  ),  "x-executor-pathTemplate": Schema.optional(Schema.String),  requestBody: Schema.optional(Schema.Unknown),  responses: Schema.Unknown,  "x-google-scopes": Schema.Array(Schema.String),}); const ConvertedSpec = Schema.Struct({  openapi: Schema.String,  servers: Schema.Array(Schema.Struct({ url: Schema.String })),  paths: Schema.Record(Schema.String, Schema.Record(Schema.String, ConvertedOperation)),  components: Schema.Struct({    schemas: Schema.Record(Schema.String, Schema.Unknown),  }),}); const decodeConvertedSpec = Schema.decodeUnknownSync(Schema.fromJsonString(ConvertedSpec)); const OAUTH2_URL = "https://www.googleapis.com/discovery/v1/apis/oauth2/v2/rest";const OAUTH2_USERINFO_SCOPES = [  "openid",  "https://www.googleapis.com/auth/userinfo.email",  "https://www.googleapis.com/auth/userinfo.profile",] as const; const oauth2DiscoveryDoc = {  name: "oauth2",  version: "v2",  title: "Google OAuth2 API",  rootUrl: "https://www.googleapis.com/",  servicePath: "",  auth: {    oauth2: {      scopes: {        openid: {          description: "Associate you with your personal info on Google",        },        "https://www.googleapis.com/auth/userinfo.email": {          description: "See your primary Google Account email address",        },        "https://www.googleapis.com/auth/userinfo.profile": {          description: "See your personal info",        },      },    },  },  methods: {    tokeninfo: {      id: "oauth2.tokeninfo",      httpMethod: "POST",      path: "oauth2/v2/tokeninfo",      parameters: {        access_token: {          location: "query",          type: "string",        },      },      response: { $ref: "Tokeninfo" },    },  },  resources: {    userinfo: {      methods: {        get: {          id: "oauth2.userinfo.get",          httpMethod: "GET",          path: "oauth2/v2/userinfo",          scopes: OAUTH2_USERINFO_SCOPES,          response: { $ref: "Userinfo" },        },      },      resources: {        v2: {          resources: {            me: {              methods: {                get: {                  id: "oauth2.userinfo.v2.me.get",                  httpMethod: "GET",                  path: "userinfo/v2/me",                  scopes: OAUTH2_USERINFO_SCOPES,                  response: { $ref: "Userinfo" },                },              },            },          },        },      },    },  },  schemas: {    Tokeninfo: {      id: "Tokeninfo",      type: "object",      properties: {        audience: { type: "string" },        scope: { type: "string" },      },    },    Userinfo: {      id: "Userinfo",      type: "object",      properties: {        email: { type: "string" },        family_name: { type: "string" },        gender: { type: "string" },        given_name: { type: "string" },        hd: { type: "string" },        id: { type: "string" },        link: { type: "string" },        locale: { type: "string" },        name: { type: "string" },        picture: { type: "string" },        verified_email: { type: "boolean" },      },    },  },}; it("accepts only supported HTTPS Google Discovery endpoints", () => {  expect(    normalizeGoogleDiscoveryUrl("https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest/"),  ).toBe("https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest");  expect(    normalizeGoogleDiscoveryUrl(      "https://www.googleapis.com/discovery/v1/apis/photospicker/v1/rest",    ),  ).toBe("https://photospicker.googleapis.com/$discovery/rest?version=v1");  expect(    normalizeGoogleDiscoveryUrl("https://www.googleapis.com/discovery/v1/apis/forms/v1/rest"),  ).toBe("https://forms.googleapis.com/$discovery/rest?version=v1");  expect(    normalizeGoogleDiscoveryUrl("https://www.googleapis.com/discovery/v1/apis/keep/v1/rest"),  ).toBe("https://keep.googleapis.com/$discovery/rest?version=v1");  expect(    normalizeGoogleDiscoveryUrl("https://chat.googleapis.com/$discovery/rest?version=v1"),  ).toBe("https://www.googleapis.com/discovery/v1/apis/chat/v1/rest");  expect(    normalizeGoogleDiscoveryUrl("https://photospicker.googleapis.com/$discovery/rest?version=v1"),  ).toBe("https://photospicker.googleapis.com/$discovery/rest?version=v1");  expect(    normalizeGoogleDiscoveryUrl("https://forms.googleapis.com/$discovery/rest?version=v1"),  ).toBe("https://forms.googleapis.com/$discovery/rest?version=v1");  expect(    normalizeGoogleDiscoveryUrl("https://keep.googleapis.com/$discovery/rest?version=v1"),  ).toBe("https://keep.googleapis.com/$discovery/rest?version=v1");   expect(isGoogleDiscoveryUrl("https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest")).toBe(    true,  );  expect(isGoogleDiscoveryUrl("https://evilgoogleapis.com/discovery/v1/apis/gmail/v1/rest")).toBe(    false,  );  expect(isGoogleDiscoveryUrl("http://www.googleapis.com/discovery/v1/apis/gmail/v1/rest")).toBe(    false,  );  expect(    isGoogleDiscoveryUrl("https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest?next=x"),  ).toBe(false);  expect(    isGoogleDiscoveryUrl("https://token@www.googleapis.com/discovery/v1/apis/gmail/v1/rest"),  ).toBe(false);}); const normalizeOpenApiRefsForPreview = (node: unknown): unknown => {  if (node == null || typeof node !== "object") return node;  if (Array.isArray(node)) return node.map(normalizeOpenApiRefsForPreview);  const obj = node as Record<string, unknown>;  if (typeof obj.$ref === "string") {    const match = obj.$ref.match(/^#\/components\/schemas\/(.+)$/);    return match ? { ...obj, $ref: `#/$defs/${match[1]}` } : obj;  }  return Object.fromEntries(    Object.entries(obj).map(([key, value]) => [key, normalizeOpenApiRefsForPreview(value)]),  );}; it.effect("converts Google Discovery documents into Executor-preserving OpenAPI 3 specs", () =>  Effect.gen(function* () {    const result = yield* convertGoogleDiscoveryToOpenApi({      discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest",      // @effect-diagnostics-next-line preferSchemaOverJson:off      documentText: JSON.stringify({        name: "gmail",        version: "v1",        title: "Gmail API",        rootUrl: "https://gmail.googleapis.com/",        servicePath: "",        auth: {          oauth2: {            scopes: {              "https://www.googleapis.com/auth/gmail.metadata": {                description: "Read metadata",              },            },          },        },        resources: {          users: {            resources: {              messages: {                methods: {                  list: {                    id: "gmail.users.messages.list",                    httpMethod: "GET",                    path: "gmail/v1/users/{userId}/messages",                    scopes: ["https://www.googleapis.com/auth/gmail.metadata"],                    parameters: {                      userId: {                        location: "path",                        required: true,                        type: "string",                        description: "The user's email address. The special value me can be used.",                      },                      metadataHeaders: {                        location: "query",                        repeated: true,                        type: "string",                      },                    },                  },                },              },              drafts: {                methods: {                  create: {                    id: "gmail.users.drafts.create",                    httpMethod: "POST",                    path: "gmail/v1/users/{userId}/drafts",                    request: { $ref: "Draft" },                    response: { $ref: "Draft" },                    scopes: ["https://www.googleapis.com/auth/gmail.metadata"],                    parameters: {                      userId: {                        location: "path",                        required: true,                        type: "string",                      },                    },                  },                },              },            },          },        },        schemas: {          Draft: {            id: "Draft",            type: "object",            description: "A draft email.",            properties: {              id: {                type: "string",                description: "The immutable ID of the draft.",              },              message: {                $ref: "Message",              },            },          },          Message: {            id: "Message",            type: "object",            properties: {              id: {                type: "string",              },              labelIds: {                type: "array",                items: { type: "string" },              },            },          },        },      }),    });     const spec = decodeConvertedSpec(result.specText);    const operation = spec.paths["/gmail/v1/users/{userId}/messages"]?.get;    const createDraft = spec.paths["/gmail/v1/users/{userId}/drafts"]?.post;    expect(spec.openapi).toBe("3.1.0");    expect(spec.servers).toEqual([{ url: "https://gmail.googleapis.com/" }]);    expect(result.specText).not.toContain("_tag");    expect(operation).toMatchObject({      operationId: "users.messages.list",      "x-executor-toolPath": "users.messages.list",      "x-google-scopes": ["https://www.googleapis.com/auth/gmail.metadata"],    });    expect(operation?.security).toEqual([      { googleOAuth2: ["https://www.googleapis.com/auth/gmail.metadata"] },    ]);    expect(operation?.parameters).toContainEqual(      expect.objectContaining({        name: "metadataHeaders",        in: "query",        style: "form",        explode: true,      }),    );    expect(operation?.parameters).toContainEqual(      expect.objectContaining({        name: "userId",        description: "The user's email address. The special value me can be used.",        schema: expect.objectContaining({ type: "string" }),      }),    );    expect(createDraft).toMatchObject({      operationId: "users.drafts.create",      "x-executor-toolPath": "users.drafts.create",    });    expect(createDraft).toMatchObject({      requestBody: {        content: {          "application/json": {            schema: { $ref: "#/components/schemas/Draft" },          },        },      },    });    expect(createDraft?.parameters).toContainEqual(      expect.objectContaining({        name: "userId",        schema: expect.objectContaining({ type: "string" }),      }),    );     const parsed = yield* parse(result.specText);    const extracted = yield* extract(parsed);    const extractedDraftCreate = extracted.operations.find(      (candidate) => candidate.operationId === "users.drafts.create",    );    expect(extractedDraftCreate?.operationId).toBe("users.drafts.create");    const preview = yield* Effect.promise(() =>      buildToolTypeScriptPreview({        inputSchema: normalizeOpenApiRefsForPreview(          extractedDraftCreate            ? Option.getOrUndefined(extractedDraftCreate.inputSchema)            : undefined,        ),        outputSchema: normalizeOpenApiRefsForPreview(          extractedDraftCreate            ? Option.getOrUndefined(extractedDraftCreate.outputSchema)            : undefined,        ),        defs: new Map(          Object.entries(spec.components.schemas).map(([name, schema]) => [            name,            normalizeOpenApiRefsForPreview(schema),          ]),        ),      }),    );    expect(preview.inputTypeScript).toBe("{ userId: string; body?: Draft; }");    expect(preview.outputTypeScript).toBe("Draft");    expect(preview.typeScriptDefinitions).toMatchObject({      Draft: "{ id?: string; message?: Message; }",      Message: "{ id?: string; labelIds?: string[]; }",    });    // v2: the conversion now exposes a v2 oauth `authenticationTemplate` rather    // than v1's `oauth2` source config.    // removed: identityScopes assertion - that field belonged to the v1    // OAuth2SourceConfig slot model, which no longer exists in v2.    const oauthTemplate = result.authenticationTemplate?.find((entry) => entry.kind === "oauth2");    expect(oauthTemplate).toBeDefined();  }),); it.effect("converts Google OAuth2 v2 top-level and aliased userinfo methods", () =>  Effect.gen(function* () {    const result = yield* convertGoogleDiscoveryToOpenApi({      discoveryUrl: OAUTH2_URL,      // @effect-diagnostics-next-line preferSchemaOverJson:off      documentText: JSON.stringify(oauth2DiscoveryDoc),    });     const spec = decodeConvertedSpec(result.specText);    const userinfo = spec.paths["/oauth2/v2/userinfo"]?.get;    const userinfoMe = spec.paths["/userinfo/v2/me"]?.get;    const tokeninfo = spec.paths["/oauth2/v2/tokeninfo"]?.post;     expect(userinfo).toMatchObject({      operationId: "userinfo.get",      "x-executor-toolPath": "userinfo.get",      "x-google-scopes": [...OAUTH2_USERINFO_SCOPES],    });    expect(userinfo?.security).toEqual(      OAUTH2_USERINFO_SCOPES.map((scope) => ({ googleOAuth2: [scope] })),    );    expect(userinfo?.responses).toMatchObject({      "200": {        content: {          "application/json": {            schema: { $ref: "#/components/schemas/Userinfo" },          },        },      },    });    expect(userinfoMe).toMatchObject({      operationId: "userinfo.v2.me.get",      "x-executor-toolPath": "userinfo.v2.me.get",      "x-google-scopes": [...OAUTH2_USERINFO_SCOPES],    });    expect(tokeninfo).toMatchObject({      operationId: "tokeninfo",      "x-executor-toolPath": "tokeninfo",    });  }),); it.effect("marks Google Discovery media-download methods as binary responses", () =>  Effect.gen(function* () {    const result = yield* convertGoogleDiscoveryToOpenApi({      discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/drive/v3/rest",      // @effect-diagnostics-next-line preferSchemaOverJson:off      documentText: JSON.stringify({        name: "drive",        version: "v3",        title: "Drive API",        rootUrl: "https://www.googleapis.com/",        servicePath: "drive/v3/",        resources: {          files: {            methods: {              export: {                id: "drive.files.export",                httpMethod: "GET",                path: "files/{fileId}/export",                supportsMediaDownload: true,                useMediaDownloadService: true,                parameters: {                  fileId: {                    location: "path",                    required: true,                    type: "string",                  },                  mimeType: {                    location: "query",                    required: true,                    type: "string",                  },                },              },            },          },        },        schemas: {},      }),    });     const spec = decodeConvertedSpec(result.specText);    const operation = spec.paths["/files/{fileId}/export"]?.get;    expect(operation?.responses).toMatchObject({      "200": {        content: {          "application/octet-stream": {            schema: { type: "string", format: "binary" },          },        },      },    });     const parsed = yield* parse(result.specText);    const extracted = yield* extract(parsed);    const exportOperation = extracted.operations.find(      (candidate) => candidate.operationId === "files.export",    );    expect(exportOperation?.operationId).toBe("files.export");    const responseFileHint = Option.flatMap(      exportOperation?.responseBody ?? Option.none(),

Lines 1–500 of 1671.