UNPKG

mockserver-client

Version:

A node client for the MockServer

565 lines (512 loc) 22.4 kB
/* * mockserver * http://mock-server.com * * Copyright (c) 2014 James Bloom * Licensed under the Apache License, Version 2.0 */ /* * Node/TypeScript port of the Java client A2aMockBuilder * (org.mockserver.client.A2aMockBuilder). * * Builds the set of MockServer expectations needed to mock an A2A * (Agent-to-Agent) agent: a static agent-card document served over GET, and * JSON-RPC 2.0 task methods (tasks/send, tasks/get, tasks/cancel) over POST, * with optional SSE streaming and push-notification config + delivery. The * produced expectation JSON is wire identical to the Java builder so both * clients drive the same server behaviour. * * Usage: * * var a2aMock = require('mockserver-client').a2aMock; // or client.a2aMock * * a2aMock("/a2a") * .withAgentName("MyAgent") * .withSkill("translate") * .withName("Translation") * .withDescription("Translates text between languages") * .withTag("i18n") * .withExample("Translate hello to Spanish") * .and() * .withStreaming() * .onTaskSend() * .matchingMessage("translate.*") * .respondingWith("Hola") * .and() * .applyTo(client); // returns a promise (delegates to mockAnyResponse) * * Or, without a client, obtain the raw expectation array: * * var expectations = a2aMock("/a2a"). ... .build(); */ (function () { "use strict"; // ------------------------------------------------------------------ // Escaping helpers — ported 1:1 from the Java A2aMockBuilder so the // produced template/body strings are byte-identical. // ------------------------------------------------------------------ /** * JSON-escape a string for inlining inside a JSON string literal, * returning the contents WITHOUT the surrounding quotes. Mirrors Java's * OBJECT_MAPPER.writeValueAsString(value) then stripping the outer quotes. */ var escapeJson = function (value) { if (value === null || value === undefined) { return ""; } var quoted = JSON.stringify(String(value)); return quoted.substring(1, quoted.length - 1); }; /** * Escape Velocity meta-characters so literal '$' and '#' in mock content * are not interpreted as Velocity references/directives. Mirrors Java: * replace("$", "${esc.d}").replace("#", "${esc.h}"). */ var escapeVelocity = function (value) { if (value === null || value === undefined) { return value; } return String(value).split("$").join("${esc.d}").split("#").join("${esc.h}"); }; /** * Build the Velocity template string that renders a JSON-RPC 2.0 success * response wrapping the supplied result JSON. The id is echoed back from * the inbound request via the jsonRpcRawId binding. Mirrors Java's * velocityJsonRpcResponse. */ var velocityJsonRpcResponse = function (resultJson) { return "{\"statusCode\": 200, " + "\"headers\": [{\"name\": \"Content-Type\", \"values\": [\"application/json\"]}], " + "\"body\": {\"jsonrpc\": \"2.0\", \"result\": " + resultJson + ", \"id\": $!{request.jsonRpcRawId}}}"; }; // ------------------------------------------------------------------ // Expectation fragment helpers // ------------------------------------------------------------------ var jsonRpcRequest = function (path, method) { return { method: "POST", path: path, body: { type: "JSON_RPC", method: method } }; }; var jsonPathRequest = function (path, jsonPath) { return { method: "POST", path: path, body: { type: "JSON_PATH", jsonPath: jsonPath } }; }; var velocityTemplateResponseExpectation = function (httpRequest, resultJson) { return { httpRequest: httpRequest, httpResponseTemplate: { template: velocityJsonRpcResponse(resultJson), templateType: "VELOCITY" } }; }; /** * Parse a webhook URL into {host, port, secure, path} — mirrors Java's * WebhookTarget.parse. Falls back to 80/443 by scheme when no explicit * port is given, and to "/" when the URL carries no path. */ var parseWebhookTarget = function (webhookUrl) { // Node's URL is available in modern Node and in browsers; use it when // present, otherwise fall back to a tolerant regex parse so the builder // stays pure and dependency-free. var host, port, secure, path; try { var parsed = new URL(webhookUrl); secure = parsed.protocol === "https:"; host = parsed.hostname; port = parsed.port ? parseInt(parsed.port, 10) : (secure ? 443 : 80); path = parsed.pathname && parsed.pathname.length > 0 ? parsed.pathname : "/"; } catch (e) { var m = /^(https?):\/\/([^/:]+)(?::(\d+))?(\/[^?#]*)?/i.exec(webhookUrl || ""); if (!m || !m[2]) { throw new Error("Invalid push-notification webhook URL (no host): " + webhookUrl); } secure = m[1].toLowerCase() === "https"; host = m[2]; port = m[3] ? parseInt(m[3], 10) : (secure ? 443 : 80); path = m[4] && m[4].length > 0 ? m[4] : "/"; } if (!host) { throw new Error("Invalid push-notification webhook URL (no host): " + webhookUrl); } return {host: host, port: port, secure: secure, path: path}; }; // ------------------------------------------------------------------ // Builder // ------------------------------------------------------------------ var a2aMock = function (path) { var state = { path: typeof path === "string" ? path : "/a2a", agentCardPath: "/.well-known/agent.json", agentName: "MockAgent", agentDescription: "A mock A2A agent", agentVersion: "1.0.0", agentUrl: null, defaultTaskResponse: "Task completed successfully", streaming: false, streamingMethod: "message/stream", pushNotificationUrl: null, skills: [], taskHandlers: [] }; // ---- result-JSON helpers (mirror Java taskResultJson / buildTaskResultJson) ---- var taskResultJson = function (escapedText, isError) { var taskState = isError ? "failed" : "completed"; return "{\"id\": \"mock-task-id\", " + "\"status\": {\"state\": \"" + taskState + "\"}, " + "\"artifacts\": [{\"parts\": [{\"type\": \"text\", \"text\": \"" + escapedText + "\"}]}]}"; }; // For Velocity-templated response bodies: the text must survive the // Velocity engine, so metacharacters are escaped here and un-escaped by // the engine at response time. var buildTaskResultJson = function (responseText, isError) { return taskResultJson(escapeVelocity(escapeJson(responseText)), isError); }; // For literal (non-templated) bodies (the webhook POST payload), where // no Velocity engine runs, only JSON escaping is applied — Velocity // escaping would corrupt any '$' / '#'. var buildTaskResultJsonRaw = function (responseText, isError) { return taskResultJson(escapeJson(responseText), isError); }; // ---- agent card ---- var buildAgentCardExpectation = function () { var skillsJson = "["; for (var i = 0; i < state.skills.length; i++) { if (i > 0) { skillsJson += ", "; } var skill = state.skills[i]; skillsJson += "{"; skillsJson += "\"id\": \"" + escapeJson(skill.id) + "\""; skillsJson += ", \"name\": \"" + escapeJson(skill.name !== null && skill.name !== undefined ? skill.name : skill.id) + "\""; if (skill.description !== null && skill.description !== undefined) { skillsJson += ", \"description\": \"" + escapeJson(skill.description) + "\""; } if (skill.tags.length > 0) { skillsJson += ", \"tags\": ["; for (var t = 0; t < skill.tags.length; t++) { if (t > 0) { skillsJson += ", "; } skillsJson += "\"" + escapeJson(skill.tags[t]) + "\""; } skillsJson += "]"; } if (skill.examples.length > 0) { skillsJson += ", \"examples\": ["; for (var x = 0; x < skill.examples.length; x++) { if (x > 0) { skillsJson += ", "; } skillsJson += "\"" + escapeJson(skill.examples[x]) + "\""; } skillsJson += "]"; } skillsJson += "}"; } skillsJson += "]"; var url = (state.agentUrl !== null && state.agentUrl !== undefined) ? state.agentUrl : "http://localhost" + state.path; var agentCardJson = "{" + "\"name\": \"" + escapeJson(state.agentName) + "\", " + "\"description\": \"" + escapeJson(state.agentDescription) + "\", " + "\"version\": \"" + escapeJson(state.agentVersion) + "\", " + "\"url\": \"" + escapeJson(url) + "\", " + "\"capabilities\": {\"streaming\": " + (state.streaming ? "true" : "false") + ", \"pushNotifications\": " + (state.pushNotificationUrl !== null ? "true" : "false") + ", \"stateTransitionHistory\": false}, " + "\"skills\": " + skillsJson + "}"; return { httpRequest: { method: "GET", path: state.agentCardPath }, httpResponse: { statusCode: 200, headers: [{name: "Content-Type", values: ["application/json"]}], body: agentCardJson } }; }; // ---- task methods ---- var buildTasksSendExpectation = function () { var resultJson = buildTaskResultJson(state.defaultTaskResponse, false); return velocityTemplateResponseExpectation(jsonRpcRequest(state.path, "tasks/send"), resultJson); }; var buildTasksGetExpectation = function () { var resultJson = buildTaskResultJson(state.defaultTaskResponse, false); return velocityTemplateResponseExpectation(jsonRpcRequest(state.path, "tasks/get"), resultJson); }; var buildTasksCancelExpectation = function () { var resultJson = "{\"id\": \"mock-task-id\", \"status\": {\"state\": \"canceled\"}}"; return velocityTemplateResponseExpectation(jsonRpcRequest(state.path, "tasks/cancel"), resultJson); }; // Escapes a user-supplied regular expression so it can be embedded // between the "/.../" delimiters of the JSON_PATH regex literal without // breaking out of it. Single left-to-right pass that PRESERVES existing // regex escape sequences (e.g. "\d", "\/") and only neutralizes the // delimiter ("/" -> "\/"), control characters (newline/CR), and a // trailing lone backslash (which would otherwise escape the closing // "/" delimiter). NUL is stripped. var escapeMessagePattern = function (pattern) { var out = "", i = 0, n = pattern.length; while (i < n) { var c = pattern.charAt(i); if (c === "\\") { if (i + 1 < n) { out += "\\" + pattern.charAt(i + 1); i += 2; continue; } out += "\\\\"; // trailing lone backslash -> literal backslash } else if (c === "/") { out += "\\/"; } else if (c === "\n") { out += "\\n"; } else if (c === "\r") { out += "\\r"; } else if (c === "\0") { // strip NUL } else { out += c; } i++; } return out; }; var buildCustomTaskHandler = function (handler) { var escapedPattern = escapeMessagePattern(handler.messagePattern); var jsonPath = "$[?(@.method == 'tasks/send' && @.params.message.parts[0].text =~ /" + escapedPattern + "/)]"; var resultJson = buildTaskResultJson(handler.responseText, handler.isError); return velocityTemplateResponseExpectation(jsonPathRequest(state.path, jsonPath), resultJson); }; // ---- streaming ---- var buildStreamingExpectation = function () { var text = escapeJson(state.defaultTaskResponse); var taskId = "mock-task-id"; // A2A streaming: each SSE event data is a JSON-RPC 2.0 response // envelope wrapping a TaskStatusUpdateEvent or // TaskArtifactUpdateEvent. The JSON-RPC id is not known at build // time, so a stable placeholder is used. var statusWorking = { event: "message", data: "{\"jsonrpc\": \"2.0\", \"id\": \"1\", \"result\": " + "{\"taskId\": \"" + taskId + "\", \"kind\": \"status-update\", " + "\"status\": {\"state\": \"working\"}, \"final\": false}}" }; var artifactUpdate = { event: "message", data: "{\"jsonrpc\": \"2.0\", \"id\": \"1\", \"result\": " + "{\"taskId\": \"" + taskId + "\", \"kind\": \"artifact-update\", " + "\"artifact\": {\"parts\": [{\"type\": \"text\", \"text\": \"" + text + "\"}]}}}" }; var statusCompleted = { event: "message", data: "{\"jsonrpc\": \"2.0\", \"id\": \"1\", \"result\": " + "{\"taskId\": \"" + taskId + "\", \"kind\": \"status-update\", " + "\"status\": {\"state\": \"completed\"}, \"final\": true}}" }; return { httpRequest: jsonRpcRequest(state.path, state.streamingMethod), httpSseResponse: { statusCode: 200, events: [statusWorking, artifactUpdate, statusCompleted], closeConnection: true } }; }; // ---- push notifications ---- var buildPushNotificationConfigExpectation = function () { // Echo the registered push-notification config back as the JSON-RPC result. var resultJson = "{\"url\": \"" + escapeVelocity(escapeJson(state.pushNotificationUrl)) + "\"}"; return velocityTemplateResponseExpectation( jsonRpcRequest(state.path, "tasks/pushNotificationConfig/set"), resultJson ); }; var buildPushNotificationDeliveryExpectation = function () { // When push notifications are configured, a tasks/send both returns // the JSON-RPC task response to the caller AND POSTs the completed // task to the configured webhook URL. Modelled with an // override-forwarded-request: the request override targets the // webhook (literal body), and a Velocity response template produces // the caller's JSON-RPC response so the request's id is echoed back. var target = parseWebhookTarget(state.pushNotificationUrl); // Literal webhook POST body — no Velocity engine runs over a request // override, so only JSON escaping is applied. var pushBody = "{\"jsonrpc\": \"2.0\", \"result\": " + buildTaskResultJsonRaw(state.defaultTaskResponse, false) + "}"; var webhookRequest = { method: "POST", path: target.path, socketAddress: { host: target.host, port: target.port, scheme: target.secure ? "HTTPS" : "HTTP" }, secure: target.secure, headers: [ {name: "Host", values: [target.host + ":" + target.port]}, {name: "Content-Type", values: ["application/json"]} ], body: pushBody }; return { httpRequest: jsonRpcRequest(state.path, "tasks/send"), httpOverrideForwardedRequest: { requestOverride: webhookRequest, responseTemplate: { template: velocityJsonRpcResponse(buildTaskResultJson(state.defaultTaskResponse, false)), templateType: "VELOCITY" } } }; }; // ---- assembly (mirrors Java A2aMockBuilder.build ordering) ---- var build = function () { var expectations = []; expectations.push(buildAgentCardExpectation()); for (var h = 0; h < state.taskHandlers.length; h++) { expectations.push(buildCustomTaskHandler(state.taskHandlers[h])); } if (state.streaming) { expectations.push(buildStreamingExpectation()); } if (state.pushNotificationUrl !== null && state.pushNotificationUrl !== undefined) { expectations.push(buildPushNotificationConfigExpectation()); expectations.push(buildPushNotificationDeliveryExpectation()); } else { expectations.push(buildTasksSendExpectation()); } expectations.push(buildTasksGetExpectation()); expectations.push(buildTasksCancelExpectation()); return expectations; }; // ---- nested fluent builders ---- var rootApi; var skillBuilder = function (id) { var skill = { id: id, name: null, description: null, tags: [], examples: [] }; var skillApi = { withName: function (name) { skill.name = name; return skillApi; }, withDescription: function (description) { skill.description = description; return skillApi; }, withTag: function (tag) { skill.tags.push(tag); return skillApi; }, withExample: function (example) { skill.examples.push(example); return skillApi; }, and: function () { state.skills.push(skill); return rootApi; } }; return skillApi; }; var taskHandlerBuilder = function () { var handler = { messagePattern: ".*", responseText: "Task completed", isError: false }; var handlerApi = { matchingMessage: function (pattern) { handler.messagePattern = pattern; return handlerApi; }, respondingWith: function (text, isError) { handler.responseText = text; if (isError !== undefined) { handler.isError = !!isError; } return handlerApi; }, and: function () { state.taskHandlers.push(handler); return rootApi; } }; return handlerApi; }; rootApi = { withAgentName: function (name) { state.agentName = name; return rootApi; }, withAgentDescription: function (description) { state.agentDescription = description; return rootApi; }, withAgentVersion: function (version) { state.agentVersion = version; return rootApi; }, withAgentUrl: function (url) { state.agentUrl = url; return rootApi; }, withAgentCardPath: function (cardPath) { state.agentCardPath = cardPath; return rootApi; }, withDefaultTaskResponse: function (response) { state.defaultTaskResponse = response; return rootApi; }, withStreaming: function () { state.streaming = true; return rootApi; }, withStreamingMethod: function (method) { state.streamingMethod = method; state.streaming = true; return rootApi; }, withPushNotifications: function (webhookUrl) { state.pushNotificationUrl = webhookUrl; return rootApi; }, withSkill: function (id) { return skillBuilder(id); }, onTaskSend: function () { return taskHandlerBuilder(); }, build: build, applyTo: function (client) { return client.mockAnyResponse(build()); } }; return rootApi; }; if (typeof module !== 'undefined') { module.exports = { a2aMock: a2aMock }; } })();