diff --git a/packages/plugins/google/src/sdk/discovery.test.ts b/packages/plugins/google/src/sdk/discovery.test.ts index 30604ed85..bfd77691f 100644 --- a/packages/plugins/google/src/sdk/discovery.test.ts +++ b/packages/plugins/google/src/sdk/discovery.test.ts @@ -395,6 +395,195 @@ it.effect("supplies documented scopes when Picker Discovery omits auth metadata" }), ); +it.effect( + "generates a separate media-upload operation for Google Discovery methods with supportsMediaUpload", + () => + 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: { + create: { + id: "drive.files.create", + httpMethod: "POST", + path: "files", + request: { $ref: "File" }, + response: { $ref: "File" }, + supportsMediaUpload: true, + mediaUpload: { + accept: ["*/*"], + maxSize: "5497558138880", + protocols: { + simple: { + multipart: true, + path: "/upload/drive/v3/files", + }, + resumable: { + multipart: true, + path: "/resumable/upload/drive/v3/files", + }, + }, + }, + scopes: ["https://www.googleapis.com/auth/drive.file"], + parameters: { + enforceSingleParent: { + location: "query", + type: "boolean", + }, + }, + }, + update: { + id: "drive.files.update", + httpMethod: "PATCH", + path: "files/{fileId}", + request: { $ref: "File" }, + response: { $ref: "File" }, + supportsMediaUpload: true, + mediaUpload: { + accept: ["*/*"], + protocols: { + simple: { + multipart: true, + path: "/upload/drive/v3/files/{fileId}", + }, + }, + }, + scopes: ["https://www.googleapis.com/auth/drive.file"], + parameters: { + fileId: { location: "path", required: true, type: "string" }, + }, + }, + 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: { + File: { + id: "File", + type: "object", + properties: { + name: { type: "string" }, + }, + }, + }, + }), + }); + + const spec = decodeConvertedSpec(result.specText); + // The metadata-only operation should keep the original JSON behavior. + const createMetadata = spec.paths["/files"]?.post; + expect(createMetadata).toMatchObject({ + operationId: "files.create", + "x-executor-toolPath": "files.create", + requestBody: { + content: { + "application/json": { + schema: { $ref: "#/components/schemas/File" }, + }, + }, + }, + }); + + // A separate media-upload operation should be emitted with the simple upload path. + const createMedia = spec.paths["/upload/drive/v3/files"]?.post; + expect(createMedia).toMatchObject({ + operationId: "files.createMedia", + "x-executor-toolPath": "files.createMedia", + "x-executor-pathTemplate": "/upload/drive/v3/files", + }); + expect(createMedia?.parameters).toContainEqual( + expect.objectContaining({ + name: "uploadType", + in: "query", + required: true, + schema: { + type: "string", + description: "The upload type for the media upload.", + enum: ["media"], + default: "media", + }, + }), + ); + expect(createMedia?.requestBody).toMatchObject({ + content: { + "application/octet-stream": { + schema: { type: "string", format: "binary" }, + }, + }, + }); + + // The updateMedia operation should preserve path parameters from the original method. + const updateMedia = spec.paths["/upload/drive/v3/files/{fileId}"]?.patch; + expect(updateMedia).toMatchObject({ + operationId: "files.updateMedia", + "x-executor-toolPath": "files.updateMedia", + "x-executor-pathTemplate": "/upload/drive/v3/files/{fileId}", + }); + expect(updateMedia?.parameters).toContainEqual( + expect.objectContaining({ + name: "fileId", + in: "path", + required: true, + }), + ); + expect(updateMedia?.parameters).toContainEqual( + expect.objectContaining({ + name: "uploadType", + in: "query", + required: true, + schema: { + type: "string", + description: "The upload type for the media upload.", + enum: ["media"], + default: "media", + }, + }), + ); + + // Extraction should produce a usable input schema that includes bodyBase64. + const parsed = yield* parse(result.specText); + const extracted = yield* extract(parsed); + const createMediaOp = extracted.operations.find( + (candidate) => candidate.operationId === "files.createMedia", + ); + expect(createMediaOp?.operationId).toBe("files.createMedia"); + const inputSchema = createMediaOp + ? (Option.getOrUndefined(createMediaOp.inputSchema) as + | { + properties?: Record; + required?: string[]; + } + | undefined) + : undefined; + expect(inputSchema).toBeDefined(); + expect(inputSchema?.properties?.bodyBase64).toMatchObject({ + type: "string", + contentEncoding: "base64", + contentMediaType: "application/octet-stream", + }); + expect(inputSchema?.required).toContain("bodyBase64"); + expect(inputSchema?.properties?.body).toBeUndefined(); + }), +); + it.effect("bundles Google Discovery documents into one Google OpenAPI source", () => Effect.gen(function* () { const result = yield* convertGoogleDiscoveryBundleToOpenApi({ @@ -631,6 +820,91 @@ const decodeConvertedSpecSecurity = Schema.decodeUnknownSync( Schema.fromJsonString(ConvertedSpecSecurity), ); +it.effect("generates media-upload operations for bundled Google Discovery documents", () => + Effect.gen(function* () { + const result = yield* convertGoogleDiscoveryBundleToOpenApi({ + documents: [ + { + 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/", + auth: { + oauth2: { + scopes: { + "https://www.googleapis.com/auth/drive.file": { + description: "Drive file access", + }, + }, + }, + }, + resources: { + files: { + methods: { + create: { + id: "drive.files.create", + httpMethod: "POST", + path: "files", + request: { $ref: "File" }, + response: { $ref: "File" }, + supportsMediaUpload: true, + mediaUpload: { + accept: ["*/*"], + protocols: { + simple: { + multipart: true, + path: "/upload/drive/v3/files", + }, + }, + }, + scopes: ["https://www.googleapis.com/auth/drive.file"], + parameters: {}, + }, + }, + }, + }, + schemas: { + File: { + id: "File", + type: "object", + properties: { + name: { type: "string" }, + }, + }, + }, + }), + }, + ], + }); + + const spec = decodeConvertedSpec(result.specText); + const createMedia = spec.paths["/upload/drive/v3/files"]?.post; + expect(createMedia).toMatchObject({ + operationId: "drive.files.createMedia", + "x-executor-toolPath": "drive.files.createMedia", + "x-executor-pathTemplate": "/upload/drive/v3/files", + }); + expect(createMedia?.parameters).toContainEqual( + expect.objectContaining({ + name: "uploadType", + in: "query", + required: true, + }), + ); + expect(createMedia?.requestBody).toMatchObject({ + content: { + "application/octet-stream": { + schema: { type: "string", format: "binary" }, + }, + }, + }); + }), +); + it.effect("compacts and filters the merged bundle scope set into a clean consent set", () => Effect.gen(function* () { const result = yield* convertGoogleDiscoveryBundleToOpenApi({ diff --git a/packages/plugins/google/src/sdk/discovery.ts b/packages/plugins/google/src/sdk/discovery.ts index f81530598..2e4bbec31 100644 --- a/packages/plugins/google/src/sdk/discovery.ts +++ b/packages/plugins/google/src/sdk/discovery.ts @@ -200,6 +200,22 @@ const DiscoveryRef = Schema.Struct({ $ref: Schema.optional(Schema.String), }); +const DiscoveryMediaUploadProtocol = Schema.Struct({ + path: TextOption, + multipart: Schema.optional(Schema.Boolean), +}); + +const DiscoveryMediaUploadProtocols = Schema.Struct({ + simple: Schema.optional(DiscoveryMediaUploadProtocol), + resumable: Schema.optional(DiscoveryMediaUploadProtocol), +}); + +const DiscoveryMediaUpload = Schema.Struct({ + accept: Schema.optional(TextArray), + maxSize: Schema.optional(Schema.String), + protocols: Schema.optional(DiscoveryMediaUploadProtocols), +}); + const DiscoveryMethod = Schema.Struct({ id: TextOption, description: TextOption, @@ -209,6 +225,8 @@ const DiscoveryMethod = Schema.Struct({ request: Schema.optional(DiscoveryRef), response: Schema.optional(DiscoveryRef), scopes: TextArray, + supportsMediaUpload: Schema.optional(Schema.Boolean), + mediaUpload: Schema.optional(DiscoveryMediaUpload), supportsMediaDownload: Schema.optional(Schema.Boolean), useMediaDownloadService: Schema.optional(Schema.Boolean), }); @@ -708,6 +726,104 @@ const buildDiscoveryOperation = (input: { }; }; +const buildDiscoveryMediaUploadOperation = (input: { + readonly document: DiscoveryDocument; + readonly method: DiscoveryMethod; + readonly toolPath: string; + readonly oauthScopes?: readonly string[]; + readonly schemaNameForRef?: (name: string) => string; + readonly serverUrl?: string; + readonly tags?: readonly string[]; +}): OpenApiOperationObject | undefined => { + if (input.method.supportsMediaUpload !== true) return undefined; + const mediaUpload = input.method.mediaUpload; + if (mediaUpload === undefined) return undefined; + const simpleProtocol = mediaUpload.protocols?.simple; + const uploadPath = simpleProtocol ? Option.getOrUndefined(simpleProtocol.path) : undefined; + if (!uploadPath) return undefined; + + const mergedParameters = new Map(); + for (const [name, raw] of Object.entries(input.document.parameters ?? {})) { + const parameter = decodeDiscoveryParameter(raw); + if (parameter.location) mergedParameters.set(name, parameter); + } + for (const [name, raw] of Object.entries(input.method.parameters ?? {})) { + const parameter = decodeDiscoveryParameter(raw); + if (parameter.location) mergedParameters.set(name, parameter); + } + mergedParameters.set("uploadType", { + type: "string", + location: "query", + required: true, + description: Option.some("The upload type for the media upload."), + properties: {}, + items: undefined, + additionalProperties: undefined, + enum: ["media"], + format: undefined, + readOnly: undefined, + default: "media", + $ref: undefined, + repeated: undefined, + }); + + const methodScopes = input.oauthScopes ?? input.method.scopes ?? []; + const schemaNameForRef = input.schemaNameForRef ?? identitySchemaName; + const methodDescription = Option.getOrUndefined(input.method.description); + + return { + operationId: `${input.toolPath}Media`, + "x-executor-toolPath": `${input.toolPath}Media`, + "x-executor-pathTemplate": uploadPath, + ...(input.tags && input.tags.length > 0 ? { tags: input.tags } : {}), + ...(methodDescription !== undefined + ? { description: `${methodDescription} (media upload)` } + : {}), + ...(input.serverUrl ? { servers: [{ url: input.serverUrl }] } : {}), + parameters: [...mergedParameters.entries()].flatMap(([name, parameter]) => { + const location = parameter.location; + if (!location) return []; + const description = Option.getOrUndefined(parameter.description); + const allowReserved = location === "path" && pathUsesReservedExpansion(uploadPath, name); + return [ + { + name, + in: location, + required: location === "path" ? true : parameter.required === true, + ...(description !== undefined ? { description } : {}), + schema: parameterSchema(parameter, schemaNameForRef), + ...(location === "query" + ? { style: "form" as const, explode: parameter.repeated === true } + : {}), + ...(allowReserved ? { allowReserved: true } : {}), + }, + ]; + }), + requestBody: { + required: true, + content: { + "application/octet-stream": { + schema: { type: "string", format: "binary" }, + }, + }, + }, + responses: { + "200": { + description: "Successful response", + content: { + "application/json": { + schema: input.method.response?.$ref + ? { $ref: schemaRef(schemaNameForRef(input.method.response.$ref)) } + : {}, + }, + }, + }, + }, + ...(methodScopes.length > 0 ? { security: [{ googleOAuth2: methodScopes }] } : {}), + "x-google-scopes": methodScopes, + }; +}; + const GOOGLE_OAUTH_SECURITY_SCHEME = "googleOAuth2"; const GOOGLE_PHOTOS_LIBRARY_SERVICE = "photoslibrary"; const GOOGLE_PHOTOS_APPENDONLY_SCOPE = "https://www.googleapis.com/auth/photoslibrary.appendonly"; @@ -847,7 +963,7 @@ export const convertGoogleDiscoveryToOpenApi = Effect.fn("OpenApi.convertGoogleD }); const info = yield* discoveryDocumentInfo(document, input.discoveryUrl); - const { service, version, baseUrl, title } = info; + const { service, version, rootUrl, baseUrl, title } = info; const paths: Record> = {}; for (const method of allDiscoveryMethods(document)) { @@ -870,6 +986,31 @@ export const convertGoogleDiscoveryToOpenApi = Effect.fn("OpenApi.convertGoogleD pathTemplate: pathTemplate.startsWith("/") ? pathTemplate : `/${pathTemplate}`, oauthScopes: discoveryMethodScopesForService(service, method), }); + + const mediaUploadOperation = buildDiscoveryMediaUploadOperation({ + document, + method, + toolPath, + oauthScopes: discoveryMethodScopesForService(service, method), + serverUrl: rootUrl, + }); + if (mediaUploadOperation) { + const mediaUploadPathTemplate = mediaUploadOperation["x-executor-pathTemplate"] ?? ""; + const uploadPath = normalizeDiscoveryPathTemplate( + mediaUploadPathTemplate.startsWith("/") + ? mediaUploadPathTemplate + : `/${mediaUploadPathTemplate}`, + ); + const uploadMethodKey = method.httpMethod.toLowerCase(); + const uploadPathKey = uniquePathKey( + paths, + uploadPath, + uploadMethodKey, + mediaUploadOperation.operationId, + ); + paths[uploadPathKey] ??= {}; + paths[uploadPathKey]![uploadMethodKey] = mediaUploadOperation; + } } if ( @@ -1020,6 +1161,33 @@ export const convertGoogleDiscoveryBundleToOpenApi = Effect.fn( serverUrl: info.baseUrl, tags: [info.title], }); + + const mediaUploadOperation = buildDiscoveryMediaUploadOperation({ + document: info.document, + method, + toolPath, + oauthScopes, + schemaNameForRef, + serverUrl: info.rootUrl, + tags: [info.title], + }); + if (mediaUploadOperation) { + const mediaUploadPathTemplate = mediaUploadOperation["x-executor-pathTemplate"] ?? ""; + const uploadPath = normalizeDiscoveryPathTemplate( + mediaUploadPathTemplate.startsWith("/") + ? mediaUploadPathTemplate + : `/${mediaUploadPathTemplate}`, + ); + const uploadMethodKey = method.httpMethod.toLowerCase(); + const uploadPathKey = uniquePathKey( + paths, + uploadPath, + uploadMethodKey, + mediaUploadOperation.operationId, + ); + paths[uploadPathKey] ??= {}; + paths[uploadPathKey]![uploadMethodKey] = mediaUploadOperation; + } } if (