-
Notifications
You must be signed in to change notification settings - Fork 9
feat(connectors): POST /api/connectors/files — stage images for LinkedIn/X posts #691
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
52eeba7
27c88c9
4b991c2
8dd4034
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<ReturnType<typeof getComposioClient>>); | ||
| }); | ||
|
|
||
| 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"); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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", | ||
| }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: deriveToolkitSlug uses a first-underscore-token heuristic that silently produces wrong toolkit slugs for multi-word toolkits (e.g., GOOGLE_DRIVE_UPLOAD → 'google' instead of 'google_drive'). The API accepts arbitrary toolSlug values, so callers using multi-word-prefix actions will get mis-scoped uploads. Prompt for AI agents |
||
| return toolSlug.split("_")[0].toLowerCase(); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<UploadedConnectorFile> { | ||
| 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 }; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P3: Missing test for empty-string toolSlug. The schema's
.min(1)validation is untested — an empty string""passesz.string()but fails.min(1), which is a different code path than a missing key.Prompt for AI agents