854 lines · 29.9 KB · TypeScript
RawDownload
// Converts Google Discovery documents directly into OpenAPI 3.x. Public// Discovery converters currently target Swagger 2.0 or a broad conversion// pipeline; this adapter emits the shape Executor parses while preserving// Executor-specific tool ids and query semantics.import { Effect, Option, Predicate, Schema, SchemaGetter } from "effect";import { HttpClient, HttpClientRequest } from "effect/unstable/http"; import { OpenApiParseError } from "./errors";import { compactGoogleOAuthScopes } from "./google-oauth-scopes";import type { SpecFetchCredentials } from "./parse";import type { Authentication } from "./types";import { AuthTemplateSlug } from "@executor-js/sdk/shared"; const DISCOVERY_SERVICE_HOST = "https://www.googleapis.com/discovery/v1/apis";const GOOGLE_BUNDLE_BASE_URL = "https://www.googleapis.com/";const GOOGLE_OAUTH_AUTHORIZATION_URL = "https://accounts.google.com/o/oauth2/v2/auth";const GOOGLE_OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token";const OPENAPI_SCHEMA_TYPES = new Set([  "array",  "boolean",  "integer",  "null",  "number",  "object",  "string",]); type JsonPrimitive = string | number | boolean | null;type JsonValue = JsonPrimitive | readonly JsonValue[] | { readonly [key: string]: JsonValue }; type OpenApiSchemaObject = {  readonly $ref?: string;  readonly type?: "array" | "boolean" | "integer" | "null" | "number" | "object" | "string";  readonly description?: string;  readonly title?: string;  readonly format?: string;  readonly readOnly?: boolean;  readonly default?: JsonValue;  readonly enum?: readonly JsonValue[];  readonly items?: OpenApiSchemaObject;  readonly properties?: Record<string, OpenApiSchemaObject>;  readonly required?: readonly string[];  readonly additionalProperties?: boolean | OpenApiSchemaObject;}; type OpenApiParameterObject = {  readonly name: string;  readonly in: "path" | "query" | "header";  readonly required: boolean;  readonly description?: string;  readonly schema: OpenApiSchemaObject;  readonly style?: "form";  readonly explode?: boolean;  readonly allowReserved?: boolean;}; type OpenApiOperationObject = {  readonly operationId: string;  readonly "x-executor-toolPath": string;  readonly "x-executor-pathTemplate"?: string;  readonly tags?: readonly string[];  readonly description?: string;  readonly servers?: readonly { readonly url: string }[];  readonly parameters: readonly OpenApiParameterObject[];  readonly requestBody?: {    readonly required: false;    readonly content: {      readonly "application/json": {        readonly schema: OpenApiSchemaObject;      };    };  };  readonly responses: {    readonly "200": {      readonly description: "Successful response";      readonly content: {        readonly "application/json": {          readonly schema: OpenApiSchemaObject;        };      };    };  };  readonly security?: readonly Record<string, readonly string[]>[];  readonly "x-google-scopes": readonly string[];}; type OpenApiDocument = {  readonly openapi: "3.1.0";  readonly info: {    readonly title: string;    readonly version: string;  };  readonly servers: readonly { readonly url: string }[];  readonly paths: Record<string, Record<string, OpenApiOperationObject>>;  readonly components: {    readonly schemas: Record<string, OpenApiSchemaObject>;    readonly securitySchemes?: Record<      string,      {        readonly type: "oauth2";        readonly flows: {          readonly authorizationCode: {            readonly authorizationUrl: string;            readonly tokenUrl: string;            readonly scopes: Record<string, string>;          };        };      }    >;  };  readonly security?: readonly Record<string, readonly string[]>[];  readonly "x-executor-origin":    | {        readonly kind: "googleDiscovery";        readonly discoveryUrl: string;        readonly service: string;        readonly version: string;      }    | {        readonly kind: "googleDiscoveryBundle";        readonly services: readonly {          readonly discoveryUrl: string;          readonly service: string;          readonly version: string;        }[];      };}; const TextOption = Schema.OptionFromOptional(Schema.Trim).pipe(  Schema.decode({    decode: SchemaGetter.transform((value) => Option.filter(value, (text) => text.length > 0)),    encode: SchemaGetter.transform((value) => value),  }),  Schema.withDecodingDefaultType(Effect.succeed(Option.none())),);const TextArray = Schema.optional(Schema.Array(Schema.String)).pipe(  Schema.withDecodingDefaultType(Effect.succeed([] as string[])),);const UnknownRecord = Schema.Record(Schema.String, Schema.Unknown);const UnknownRecordWithDefault = Schema.optional(UnknownRecord).pipe(  Schema.withDecodingDefaultType(Effect.succeed({})),); const DiscoveryParameter = Schema.Struct({  type: Schema.optional(Schema.String),  description: TextOption,  properties: UnknownRecordWithDefault,  items: Schema.optional(Schema.Unknown),  additionalProperties: Schema.optional(Schema.Union([Schema.Boolean, Schema.Unknown])),  enum: TextArray,  format: Schema.optional(Schema.String),  readOnly: Schema.optional(Schema.Boolean),  default: Schema.optional(Schema.Union([Schema.String, Schema.Number, Schema.Boolean])),  $ref: Schema.optional(Schema.String),  location: Schema.optional(Schema.Literals(["path", "query", "header"])),  required: Schema.optional(Schema.Boolean),  repeated: Schema.optional(Schema.Boolean),});type DiscoveryParameter = typeof DiscoveryParameter.Type; const DiscoveryRef = Schema.Struct({  $ref: Schema.optional(Schema.String),}); const DiscoveryMethod = Schema.Struct({  id: TextOption,  description: TextOption,  httpMethod: Schema.optional(Schema.String),  path: TextOption,  parameters: UnknownRecordWithDefault,  request: Schema.optional(DiscoveryRef),  response: Schema.optional(DiscoveryRef),  scopes: TextArray,});type DiscoveryMethod = typeof DiscoveryMethod.Type; const DiscoveryResource = Schema.Struct({  methods: UnknownRecordWithDefault,  resources: UnknownRecordWithDefault,}); const DiscoveryDocument = Schema.Struct({  name: TextOption,  version: TextOption,  title: TextOption,  rootUrl: TextOption,  servicePath: Schema.optional(Schema.Trim).pipe(    Schema.withDecodingDefaultType(Effect.succeed("")),  ),  parameters: UnknownRecordWithDefault,  methods: UnknownRecordWithDefault,  resources: UnknownRecordWithDefault,  schemas: UnknownRecordWithDefault,  auth: Schema.optional(    Schema.Struct({      oauth2: Schema.optional(        Schema.Struct({          scopes: Schema.optional(            Schema.Record(              Schema.String,              Schema.Struct({                description: TextOption,              }),            ),          ).pipe(Schema.withDecodingDefaultType(Effect.succeed({}))),        }),      ),    }),  ),});type DiscoveryDocument = typeof DiscoveryDocument.Type; export interface GoogleDiscoveryOpenApiConversion {  readonly specText: string;  readonly baseUrl: string;  readonly title: string;  readonly service: string;  readonly version: string;  readonly discoveryUrls?: readonly string[];  /** The v2 oauth auth template the converted integration declares, when the   *  Discovery document advertises OAuth2 scopes. */  readonly authenticationTemplate?: readonly Authentication[];} const decodeDiscoveryDocument = Schema.decodeUnknownSync(DiscoveryDocument);const decodeDiscoveryParameter = Schema.decodeUnknownSync(DiscoveryParameter);const decodeDiscoveryMethod = Schema.decodeUnknownSync(DiscoveryMethod);const decodeDiscoveryResource = Schema.decodeUnknownSync(DiscoveryResource);const parseJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); const normalizeDiscoveryUrl = (discoveryUrl: string): string => {  const trimmed = discoveryUrl.trim();  if (!URL.canParse(trimmed)) return trimmed;  const parsed = new URL(trimmed);  if (parsed.pathname !== "/$discovery/rest") return trimmed;  const version = parsed.searchParams.get("version")?.trim();  if (!version) return trimmed;  const host = parsed.hostname.toLowerCase();  if (!host.endsWith(".googleapis.com")) return trimmed;  const rawService = host.slice(0, -".googleapis.com".length);  const service =    rawService === "calendar-json"      ? "calendar"      : rawService.endsWith("-json")        ? rawService.slice(0, -5)        : rawService;  return service ? `${DISCOVERY_SERVICE_HOST}/${service}/${version}/rest` : trimmed;}; export const isGoogleDiscoveryUrl = (url: string): boolean => {  const trimmed = url.trim();  if (!URL.canParse(trimmed)) return false;  const parsed = new URL(trimmed);  const host = parsed.hostname.toLowerCase();  if (!host.endsWith("googleapis.com")) return false;  return parsed.pathname.includes("/discovery/") || parsed.pathname.includes("$discovery");}; export const fetchGoogleDiscoveryDocument = Effect.fn("OpenApi.fetchGoogleDiscoveryDocument")(  function* (discoveryUrl: string, credentials?: SpecFetchCredentials) {    const client = yield* HttpClient.HttpClient;    const requestUrl = new URL(discoveryUrl);    for (const [name, value] of Object.entries(credentials?.queryParams ?? {})) {      requestUrl.searchParams.set(name, value);    }    let request = HttpClientRequest.get(requestUrl.toString()).pipe(      HttpClientRequest.setHeader("Accept", "application/json, */*"),    );    for (const [name, value] of Object.entries(credentials?.headers ?? {})) {      request = HttpClientRequest.setHeader(request, name, value);    }    const response = yield* client.execute(request).pipe(      Effect.mapError(        () =>          new OpenApiParseError({            message: "Failed to fetch Google Discovery document",          }),      ),    );    if (response.status < 200 || response.status >= 300) {      return yield* new OpenApiParseError({        message: `Failed to fetch Google Discovery document: HTTP ${response.status}`,      });    }    return yield* response.text.pipe(      Effect.mapError(        () =>          new OpenApiParseError({            message: "Failed to read Google Discovery document body",          }),      ),    );  },); const schemaRef = (name: string) => `#/components/schemas/${name}`; const identitySchemaName = (name: string): string => name; const schemaComponentPart = (value: string): string =>  value    .trim()    .replace(/[^A-Za-z0-9._-]+/g, "_")    .replace(/^_+|_+$/g, "") || "schema"; const normalizeDiscoveryPathTemplate = (pathTemplate: string): string =>  pathTemplate.replaceAll(/\{\+([^{}]+)\}/g, "{$1}"); const pathUsesReservedExpansion = (pathTemplate: string, parameterName: string): boolean =>  pathTemplate.includes(`{+${parameterName}}`); const uniquePathKey = (  paths: Record<string, Record<string, OpenApiOperationObject>>,  preferredPath: string,  method: string,  toolPath: string,): string => {  if (!paths[preferredPath]?.[method]) return preferredPath;  const disambiguated = `/${toolPath.replace(/[^A-Za-z0-9._~-]+/g, "/")}`;  if (!paths[disambiguated]?.[method]) return disambiguated;  let index = 2;  while (paths[`${disambiguated}/${index}`]?.[method]) index += 1;  return `${disambiguated}/${index}`;}; const discoveryDescription = (value: unknown): string | undefined =>  typeof value === "string"    ? value    : Option.isOption(value) && Option.isSome(value)      ? typeof value.value === "string"        ? value.value        : undefined      : undefined; const isRecord = (value: unknown): value is Record<string, unknown> =>  typeof value === "object" && value !== null && !Array.isArray(value); const jsonValue = (value: unknown): JsonValue | undefined => {  if (    value === null ||    typeof value === "string" ||    typeof value === "number" ||    typeof value === "boolean"  ) {    return value;  }  if (Array.isArray(value)) {    const values = value.map(jsonValue);    return values.every(Predicate.isNotUndefined) ? values : undefined;  }  if (!isRecord(value)) return undefined;  const entries = Object.entries(value).flatMap(([key, item]) => {    const converted = jsonValue(item);    return converted === undefined ? [] : [[key, converted] as const];  });  return Object.fromEntries(entries);}; const stringArray = (value: unknown): readonly string[] | undefined => {  if (!Array.isArray(value)) return undefined;  const strings = value.filter((item): item is string => typeof item === "string");  return strings.length === value.length ? strings : undefined;}; const schemaType = (value: unknown): OpenApiSchemaObject["type"] | undefined =>  typeof value === "string" && OPENAPI_SCHEMA_TYPES.has(value)    ? (value as OpenApiSchemaObject["type"])    : undefined; const discoverySchemaToOpenApiSchema = (  raw: unknown,  schemaNameForRef: (name: string) => string = identitySchemaName,): OpenApiSchemaObject => {  if (!isRecord(raw)) return {};  const schema = raw;  if (typeof schema.$ref === "string") return { $ref: schemaRef(schemaNameForRef(schema.$ref)) };   const description = discoveryDescription(schema.description);  const title = discoveryDescription(schema.title);  const defaultValue = jsonValue(schema.default);  const enumValues = Array.isArray(schema.enum)    ? schema.enum.map(jsonValue).filter(Predicate.isNotUndefined)    : [];  const format = typeof schema.format === "string" ? schema.format : undefined;  const readOnly = typeof schema.readOnly === "boolean" ? schema.readOnly : undefined;  const type = schemaType(schema.type);   const base = {    ...(description !== undefined ? { description } : {}),    ...(title !== undefined ? { title } : {}),    ...(format !== undefined ? { format } : {}),    ...(readOnly !== undefined ? { readOnly } : {}),    ...(defaultValue !== undefined ? { default: defaultValue } : {}),    ...(enumValues.length > 0 ? { enum: enumValues } : {}),  } satisfies OpenApiSchemaObject;   if (type === "array") {    return {      ...base,      type: "array",      items: discoverySchemaToOpenApiSchema(schema.items, schemaNameForRef),    };  }   const properties = schema.properties;  if (    type === "object" ||    (isRecord(properties) && Object.keys(properties).length > 0) ||    schema.additionalProperties !== undefined  ) {    const convertedProperties = isRecord(properties)      ? Object.fromEntries(          Object.entries(properties).map(([name, value]) => [            name,            discoverySchemaToOpenApiSchema(value, schemaNameForRef),          ]),        )      : undefined;    const required = stringArray(schema.required);    const additionalProperties =      schema.additionalProperties === undefined        ? undefined        : typeof schema.additionalProperties === "boolean"          ? schema.additionalProperties          : discoverySchemaToOpenApiSchema(schema.additionalProperties, schemaNameForRef);    return {      ...base,      type: "object",      ...(convertedProperties && Object.keys(convertedProperties).length > 0        ? { properties: convertedProperties }        : {}),      ...(required && required.length > 0 ? { required } : {}),      ...(additionalProperties !== undefined ? { additionalProperties } : {}),    };  }   return type !== undefined ? { ...base, type } : base;}; const parameterSchema = (  parameter: DiscoveryParameter,  schemaNameForRef: (name: string) => string = identitySchemaName,): OpenApiSchemaObject => {  const base = discoverySchemaToOpenApiSchema(parameter, schemaNameForRef);  return parameter.repeated    ? {        type: "array",        items: base,      }    : base;}; const methodToolPath = (service: string, methodId: string): string =>  methodId.startsWith(`${service}.`) ? methodId.slice(service.length + 1) : methodId; const collectMethods = (resource: unknown): DiscoveryMethod[] => {  const decoded = decodeDiscoveryResource(resource);  const direct = Object.values(decoded.methods ?? {}).map((raw) => decodeDiscoveryMethod(raw));  const nested = Object.values(decoded.resources ?? {}).flatMap(collectMethods);  return [...direct, ...nested];}; const discoveryScopes = (document: DiscoveryDocument): Record<string, string> =>  Object.fromEntries(    Object.entries(document.auth?.oauth2?.scopes ?? {}).map(([scope, value]) => [      scope,      Option.getOrElse(value.description, () => ""),    ]),  ); // The scope set the converted integration DECLARES (and that `oauth.start`// requests at connect) must match the consent the picker previews. Both run the// raw Discovery union through `compactGoogleOAuthScopes`, which drops scopes a// user OAuth consent screen can't show (`chat.bot`/`chat.app.*`/`keep`) and// collapses sub-scopes under their broad parent (`gmail.*` → `mail.google.com`,// `userinfo.email` → `email`). Descriptions are preserved where the raw map had// them; compaction-introduced identity scopes (`email`/`profile`) fall back to// the broad parent's description. Per-operation `x-google-scopes`/`security`// stay RAW — they describe which scope each method needs, not consent.const compactDiscoveryScopeMap = (raw: Record<string, string>): Record<string, string> => {  const descriptionFor = (scope: string): string => {    if (raw[scope] !== undefined) return raw[scope];    if (scope === "email") return raw["https://www.googleapis.com/auth/userinfo.email"] ?? "";    if (scope === "profile") return raw["https://www.googleapis.com/auth/userinfo.profile"] ?? "";    return "";  };  return Object.fromEntries(    compactGoogleOAuthScopes(Object.keys(raw)).map((scope) => [scope, descriptionFor(scope)]),  );}; const allDiscoveryMethods = (document: DiscoveryDocument): DiscoveryMethod[] => [  ...Object.values(document.methods ?? {}).map((raw) => decodeDiscoveryMethod(raw)),  ...Object.values(document.resources ?? {}).flatMap(collectMethods),]; type DiscoveryDocumentInfo = {  readonly discoveryUrl: string;  readonly document: DiscoveryDocument;  readonly service: string;

Lines 1–500 of 854.