feat(platform): centralize api calls in nextjs for token handling (#10222)

This PR helps to send all the React query requests through a Next.js
server proxy. It works something like this: when a user sends a request,
our custom mutator sends a request to the proxy server, where we add the
auth token to the header and send it to the backend again. 🌐

Users can send a client-side request directly to the backend server
because their browser does have access to auth tokens, so they need to
go via the Next.js server. 🚀

### Changes 🏗️

- Change the position of the generated client, mutator, and transfer
inside `/src/app/api`
- Update the mutator to send the request to the proxy server 
- Add a proxy server at `/api/proxy`, which handles the request using
`makeAuthenticatedRequest` and `makeAuthenticatedFileUpload` helpers and
sends the request to the backend
- Remove `getSupabaseClient`, because we do not have access to the auth
token on client side, hence no need 🔑
- Update Orval configs to generate the client at the new position 
- Added new backend updates to the auto-generated client.

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  - [x] The setting page is using React Query and is working fine.
  - [x] The mutator is sending requests to the proxy server correctly.
  - [x] The proxy server is handling requests correctly.
- [x] The response handling is correct in both the proxy server and the
custom mutator.
This commit is contained in:
Abhimanyu Yadav
2025-06-30 14:01:08 +05:30
committed by GitHub
parent 2dd366172e
commit b5c7f381c1
15 changed files with 280 additions and 75 deletions

2
.gitignore vendored
View File

@@ -179,4 +179,4 @@ autogpt_platform/backend/settings.py
.claude/settings.local.json
# Auto generated client
autogpt_platform/frontend/src/api/__generated__
autogpt_platform/frontend/src/app/api/__generated__

View File

@@ -3,13 +3,13 @@ import { defineConfig } from "orval";
export default defineConfig({
autogpt_api_client: {
input: {
target: `./src/api/openapi.json`,
target: `./src/app/api/openapi.json`,
override: {
transformer: "./src/api/transformers/fix-tags.mjs",
transformer: "./src/app/api/transformers/fix-tags.mjs",
},
},
output: {
workspace: "./src/api",
workspace: "./src/app/api",
target: `./__generated__/endpoints`,
schemas: "./__generated__/models",
mode: "tags-split",
@@ -39,13 +39,13 @@ export default defineConfig({
},
autogpt_zod_schema: {
input: {
target: `./src/api/openapi.json`,
target: `./src/app/api/openapi.json`,
override: {
transformer: "./src/api/transformers/fix-tags.mjs",
transformer: "./src/app/api/transformers/fix-tags.mjs",
},
},
output: {
workspace: "./src/api",
workspace: "./src/app/api",
target: `./__generated__/zod-schema`,
schemas: "./__generated__/models",
mode: "tags-split",

View File

@@ -17,7 +17,8 @@
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"test-storybook": "test-storybook",
"fetch:openapi": "curl http://localhost:8006/openapi.json > ./src/api/openapi.json && prettier --write ./src/api/openapi.json",
"test-storybook:ci": "concurrently -k -s first -n \"SB,TEST\" -c \"magenta,blue\" \"pnpm run build-storybook -- --quiet && npx http-server storybook-static --port 6006 --silent\" \"wait-on tcp:6006 && pnpm run test-storybook\"",
"fetch:openapi": "curl http://localhost:8006/openapi.json > ./src/app/api/openapi.json && prettier --write ./src/app/api/openapi.json",
"generate:api-client": "orval --config ./orval.config.ts",
"generate:api-all": "pnpm run fetch:openapi && pnpm run generate:api-client"
},

View File

@@ -3,8 +3,8 @@ import {
getGetV1ListUserApiKeysQueryKey,
useDeleteV1RevokeApiKey,
useGetV1ListUserApiKeys,
} from "@/api/__generated__/endpoints/api-keys/api-keys";
import { APIKeyWithoutHash } from "@/api/__generated__/models/aPIKeyWithoutHash";
} from "@/app/api/__generated__/endpoints/api-keys/api-keys";
import { APIKeyWithoutHash } from "@/app/api/__generated__/models/aPIKeyWithoutHash";
import { useToast } from "@/components/ui/use-toast";
import { getQueryClient } from "@/lib/react-query/queryClient";

View File

@@ -15,7 +15,7 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import { useAPIkeysModals } from "./useAPIkeysModals";
import { APIKeyPermission } from "@/api/__generated__/models/aPIKeyPermission";
import { APIKeyPermission } from "@/app/api/__generated__/models/aPIKeyPermission";
export const APIKeysModals = () => {
const {

View File

@@ -2,9 +2,9 @@
import {
getGetV1ListUserApiKeysQueryKey,
usePostV1CreateNewApiKey,
} from "@/api/__generated__/endpoints/api-keys/api-keys";
import { APIKeyPermission } from "@/api/__generated__/models/aPIKeyPermission";
import { CreateAPIKeyResponse } from "@/api/__generated__/models/createAPIKeyResponse";
} from "@/app/api/__generated__/endpoints/api-keys/api-keys";
import { APIKeyPermission } from "@/app/api/__generated__/models/aPIKeyPermission";
import { CreateAPIKeyResponse } from "@/app/api/__generated__/models/createAPIKeyResponse";
import { useToast } from "@/components/ui/use-toast";
import { getQueryClient } from "@/lib/react-query/queryClient";
import { useState } from "react";

View File

@@ -6,7 +6,7 @@ import { NotificationPreferenceDTO } from "@/lib/autogpt-server-api/types";
import {
postV1UpdateNotificationPreferences,
postV1UpdateUserEmail,
} from "@/api/__generated__/endpoints/auth/auth";
} from "@/app/api/__generated__/endpoints/auth/auth";
export async function updateSettings(formData: FormData) {
const supabase = await getServerSupabase();

View File

@@ -13,7 +13,7 @@ import {
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Separator } from "@/components/ui/separator";
import { NotificationPreference } from "@/api/__generated__/models/notificationPreference";
import { NotificationPreference } from "@/app/api/__generated__/models/notificationPreference";
import { User } from "@supabase/supabase-js";
import { useSettingsForm } from "./useSettingsForm";

View File

@@ -5,7 +5,7 @@ import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { updateSettings } from "../../actions";
import { useToast } from "@/components/ui/use-toast";
import { NotificationPreference } from "@/api/__generated__/models/notificationPreference";
import { NotificationPreference } from "@/app/api/__generated__/models/notificationPreference";
import { User } from "@supabase/supabase-js";
export const useSettingsForm = ({

View File

@@ -1,5 +1,5 @@
"use client";
import { useGetV1GetNotificationPreferences } from "@/api/__generated__/endpoints/auth/auth";
import { useGetV1GetNotificationPreferences } from "@/app/api/__generated__/endpoints/auth/auth";
import { SettingsForm } from "@/app/(platform)/profile/(user)/settings/components/SettingsForm/SettingsForm";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import * as React from "react";

View File

@@ -1,7 +1,4 @@
import { getSupabaseClient } from "@/lib/supabase/getSupabaseClient";
const BASE_URL =
process.env.NEXT_PUBLIC_AGPT_SERVER_BASE_URL || "http://localhost:8006";
const BASE_URL = "/api/proxy"; // Sending request via nextjs Server
const getBody = <T>(c: Response | Request): Promise<T> => {
const contentType = c.headers.get("content-type");
@@ -17,18 +14,6 @@ const getBody = <T>(c: Response | Request): Promise<T> => {
return c.text() as Promise<T>;
};
const getSupabaseToken = async () => {
const supabase = await getSupabaseClient();
const {
data: { session },
} = (await supabase?.auth.getSession()) || {
data: { session: null },
};
return session?.access_token;
};
export const customMutator = async <T = any>(
url: string,
options: RequestInit & {
@@ -47,12 +32,6 @@ export const customMutator = async <T = any>(
...((requestOptions.headers as Record<string, string>) || {}),
};
const token = await getSupabaseToken();
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const isFormData = data instanceof FormData;
// Currently, only two content types are handled here: application/json and multipart/form-data

View File

@@ -186,14 +186,16 @@
"oneOf": [
{ "$ref": "#/components/schemas/OAuth2Credentials" },
{ "$ref": "#/components/schemas/APIKeyCredentials" },
{ "$ref": "#/components/schemas/UserPasswordCredentials" }
{ "$ref": "#/components/schemas/UserPasswordCredentials" },
{ "$ref": "#/components/schemas/HostScopedCredentials-Input" }
],
"discriminator": {
"propertyName": "type",
"mapping": {
"oauth2": "#/components/schemas/OAuth2Credentials",
"api_key": "#/components/schemas/APIKeyCredentials",
"user_password": "#/components/schemas/UserPasswordCredentials"
"user_password": "#/components/schemas/UserPasswordCredentials",
"host_scoped": "#/components/schemas/HostScopedCredentials-Input"
}
},
"title": "Credentials"
@@ -210,14 +212,18 @@
"oneOf": [
{ "$ref": "#/components/schemas/OAuth2Credentials" },
{ "$ref": "#/components/schemas/APIKeyCredentials" },
{ "$ref": "#/components/schemas/UserPasswordCredentials" }
{ "$ref": "#/components/schemas/UserPasswordCredentials" },
{
"$ref": "#/components/schemas/HostScopedCredentials-Output"
}
],
"discriminator": {
"propertyName": "type",
"mapping": {
"oauth2": "#/components/schemas/OAuth2Credentials",
"api_key": "#/components/schemas/APIKeyCredentials",
"user_password": "#/components/schemas/UserPasswordCredentials"
"user_password": "#/components/schemas/UserPasswordCredentials",
"host_scoped": "#/components/schemas/HostScopedCredentials-Output"
}
},
"title": "Response Postv1Createcredentials"
@@ -270,14 +276,18 @@
"oneOf": [
{ "$ref": "#/components/schemas/OAuth2Credentials" },
{ "$ref": "#/components/schemas/APIKeyCredentials" },
{ "$ref": "#/components/schemas/UserPasswordCredentials" }
{ "$ref": "#/components/schemas/UserPasswordCredentials" },
{
"$ref": "#/components/schemas/HostScopedCredentials-Output"
}
],
"discriminator": {
"propertyName": "type",
"mapping": {
"oauth2": "#/components/schemas/OAuth2Credentials",
"api_key": "#/components/schemas/APIKeyCredentials",
"user_password": "#/components/schemas/UserPasswordCredentials"
"user_password": "#/components/schemas/UserPasswordCredentials",
"host_scoped": "#/components/schemas/HostScopedCredentials-Output"
}
},
"title": "Response Getv1Getcredential"
@@ -434,9 +444,7 @@
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Body_postV1LogRawMetric"
}
"schema": { "$ref": "#/components/schemas/LogRawMetricRequest" }
}
},
"required": true
@@ -3797,16 +3805,6 @@
"required": ["type", "data", "data_index"],
"title": "Body_postV1LogRawAnalytics"
},
"Body_postV1LogRawMetric": {
"properties": {
"metric_name": { "type": "string", "title": "Metric Name" },
"metric_value": { "type": "number", "title": "Metric Value" },
"data_string": { "type": "string", "title": "Data String" }
},
"type": "object",
"required": ["metric_name", "metric_value", "data_string"],
"title": "Body_postV1LogRawMetric"
},
"Body_postV2Add_credits_to_user": {
"properties": {
"user_id": { "type": "string", "title": "User Id" },
@@ -4019,7 +4017,7 @@
"provider": { "$ref": "#/components/schemas/ProviderName" },
"type": {
"type": "string",
"enum": ["api_key", "oauth2", "user_password"],
"enum": ["api_key", "oauth2", "user_password", "host_scoped"],
"title": "Type"
}
},
@@ -4035,7 +4033,7 @@
"provider": { "type": "string", "title": "Provider" },
"type": {
"type": "string",
"enum": ["api_key", "oauth2", "user_password"],
"enum": ["api_key", "oauth2", "user_password", "host_scoped"],
"title": "Type"
},
"title": {
@@ -4052,6 +4050,11 @@
"username": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Username"
},
"host": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Host",
"description": "Host pattern for host-scoped credentials"
}
},
"type": "object",
@@ -4393,6 +4396,70 @@
"type": "object",
"title": "HTTPValidationError"
},
"HostScopedCredentials-Input": {
"properties": {
"id": { "type": "string", "title": "Id" },
"provider": { "type": "string", "title": "Provider" },
"title": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Title"
},
"type": {
"type": "string",
"const": "host_scoped",
"title": "Type",
"default": "host_scoped"
},
"host": {
"type": "string",
"title": "Host",
"description": "The host/URI pattern to match against request URLs"
},
"headers": {
"additionalProperties": {
"type": "string",
"format": "password",
"writeOnly": true
},
"type": "object",
"title": "Headers",
"description": "Key-value header map to add to matching requests"
}
},
"type": "object",
"required": ["provider", "host"],
"title": "HostScopedCredentials"
},
"HostScopedCredentials-Output": {
"properties": {
"id": { "type": "string", "title": "Id" },
"provider": { "type": "string", "title": "Provider" },
"title": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Title"
},
"type": {
"type": "string",
"const": "host_scoped",
"title": "Type",
"default": "host_scoped"
},
"host": {
"type": "string",
"title": "Host",
"description": "The host/URI pattern to match against request URLs"
},
"headers": {
"additionalProperties": { "type": "string" },
"type": "object",
"title": "Headers",
"description": "Key-value header map to add to matching requests"
}
},
"type": "object",
"required": ["provider", "host"],
"title": "HostScopedCredentials"
},
"LibraryAgent": {
"properties": {
"id": { "type": "string", "title": "Id" },
@@ -4712,6 +4779,24 @@
"required": ["source_id", "sink_id", "source_name", "sink_name"],
"title": "Link"
},
"LogRawMetricRequest": {
"properties": {
"metric_name": {
"type": "string",
"minLength": 1,
"title": "Metric Name"
},
"metric_value": { "type": "number", "title": "Metric Value" },
"data_string": {
"type": "string",
"minLength": 1,
"title": "Data String"
}
},
"type": "object",
"required": ["metric_name", "metric_value", "data_string"],
"title": "LogRawMetricRequest"
},
"LoginResponse": {
"properties": {
"login_url": { "type": "string", "title": "Login Url" },
@@ -5459,6 +5544,7 @@
"google",
"google_maps",
"groq",
"http",
"hubspot",
"ideogram",
"jina",

View File

@@ -0,0 +1,153 @@
import { NextRequest, NextResponse } from "next/server";
import {
makeAuthenticatedRequest,
makeAuthenticatedFileUpload,
} from "@/lib/autogpt-server-api/helpers";
const BACKEND_BASE_URL =
process.env.NEXT_PUBLIC_AGPT_SERVER_BASE_URL || "http://localhost:8006";
function buildBackendUrl(path: string[], queryString: string): string {
const backendPath = path.join("/");
return `${BACKEND_BASE_URL}/${backendPath}${queryString}`;
}
async function handleJsonRequest(
req: NextRequest,
method: string,
backendUrl: string,
): Promise<any> {
const payload = await req.json();
return await makeAuthenticatedRequest(
method,
backendUrl,
payload,
"application/json",
);
}
async function handleFormDataRequest(
req: NextRequest,
backendUrl: string,
): Promise<any> {
const formData = await req.formData();
return await makeAuthenticatedFileUpload(backendUrl, formData);
}
async function handleUrlEncodedRequest(
req: NextRequest,
method: string,
backendUrl: string,
): Promise<any> {
const textPayload = await req.text();
const params = new URLSearchParams(textPayload);
const payload = Object.fromEntries(params.entries());
return await makeAuthenticatedRequest(
method,
backendUrl,
payload,
"application/x-www-form-urlencoded",
);
}
async function handleRequestWithoutBody(
method: string,
backendUrl: string,
): Promise<any> {
return await makeAuthenticatedRequest(method, backendUrl);
}
function createUnsupportedContentTypeResponse(
contentType: string | null,
): NextResponse {
return NextResponse.json(
{
error:
"Unsupported Content-Type for proxying with authentication helpers.",
receivedContentType: contentType,
supportedContentTypes: [
"application/json",
"multipart/form-data",
"application/x-www-form-urlencoded",
],
},
{ status: 415 }, // Unsupported Media Type
);
}
function createResponse(
responseBody: any,
responseStatus: number,
responseHeaders: Record<string, string>,
): NextResponse {
if (responseStatus === 204) {
return new NextResponse(null, { status: responseStatus });
} else {
return NextResponse.json(responseBody, {
status: responseStatus,
headers: responseHeaders,
});
}
}
function createErrorResponse(error: unknown): NextResponse {
console.error("API proxy error:", error);
const detail =
error instanceof Error ? error.message : "An unknown error occurred";
return NextResponse.json(
{ error: "Proxy request failed", detail },
{ status: 500 }, // Internal Server Error
);
}
/**
* A simple proxy route that forwards requests to the backend API.
* It injects the server-side authentication token into the Authorization header.
* It uses the makeAuthenticatedRequest and makeAuthenticatedFileUpload helpers
* to handle request body parsing and authentication.
*/
async function handler(
req: NextRequest,
{ params }: { params: Promise<{ path: string[] }> },
) {
const { path } = await params;
const url = new URL(req.url);
const queryString = url.search;
const backendUrl = buildBackendUrl(path, queryString);
const method = req.method;
const contentType = req.headers.get("Content-Type");
let responseBody: any;
const responseStatus: number = 200;
const responseHeaders: Record<string, string> = {
"Content-Type": "application/json",
};
try {
if (method === "GET" || method === "DELETE") {
responseBody = await handleRequestWithoutBody(method, backendUrl);
} else if (contentType?.includes("application/json")) {
responseBody = await handleJsonRequest(req, method, backendUrl);
} else if (contentType?.includes("multipart/form-data")) {
responseBody = await handleFormDataRequest(req, backendUrl);
responseHeaders["Content-Type"] = "text/plain";
} else if (contentType?.includes("application/x-www-form-urlencoded")) {
responseBody = await handleUrlEncodedRequest(req, method, backendUrl);
} else {
return createUnsupportedContentTypeResponse(contentType);
}
return createResponse(responseBody, responseStatus, responseHeaders);
} catch (error) {
return createErrorResponse(error);
}
}
export {
handler as GET,
handler as POST,
handler as PUT,
handler as PATCH,
handler as DELETE,
};

View File

@@ -1,14 +0,0 @@
import { getServerSupabase } from "@/lib/supabase/server/getServerSupabase";
import { createBrowserClient } from "@supabase/ssr";
const isClient = typeof window !== "undefined";
export const getSupabaseClient = async () => {
return isClient
? createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{ isSingleton: true },
)
: await getServerSupabase();
};