// 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. Load more
// 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;