import { TransportProtocolName } from '../../core.js'; import { fromRestErrorBody } from '../../errors/index.js'; import { SendMessageResult, A2A_PROTOCOL_VERSION, A2A_CONTENT_TYPE } from '../../index.js'; import { JSON_CONTENT_TYPE } from '../../constants.js'; import { RequestOptions } from '../multitransport-client.js'; import { parseSseStream } from '../../sse_utils.js'; import { Transport, TransportFactory } from './transport.js'; import { isLegacyVersion } from '../../version_utils.js'; import { pickMatchingInterface } from './pick_interface.js'; import { FromProto } from '../../types/converters/from_proto.js'; import { AgentCard, CancelTaskRequest, DeleteTaskPushNotificationConfigRequest, GetExtendedAgentCardRequest, GetTaskPushNotificationConfigRequest, GetTaskRequest, ListTaskPushNotificationConfigsRequest, ListTaskPushNotificationConfigsResponse, MessageFns, SendMessageRequest, SendMessageResponse, StreamResponse, Task, TaskPushNotificationConfig, SubscribeToTaskRequest, ListTasksRequest, ListTasksResponse, TaskState, taskStateToJSON, } from '../../types/pb/a2a.js'; import { LegacyRestTransport } from '../../compat/v0_3/client/index.js'; const PROTOCOL_NAME: TransportProtocolName = 'HTTP+JSON'; export interface RestTransportOptions { endpoint: string; fetchImpl?: typeof fetch; } interface RestErrorStatus { code?: number; status?: string; message?: string; details?: Array>; } export class RestTransport implements Transport { private readonly customFetchImpl?: typeof fetch; private readonly endpoint: string; constructor(options: RestTransportOptions) { this.endpoint = options.endpoint.replace(/\/+$/, ''); this.customFetchImpl = options.fetchImpl; } private _buildPath(path: string, tenant?: string): string { return tenant ? '/' + encodeURIComponent(tenant) + path : path; } get protocolName(): string { return PROTOCOL_NAME; } get protocolVersion(): string { return A2A_PROTOCOL_VERSION; } async getExtendedAgentCard( params: GetExtendedAgentCardRequest, options?: RequestOptions ): Promise { const path = this._buildPath('/extendedAgentCard', params.tenant); const response = await this._sendRequest( 'GET', path, undefined, options, undefined, AgentCard ); return response; } async sendMessage( params: SendMessageRequest, options?: RequestOptions ): Promise { const requestBody = params; const path = this._buildPath('/message:send', params.tenant); const response = await this._sendRequest( 'POST', path, requestBody, options, SendMessageRequest, SendMessageResponse ); return FromProto.sendMessageResult(response); } async *sendMessageStream( params: SendMessageRequest, options?: RequestOptions ): AsyncGenerator { const requestBody = SendMessageRequest.toJSON(params); const path = this._buildPath('/message:stream', params.tenant); yield* this._sendStreamingRequest(path, requestBody, options); } async createTaskPushNotificationConfig( params: TaskPushNotificationConfig, options?: RequestOptions ): Promise { const path = this._buildPath( `/tasks/${encodeURIComponent(params.taskId)}/pushNotificationConfigs`, params.tenant ); const response = await this._sendRequest< TaskPushNotificationConfig, TaskPushNotificationConfig >('POST', path, params, options, TaskPushNotificationConfig, TaskPushNotificationConfig); return response; } async getTaskPushNotificationConfig( params: GetTaskPushNotificationConfigRequest, options?: RequestOptions ): Promise { const path = this._buildPath( `/tasks/${encodeURIComponent(params.taskId)}/pushNotificationConfigs/${encodeURIComponent( params.id )}`, params.tenant ); const response = await this._sendRequest( 'GET', path, undefined, options, undefined, TaskPushNotificationConfig ); return response; } async listTaskPushNotificationConfig( params: ListTaskPushNotificationConfigsRequest, options?: RequestOptions ): Promise { const path = this._buildPath( `/tasks/${encodeURIComponent(params.taskId)}/pushNotificationConfigs`, params.tenant ); const response = await this._sendRequest( 'GET', path, undefined, options, undefined, ListTaskPushNotificationConfigsResponse ); return response; } async deleteTaskPushNotificationConfig( params: DeleteTaskPushNotificationConfigRequest, options?: RequestOptions ): Promise { const path = this._buildPath( `/tasks/${encodeURIComponent(params.taskId)}/pushNotificationConfigs/${encodeURIComponent( params.id )}`, params.tenant ); await this._sendRequest('DELETE', path, undefined, options, undefined, undefined); } async getTask(params: GetTaskRequest, options?: RequestOptions): Promise { const queryParams = new URLSearchParams(); if (params.historyLength !== undefined) { queryParams.set('historyLength', params.historyLength.toString()); } const queryString = queryParams.toString(); const path = this._buildPath( `/tasks/${encodeURIComponent(params.id)}${queryString ? `?${queryString}` : ''}`, params.tenant ); const response = await this._sendRequest( 'GET', path, undefined, options, undefined, Task ); return response; } async cancelTask(params: CancelTaskRequest, options?: RequestOptions): Promise { const path = this._buildPath(`/tasks/${encodeURIComponent(params.id)}:cancel`, params.tenant); const response = await this._sendRequest( 'POST', path, undefined, options, undefined, Task ); return response; } async listTasks(params: ListTasksRequest, options?: RequestOptions): Promise { const queryParams = new URLSearchParams(); if (params.contextId) queryParams.set('contextId', params.contextId); if (params.status !== undefined && params.status !== TaskState.TASK_STATE_UNSPECIFIED) { queryParams.set('status', taskStateToJSON(params.status)); } if (params.pageSize !== undefined) queryParams.set('pageSize', String(params.pageSize)); if (params.pageToken) queryParams.set('pageToken', params.pageToken); if (params.historyLength !== undefined) queryParams.set('historyLength', String(params.historyLength)); if (params.statusTimestampAfter) queryParams.set('statusTimestampAfter', params.statusTimestampAfter); if (params.includeArtifacts !== undefined) queryParams.set('includeArtifacts', String(params.includeArtifacts)); const queryString = queryParams.toString(); const path = this._buildPath(`/tasks${queryString ? `?${queryString}` : ''}`, params.tenant); const response = await this._sendRequest( 'GET', path, undefined, options, undefined, ListTasksResponse ); return response; } async *resubscribeTask( params: SubscribeToTaskRequest, options?: RequestOptions ): AsyncGenerator { const path = this._buildPath( `/tasks/${encodeURIComponent(params.id)}:subscribe`, params.tenant ); yield* this._sendStreamingRequest(path, undefined, options); } private _fetch(...args: Parameters): ReturnType { if (this.customFetchImpl) { return this.customFetchImpl(...args); } if (typeof fetch === 'function') { return fetch(...args); } throw new Error( 'A `fetch` implementation was not provided and is not available in the global scope. ' + 'Please provide a `fetchImpl` in the RestTransportOptions.' ); } private _buildHeaders( options: RequestOptions | undefined, acceptHeader: string = `${A2A_CONTENT_TYPE}, ${JSON_CONTENT_TYPE}` ): HeadersInit { return { ...options?.serviceParameters, 'Content-Type': JSON_CONTENT_TYPE, Accept: acceptHeader, }; } private async _sendRequest( method: 'GET' | 'POST' | 'DELETE', path: string, body: TRequest, options: RequestOptions | undefined, requestType: MessageFns | undefined, responseType: MessageFns | undefined ): Promise { const url = `${this.endpoint}${path}`; const requestInit: RequestInit = { method, headers: this._buildHeaders(options), signal: options?.signal, }; if (body !== undefined && method !== 'GET') { if (!requestType) { throw new Error( `Bug: Request body provided for ${method} ${path} but no toJson serializer provided.` ); } requestInit.body = JSON.stringify(requestType.toJSON(body)); } const response = await this._fetch(url, requestInit); if (!response.ok) { await this._handleErrorResponse(response, path); } if (response.status === 204 || !responseType) { return undefined as TResponse; } const result = await response.json(); return responseType.fromJSON(result); } private async _handleErrorResponse(response: Response, path: string): Promise { let errorBodyText = '(empty or non-JSON response)'; let errorStatus: RestErrorStatus | undefined; try { errorBodyText = await response.text(); if (errorBodyText) { const parsed = JSON.parse(errorBodyText); if (parsed?.error && typeof parsed.error === 'object') { errorStatus = parsed.error; } } } catch { // Body wasn't JSON — fall through to a REST-scoped A2AError. } const transportCtx = { statusCode: response.status, headers: RestTransport._collectHeaders(response), }; if (errorStatus) { throw fromRestErrorBody(errorStatus, transportCtx); } throw fromRestErrorBody( { message: `HTTP error for ${path}: ${response.status} ${response.statusText}` }, transportCtx ); } private static _collectHeaders(response: Response): Record { const out: Record = {}; response.headers.forEach((value, key) => { out[key] = value; }); return out; } private async *_sendStreamingRequest( path: string, body: unknown | undefined, options?: RequestOptions ): AsyncGenerator { const url = `${this.endpoint}${path}`; const requestInit: RequestInit = { method: 'POST', headers: this._buildHeaders(options, 'text/event-stream'), signal: options?.signal, }; if (body !== undefined) { requestInit.body = JSON.stringify(body); } const response = await this._fetch(url, requestInit); if (!response.ok) { await this._handleErrorResponse(response, path); } const contentType = response.headers.get('Content-Type'); if (!contentType?.startsWith('text/event-stream')) { throw new Error( `Invalid response Content-Type for SSE stream. Expected 'text/event-stream', got '${contentType}'.` ); } const sseTransportCtx = { statusCode: response.status, headers: RestTransport._collectHeaders(response), }; for await (const event of parseSseStream(response)) { if (event.type === 'error') { const errorData = JSON.parse(event.data) as { error?: RestErrorStatus }; if (errorData.error && typeof errorData.error === 'object') { throw fromRestErrorBody(errorData.error, sseTransportCtx); } throw new Error(`SSE error event: ${JSON.stringify(errorData)}`); } yield this._processSseEventData(event.data); } } private _processSseEventData(jsonData: string): StreamResponse { if (!jsonData.trim()) { throw new Error('Attempted to process empty SSE event data.'); } try { const response = JSON.parse(jsonData); return StreamResponse.fromJSON(response); } catch (e) { console.error('Failed to parse SSE event data:', jsonData, e); throw new Error( `Failed to parse SSE event data: "${jsonData.substring(0, 100)}...". Original error: ${(e instanceof Error && e.message) || 'Unknown error'}` ); } } } export class RestTransportFactoryOptions { fetchImpl?: typeof fetch; /** * Enables the v0.3 protocol compatibility layer. When enabled, the * factory inspects the matched `AgentInterface.protocolVersion`; if * it falls in `[0.3, 1.0)`, the v0.3 `LegacyRestTransport` is * instantiated instead of v1.0. * * Default: omitted (disabled). */ legacyCompat?: { enabled: boolean }; } /** * Factory producing an HTTP+JSON `Transport`. With * `legacyCompat: { enabled: true }` it dispatches between the v1.0 and * v0.3 transports based on `AgentInterface.protocolVersion`. */ export class RestTransportFactory implements TransportFactory { constructor(private readonly options?: RestTransportFactoryOptions) {} get protocolName(): string { return PROTOCOL_NAME; } async create(url: string, agentCard: AgentCard): Promise { if (this.options?.legacyCompat?.enabled) { const iface = pickMatchingInterface(agentCard, PROTOCOL_NAME, url); if (iface && isLegacyVersion(iface.protocolVersion)) { return new LegacyRestTransport({ endpoint: url, fetchImpl: this.options?.fetchImpl, }); } } return new RestTransport({ endpoint: url, fetchImpl: this.options?.fetchImpl, }); } }