diff --git a/app/api/connectors/files/route.ts b/app/api/connectors/files/route.ts new file mode 100644 index 000000000..e0e902b47 --- /dev/null +++ b/app/api/connectors/files/route.ts @@ -0,0 +1,35 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { uploadConnectorFileHandler } from "@/lib/composio/connectors/uploadConnectorFileHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A 200 NextResponse carrying the CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * POST /api/connectors/files + * + * Stages an image into Composio storage so it can be attached to a connector + * action that accepts a `file_uploadable` field (e.g. + * `LINKEDIN_CREATE_LINKED_IN_POST.images[]`, `TWITTER_CREATION_OF_A_POST`). + * Requires `x-api-key` or `Authorization: Bearer`. + * + * @param request - The incoming request. JSON body: `url` (required — a + * publicly reachable image URL) and `toolSlug` (required — the + * UPPERCASE_SNAKE_CASE action slug the file will be attached to). + * @returns A 200 NextResponse with `{ success, name, mimetype, s3key }`, 400 on + * missing/invalid body, 401 when unauthenticated, or 502 when Composio fails + * to fetch or store the image. + */ +export async function POST(request: NextRequest) { + return uploadConnectorFileHandler(request); +} diff --git a/lib/composio/connectors/__tests__/deriveToolkitSlug.test.ts b/lib/composio/connectors/__tests__/deriveToolkitSlug.test.ts new file mode 100644 index 000000000..74c006f76 --- /dev/null +++ b/lib/composio/connectors/__tests__/deriveToolkitSlug.test.ts @@ -0,0 +1,16 @@ +import { describe, it, expect } from "vitest"; +import { deriveToolkitSlug } from "../deriveToolkitSlug"; + +describe("deriveToolkitSlug", () => { + it("derives linkedin from LINKEDIN_CREATE_LINKED_IN_POST", () => { + expect(deriveToolkitSlug("LINKEDIN_CREATE_LINKED_IN_POST")).toBe("linkedin"); + }); + + it("derives twitter from TWITTER_CREATION_OF_A_POST", () => { + expect(deriveToolkitSlug("TWITTER_CREATION_OF_A_POST")).toBe("twitter"); + }); + + it("lowercases the leading toolkit token for other slugs", () => { + expect(deriveToolkitSlug("GMAIL_SEND_EMAIL")).toBe("gmail"); + }); +}); diff --git a/lib/composio/connectors/__tests__/uploadConnectorFile.test.ts b/lib/composio/connectors/__tests__/uploadConnectorFile.test.ts new file mode 100644 index 000000000..fbb1b72a3 --- /dev/null +++ b/lib/composio/connectors/__tests__/uploadConnectorFile.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { uploadConnectorFile } from "../uploadConnectorFile"; +import { getComposioClient } from "../../client"; + +vi.mock("../../client", () => ({ + getComposioClient: vi.fn(), +})); + +describe("uploadConnectorFile", () => { + const upload = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getComposioClient).mockResolvedValue({ + files: { upload }, + } as unknown as Awaited>); + }); + + it("uploads the url scoped to the tool/toolkit and returns the flat descriptor", async () => { + upload.mockResolvedValue({ + name: "post.png", + mimetype: "image/png", + s3key: "composio/abc123", + }); + + const result = await uploadConnectorFile({ + url: "https://cdn.example.com/post.png", + toolSlug: "LINKEDIN_CREATE_LINKED_IN_POST", + }); + + expect(upload).toHaveBeenCalledWith({ + file: "https://cdn.example.com/post.png", + toolSlug: "LINKEDIN_CREATE_LINKED_IN_POST", + toolkitSlug: "linkedin", + }); + expect(result).toEqual({ + name: "post.png", + mimetype: "image/png", + s3key: "composio/abc123", + }); + }); + + it("propagates upstream upload failures", async () => { + upload.mockRejectedValue(new Error("storage returned HTTP 404")); + await expect( + uploadConnectorFile({ + url: "https://cdn.example.com/post.png", + toolSlug: "LINKEDIN_CREATE_LINKED_IN_POST", + }), + ).rejects.toThrow("storage returned HTTP 404"); + }); +}); diff --git a/lib/composio/connectors/__tests__/uploadConnectorFileHandler.test.ts b/lib/composio/connectors/__tests__/uploadConnectorFileHandler.test.ts new file mode 100644 index 000000000..9d148d455 --- /dev/null +++ b/lib/composio/connectors/__tests__/uploadConnectorFileHandler.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { uploadConnectorFileHandler } from "../uploadConnectorFileHandler"; +import { validateUploadConnectorFileRequest } from "../validateUploadConnectorFileRequest"; +import { uploadConnectorFile } from "../uploadConnectorFile"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({})), +})); + +vi.mock("../validateUploadConnectorFileRequest", () => ({ + validateUploadConnectorFileRequest: vi.fn(), +})); + +vi.mock("../uploadConnectorFile", () => ({ + uploadConnectorFile: vi.fn(), +})); + +const buildRequest = () => + new NextRequest("http://localhost/api/connectors/files", { + method: "POST", + body: JSON.stringify({ + url: "https://cdn.example.com/a.png", + toolSlug: "LINKEDIN_CREATE_LINKED_IN_POST", + }), + }); + +describe("uploadConnectorFileHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 200 with the flat file descriptor on success", async () => { + vi.mocked(validateUploadConnectorFileRequest).mockResolvedValue({ + url: "https://cdn.example.com/a.png", + toolSlug: "LINKEDIN_CREATE_LINKED_IN_POST", + }); + vi.mocked(uploadConnectorFile).mockResolvedValue({ + name: "a.png", + mimetype: "image/png", + s3key: "composio/xyz", + }); + + const response = await uploadConnectorFileHandler(buildRequest()); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body).toEqual({ + success: true, + name: "a.png", + mimetype: "image/png", + s3key: "composio/xyz", + }); + }); + + it("returns the validation/auth error response unchanged", async () => { + const errorResponse = NextResponse.json({ status: "error" }, { status: 401 }); + vi.mocked(validateUploadConnectorFileRequest).mockResolvedValue(errorResponse); + + const response = await uploadConnectorFileHandler(buildRequest()); + + expect(response).toBe(errorResponse); + expect(uploadConnectorFile).not.toHaveBeenCalled(); + }); + + it("returns 502 when the Composio upload fails", async () => { + vi.mocked(validateUploadConnectorFileRequest).mockResolvedValue({ + url: "https://cdn.example.com/a.png", + toolSlug: "LINKEDIN_CREATE_LINKED_IN_POST", + }); + vi.mocked(uploadConnectorFile).mockRejectedValue(new Error("storage returned HTTP 404")); + + const response = await uploadConnectorFileHandler(buildRequest()); + const body = await response.json(); + + expect(response.status).toBe(502); + expect(body.error).toContain("storage returned HTTP 404"); + }); +}); diff --git a/lib/composio/connectors/__tests__/validateUploadConnectorFileBody.test.ts b/lib/composio/connectors/__tests__/validateUploadConnectorFileBody.test.ts new file mode 100644 index 000000000..3673348ac --- /dev/null +++ b/lib/composio/connectors/__tests__/validateUploadConnectorFileBody.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextResponse } from "next/server"; +import { validateUploadConnectorFileBody } from "../validateUploadConnectorFileBody"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({})), +})); + +describe("validateUploadConnectorFileBody", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns the validated body for a valid url + toolSlug", () => { + const result = validateUploadConnectorFileBody({ + url: "https://cdn.example.com/post.png", + toolSlug: "LINKEDIN_CREATE_LINKED_IN_POST", + }); + expect(result).toEqual({ + url: "https://cdn.example.com/post.png", + toolSlug: "LINKEDIN_CREATE_LINKED_IN_POST", + }); + }); + + it("returns 400 when url is missing", () => { + const result = validateUploadConnectorFileBody({ toolSlug: "LINKEDIN_CREATE_LINKED_IN_POST" }); + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) expect(result.status).toBe(400); + }); + + it("returns 400 when url is not a valid URL", () => { + const result = validateUploadConnectorFileBody({ + url: "not-a-url", + toolSlug: "LINKEDIN_CREATE_LINKED_IN_POST", + }); + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) expect(result.status).toBe(400); + }); + + it("returns 400 when toolSlug is missing", () => { + const result = validateUploadConnectorFileBody({ url: "https://cdn.example.com/post.png" }); + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) expect(result.status).toBe(400); + }); +}); diff --git a/lib/composio/connectors/__tests__/validateUploadConnectorFileRequest.test.ts b/lib/composio/connectors/__tests__/validateUploadConnectorFileRequest.test.ts new file mode 100644 index 000000000..48544c340 --- /dev/null +++ b/lib/composio/connectors/__tests__/validateUploadConnectorFileRequest.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { validateUploadConnectorFileRequest } from "../validateUploadConnectorFileRequest"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({})), +})); + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +const buildRequest = (body: unknown) => + new NextRequest("http://localhost/api/connectors/files", { + method: "POST", + body: JSON.stringify(body), + }); + +describe("validateUploadConnectorFileRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns the auth error when authentication fails", async () => { + vi.mocked(validateAuthContext).mockResolvedValue( + NextResponse.json({ status: "error" }, { status: 401 }), + ); + + const result = await validateUploadConnectorFileRequest( + buildRequest({ + url: "https://cdn.example.com/a.png", + toolSlug: "LINKEDIN_CREATE_LINKED_IN_POST", + }), + ); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) expect(result.status).toBe(401); + }); + + it("returns 400 when the body is invalid", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc_1", + orgId: null, + authToken: "t", + }); + + const result = await validateUploadConnectorFileRequest(buildRequest({ toolSlug: "X" })); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) expect(result.status).toBe(400); + }); + + it("returns 400 (not a throw) when the body is malformed JSON", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc_1", + orgId: null, + authToken: "t", + }); + + const malformed = new NextRequest("http://localhost/api/connectors/files", { + method: "POST", + body: "{not json", + }); + + const result = await validateUploadConnectorFileRequest(malformed); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) expect(result.status).toBe(400); + }); + + it("returns the validated { url, toolSlug } on success", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc_1", + orgId: null, + authToken: "t", + }); + + const result = await validateUploadConnectorFileRequest( + buildRequest({ + url: "https://cdn.example.com/a.png", + toolSlug: "LINKEDIN_CREATE_LINKED_IN_POST", + }), + ); + + expect(result).toEqual({ + url: "https://cdn.example.com/a.png", + toolSlug: "LINKEDIN_CREATE_LINKED_IN_POST", + }); + }); +}); diff --git a/lib/composio/connectors/deriveToolkitSlug.ts b/lib/composio/connectors/deriveToolkitSlug.ts new file mode 100644 index 000000000..869a3c717 --- /dev/null +++ b/lib/composio/connectors/deriveToolkitSlug.ts @@ -0,0 +1,15 @@ +/** + * Derives the Composio toolkit slug from an action slug. + * + * Action slugs are `UPPERCASE_SNAKE_CASE` and prefixed with their toolkit + * (e.g. `LINKEDIN_CREATE_LINKED_IN_POST` → `linkedin`, + * `TWITTER_CREATION_OF_A_POST` → `twitter`). Composio's file upload is scoped + * to a `{ toolSlug, toolkitSlug }`, so we recover the toolkit from the leading + * token of the action slug. + * + * @param toolSlug - The action slug (e.g. `LINKEDIN_CREATE_LINKED_IN_POST`) + * @returns The lowercase toolkit slug (e.g. `linkedin`) + */ +export function deriveToolkitSlug(toolSlug: string): string { + return toolSlug.split("_")[0].toLowerCase(); +} diff --git a/lib/composio/connectors/uploadConnectorFile.ts b/lib/composio/connectors/uploadConnectorFile.ts new file mode 100644 index 000000000..d6837732b --- /dev/null +++ b/lib/composio/connectors/uploadConnectorFile.ts @@ -0,0 +1,42 @@ +import { getComposioClient } from "../client"; +import { deriveToolkitSlug } from "./deriveToolkitSlug"; + +/** + * A Composio file descriptor, ready to embed in a `file_uploadable` action + * field (e.g. `LINKEDIN_CREATE_LINKED_IN_POST.images[]`). + */ +export interface UploadedConnectorFile { + name: string; + mimetype: string; + s3key: string; +} + +/** + * Stages an image into Composio storage and returns its `{ name, mimetype, s3key }` + * descriptor. + * + * Composio's SDK fetches the URL server-side, uploads the bytes to storage + * (deduplicated by content hash), and returns a storage key that can then be + * passed verbatim into a `file_uploadable` action parameter. The upload is + * scoped to the action's tool/toolkit, derived from `toolSlug`. + * + * @param params.url - Publicly reachable image URL to stage + * @param params.toolSlug - The action slug the file will be attached to + * (e.g. `LINKEDIN_CREATE_LINKED_IN_POST`) + * @returns The Composio file descriptor + * @throws when Composio fails to fetch or store the file (surfaced as 502 upstream) + */ +export async function uploadConnectorFile(params: { + url: string; + toolSlug: string; +}): Promise { + const composio = await getComposioClient(); + + const { name, mimetype, s3key } = await composio.files.upload({ + file: params.url, + toolSlug: params.toolSlug, + toolkitSlug: deriveToolkitSlug(params.toolSlug), + }); + + return { name, mimetype, s3key }; +} diff --git a/lib/composio/connectors/uploadConnectorFileHandler.ts b/lib/composio/connectors/uploadConnectorFileHandler.ts new file mode 100644 index 000000000..6e4156d74 --- /dev/null +++ b/lib/composio/connectors/uploadConnectorFileHandler.ts @@ -0,0 +1,37 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateUploadConnectorFileRequest } from "./validateUploadConnectorFileRequest"; +import { uploadConnectorFile } from "./uploadConnectorFile"; + +/** + * Handler for POST /api/connectors/files. + * + * Stages an image (by public URL) into Composio storage and returns a flat + * `{ success, name, mimetype, s3key }` descriptor. The caller passes + * `{ name, mimetype, s3key }` into a `file_uploadable` action parameter + * (e.g. `parameters.images[]` for `LINKEDIN_CREATE_LINKED_IN_POST`) on + * POST /api/connectors/actions. A failure to fetch or store the image surfaces + * as 502 (upstream). + * + * @param request - The incoming request + * @returns A 200 response with `{ success, name, mimetype, s3key }`, or an error. + */ +export async function uploadConnectorFileHandler(request: NextRequest): Promise { + const headers = getCorsHeaders(); + + try { + const validated = await validateUploadConnectorFileRequest(request); + if (validated instanceof NextResponse) { + return validated; + } + + const { name, mimetype, s3key } = await uploadConnectorFile(validated); + + return NextResponse.json({ success: true, name, mimetype, s3key }, { status: 200, headers }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to upload connector file"; + console.error("Connector file upload error:", error); + return NextResponse.json({ error: message }, { status: 502, headers }); + } +} diff --git a/lib/composio/connectors/validateUploadConnectorFileBody.ts b/lib/composio/connectors/validateUploadConnectorFileBody.ts new file mode 100644 index 000000000..c376ef997 --- /dev/null +++ b/lib/composio/connectors/validateUploadConnectorFileBody.ts @@ -0,0 +1,45 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { z } from "zod"; + +export const uploadConnectorFileBodySchema = z.object({ + url: z + .string({ message: "url is required" }) + .url("url must be a valid URL (a publicly reachable image link)"), + toolSlug: z + .string({ message: "toolSlug is required" }) + .min(1, "toolSlug cannot be empty (e.g., 'LINKEDIN_CREATE_LINKED_IN_POST')"), +}); + +export type UploadConnectorFileBody = z.infer; + +/** + * Validates request body for POST /api/connectors/files. + * + * Body shape: { url, toolSlug }. + * + * @param body - The request body + * @returns A NextResponse with an error if validation fails, or the validated body if validation passes. + */ +export function validateUploadConnectorFileBody( + body: unknown, +): NextResponse | UploadConnectorFileBody { + const result = uploadConnectorFileBodySchema.safeParse(body); + + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { + status: "error", + missing_fields: firstError.path, + error: firstError.message, + }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } + + return result.data; +} diff --git a/lib/composio/connectors/validateUploadConnectorFileRequest.ts b/lib/composio/connectors/validateUploadConnectorFileRequest.ts new file mode 100644 index 000000000..bf1791c9b --- /dev/null +++ b/lib/composio/connectors/validateUploadConnectorFileRequest.ts @@ -0,0 +1,43 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { safeParseJson } from "@/lib/networking/safeParseJson"; +import { validateUploadConnectorFileBody } from "./validateUploadConnectorFileBody"; + +/** + * Validated params for staging a connector file. + */ +export interface UploadConnectorFileParams { + url: string; + toolSlug: string; +} + +/** + * Validates the full POST /api/connectors/files request. + * + * Handles: + * 1. Authentication (x-api-key or Bearer token) — gates the endpoint + * 2. Body validation ({ url, toolSlug }) + * + * The Composio upload is scoped by tool/toolkit, not by connected account, so + * no `account_id` is accepted or used here — authentication just gates access. + * + * @param request - The incoming request + * @returns NextResponse error or validated params + */ +export async function validateUploadConnectorFileRequest( + request: NextRequest, +): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) { + return authResult; + } + + const body = await safeParseJson(request); + const validated = validateUploadConnectorFileBody(body); + if (validated instanceof NextResponse) { + return validated; + } + + return { url: validated.url, toolSlug: validated.toolSlug }; +}