@verifik/mcp 0.0.0-stage → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +543 -2
- package/data/field-enrichment.json +452 -0
- package/docs/field-formats.md +148 -0
- package/lib/catalog.js +183 -0
- package/lib/client-features.js +64 -0
- package/lib/config.js +75 -0
- package/lib/description.js +67 -0
- package/lib/error-response.js +210 -0
- package/lib/field-enrichment.js +103 -0
- package/lib/field-normalize.js +65 -0
- package/lib/filters.js +53 -0
- package/lib/meta-tools.js +128 -0
- package/lib/proxy.js +224 -0
- package/lib/tool-naming.js +51 -0
- package/lib/tool-schema.js +245 -0
- package/lib/url-builder.js +58 -0
- package/lib/validate-args.js +230 -0
- package/package.json +60 -5
- package/server.js +140 -0
package/lib/catalog.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { filterFeatures } = require("./filters");
|
|
4
|
+
const { buildToolRegistry, toMcpToolDefinition } = require("./tool-schema");
|
|
5
|
+
const { fetchGrantedFeatureCodes } = require("./client-features");
|
|
6
|
+
|
|
7
|
+
const CATALOG_PATH = "/v2/app-features/my-list";
|
|
8
|
+
const PAGE_LIMIT = 500;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {import("./types").McpConfig} config
|
|
12
|
+
*/
|
|
13
|
+
const fetchCatalogPage = async (config, page = 1) => {
|
|
14
|
+
const url = new URL(`${config.apiBase}${CATALOG_PATH}`);
|
|
15
|
+
url.searchParams.set("page", String(page));
|
|
16
|
+
url.searchParams.set("limit", String(PAGE_LIMIT));
|
|
17
|
+
|
|
18
|
+
const response = await fetch(url, {
|
|
19
|
+
method: "GET",
|
|
20
|
+
headers: {
|
|
21
|
+
Authorization: `Bearer ${config.token}`,
|
|
22
|
+
Accept: "application/json",
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const body = await readResponseBody(response);
|
|
27
|
+
|
|
28
|
+
if (!response.ok) {
|
|
29
|
+
throw new Error(`Catalog request failed (${response.status}): ${formatBodyForError(body)}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return normalizeCatalogResponse(body);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {unknown} body
|
|
37
|
+
*/
|
|
38
|
+
const normalizeCatalogResponse = (body) => {
|
|
39
|
+
const payload = body && typeof body === "object" ? body : {};
|
|
40
|
+
const data = Array.isArray(payload.data)
|
|
41
|
+
? payload.data
|
|
42
|
+
: Array.isArray(payload.data?.docs)
|
|
43
|
+
? payload.data.docs
|
|
44
|
+
: [];
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
features: data,
|
|
48
|
+
page: Number(payload.page) || 1,
|
|
49
|
+
pages: Number(payload.pages) || 1,
|
|
50
|
+
total: Number(payload.total) || data.length,
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {import("./types").McpConfig} config
|
|
56
|
+
*/
|
|
57
|
+
const fetchAllCatalogFeatures = async (config) => {
|
|
58
|
+
const firstPage = await fetchCatalogPage(config, 1);
|
|
59
|
+
const features = [...firstPage.features];
|
|
60
|
+
|
|
61
|
+
for (let page = firstPage.page + 1; page <= firstPage.pages; page += 1) {
|
|
62
|
+
const nextPage = await fetchCatalogPage(config, page);
|
|
63
|
+
features.push(...nextPage.features);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return features;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @param {import("./types").McpConfig} config
|
|
71
|
+
* @returns {Promise<Set<string>|null>}
|
|
72
|
+
*/
|
|
73
|
+
const loadGrantedFeatureCodes = async (config) => {
|
|
74
|
+
try {
|
|
75
|
+
return await fetchGrantedFeatureCodes(config);
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @param {import("./types").McpConfig} config
|
|
83
|
+
*/
|
|
84
|
+
const loadCatalogState = async (config) => {
|
|
85
|
+
const [allFeatures, grantedFeatureCodes] = await Promise.all([
|
|
86
|
+
fetchAllCatalogFeatures(config),
|
|
87
|
+
loadGrantedFeatureCodes(config),
|
|
88
|
+
]);
|
|
89
|
+
const filteredFeatures = filterFeatures(allFeatures, config, grantedFeatureCodes);
|
|
90
|
+
const registry = buildToolRegistry(filteredFeatures);
|
|
91
|
+
|
|
92
|
+
const tools = [];
|
|
93
|
+
|
|
94
|
+
for (const [toolName, feature] of registry.toolNameToFeature.entries()) {
|
|
95
|
+
tools.push(toMcpToolDefinition(feature, toolName));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
features: filteredFeatures,
|
|
100
|
+
allFeatures,
|
|
101
|
+
tools,
|
|
102
|
+
registry,
|
|
103
|
+
grantedFeatureCodes,
|
|
104
|
+
loadedAt: new Date().toISOString(),
|
|
105
|
+
};
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* @param {Response} response
|
|
110
|
+
*/
|
|
111
|
+
const readResponseBody = async (response) => {
|
|
112
|
+
const contentType = response.headers.get("content-type") || "";
|
|
113
|
+
|
|
114
|
+
if (contentType.includes("application/json")) {
|
|
115
|
+
return response.json();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return response.text();
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* @param {unknown} body
|
|
123
|
+
*/
|
|
124
|
+
const formatBodyForError = (body) => {
|
|
125
|
+
if (typeof body === "string") return body.slice(0, 500);
|
|
126
|
+
try {
|
|
127
|
+
return JSON.stringify(body).slice(0, 500);
|
|
128
|
+
} catch {
|
|
129
|
+
return "unknown_error";
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
class CatalogCache {
|
|
134
|
+
/**
|
|
135
|
+
* @param {import("./types").McpConfig} config
|
|
136
|
+
*/
|
|
137
|
+
constructor(config) {
|
|
138
|
+
this.config = config;
|
|
139
|
+
this.state = null;
|
|
140
|
+
this.loadingPromise = null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async getState(forceRefresh = false) {
|
|
144
|
+
const shouldRefresh =
|
|
145
|
+
forceRefresh ||
|
|
146
|
+
!this.state ||
|
|
147
|
+
(this.config.catalogRefreshMs > 0 &&
|
|
148
|
+
Date.now() - Date.parse(this.state.loadedAt) >= this.config.catalogRefreshMs);
|
|
149
|
+
|
|
150
|
+
if (!shouldRefresh) return this.state;
|
|
151
|
+
if (this.loadingPromise) return this.loadingPromise;
|
|
152
|
+
|
|
153
|
+
this.loadingPromise = loadCatalogState(this.config)
|
|
154
|
+
.then((state) => {
|
|
155
|
+
this.state = state;
|
|
156
|
+
return state;
|
|
157
|
+
})
|
|
158
|
+
.finally(() => {
|
|
159
|
+
this.loadingPromise = null;
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
return this.loadingPromise;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
findFeatureByCode(code) {
|
|
166
|
+
if (!this.state) return null;
|
|
167
|
+
return this.state.features.find((feature) => feature.code === code) || null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
resolveFeatureByToolName(toolName) {
|
|
171
|
+
if (!this.state) return null;
|
|
172
|
+
return this.state.registry.toolNameToFeature.get(toolName) || null;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
module.exports = {
|
|
177
|
+
CATALOG_PATH,
|
|
178
|
+
fetchCatalogPage,
|
|
179
|
+
fetchAllCatalogFeatures,
|
|
180
|
+
loadCatalogState,
|
|
181
|
+
CatalogCache,
|
|
182
|
+
normalizeCatalogResponse,
|
|
183
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const CLIENT_FEATURES_PATH = "/v2/client-features";
|
|
4
|
+
const PAGE_LIMIT = 500;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {import("./types").McpConfig} config
|
|
8
|
+
*/
|
|
9
|
+
const fetchClientFeaturesPage = async (config, page = 1) => {
|
|
10
|
+
const url = new URL(`${config.apiBase}${CLIENT_FEATURES_PATH}`);
|
|
11
|
+
url.searchParams.set("page", String(page));
|
|
12
|
+
url.searchParams.set("limit", String(PAGE_LIMIT));
|
|
13
|
+
|
|
14
|
+
const response = await fetch(url, {
|
|
15
|
+
method: "GET",
|
|
16
|
+
headers: {
|
|
17
|
+
Authorization: `Bearer ${config.token}`,
|
|
18
|
+
Accept: "application/json",
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
if (!response.ok) {
|
|
23
|
+
throw new Error(`Client features request failed (${response.status})`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const body = await response.json();
|
|
27
|
+
const data = Array.isArray(body?.data)
|
|
28
|
+
? body.data
|
|
29
|
+
: Array.isArray(body?.data?.docs)
|
|
30
|
+
? body.data.docs
|
|
31
|
+
: [];
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
features: data,
|
|
35
|
+
page: Number(body?.page) || 1,
|
|
36
|
+
pages: Number(body?.pages) || 1,
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param {import("./types").McpConfig} config
|
|
42
|
+
*/
|
|
43
|
+
const fetchGrantedFeatureCodes = async (config) => {
|
|
44
|
+
const firstPage = await fetchClientFeaturesPage(config, 1);
|
|
45
|
+
const codes = new Set();
|
|
46
|
+
|
|
47
|
+
for (const feature of firstPage.features) {
|
|
48
|
+
if (feature?.code) codes.add(feature.code);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
for (let page = firstPage.page + 1; page <= firstPage.pages; page += 1) {
|
|
52
|
+
const nextPage = await fetchClientFeaturesPage(config, page);
|
|
53
|
+
for (const feature of nextPage.features) {
|
|
54
|
+
if (feature?.code) codes.add(feature.code);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return codes;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
module.exports = {
|
|
62
|
+
CLIENT_FEATURES_PATH,
|
|
63
|
+
fetchGrantedFeatureCodes,
|
|
64
|
+
};
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_API_BASE = "https://api.verifik.co";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @returns {import("./types").McpConfig}
|
|
7
|
+
*/
|
|
8
|
+
const loadConfig = () => {
|
|
9
|
+
const apiBase = String(process.env.VERIFIK_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, "");
|
|
10
|
+
const token = String(process.env.VERIFIK_API_TOKEN || "").trim();
|
|
11
|
+
|
|
12
|
+
if (!token) {
|
|
13
|
+
throw new Error("VERIFIK_API_TOKEN is required (client JWT / API bearer token)");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const smartCheckOnly = parseBooleanEnv(process.env.VERIFIK_MCP_SMARTCHECK_ONLY, true);
|
|
17
|
+
const countryFilter = parseListEnv(process.env.VERIFIK_MCP_COUNTRY);
|
|
18
|
+
const baseCategoryFilter = parseListEnv(process.env.VERIFIK_MCP_BASE_CATEGORY);
|
|
19
|
+
const codeAllowlist = parseListEnv(process.env.VERIFIK_MCP_CODES);
|
|
20
|
+
const catalogRefreshMs = parsePositiveInt(process.env.VERIFIK_MCP_CATALOG_REFRESH_MS, 0);
|
|
21
|
+
const requestTimeoutMs = parsePositiveInt(process.env.VERIFIK_MCP_REQUEST_TIMEOUT_MS, 90_000);
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
apiBase,
|
|
25
|
+
token,
|
|
26
|
+
smartCheckOnly,
|
|
27
|
+
countryFilter,
|
|
28
|
+
baseCategoryFilter,
|
|
29
|
+
codeAllowlist,
|
|
30
|
+
catalogRefreshMs,
|
|
31
|
+
requestTimeoutMs,
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {string|undefined} value
|
|
37
|
+
* @param {boolean} defaultValue
|
|
38
|
+
*/
|
|
39
|
+
const parseBooleanEnv = (value, defaultValue) => {
|
|
40
|
+
if (value == null || value === "") return defaultValue;
|
|
41
|
+
const normalized = String(value).trim().toLowerCase();
|
|
42
|
+
if (["1", "true", "yes", "on"].includes(normalized)) return true;
|
|
43
|
+
if (["0", "false", "no", "off"].includes(normalized)) return false;
|
|
44
|
+
return defaultValue;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {string|undefined} value
|
|
49
|
+
* @returns {string[]}
|
|
50
|
+
*/
|
|
51
|
+
const parseListEnv = (value) => {
|
|
52
|
+
if (!value || !String(value).trim()) return [];
|
|
53
|
+
return String(value)
|
|
54
|
+
.split(",")
|
|
55
|
+
.map((entry) => entry.trim())
|
|
56
|
+
.filter(Boolean);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {string|undefined} value
|
|
61
|
+
* @param {number} defaultValue
|
|
62
|
+
*/
|
|
63
|
+
const parsePositiveInt = (value, defaultValue) => {
|
|
64
|
+
if (value == null || value === "") return defaultValue;
|
|
65
|
+
const parsed = Number.parseInt(String(value), 10);
|
|
66
|
+
if (!Number.isFinite(parsed) || parsed < 0) return defaultValue;
|
|
67
|
+
return parsed;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
module.exports = {
|
|
71
|
+
DEFAULT_API_BASE,
|
|
72
|
+
loadConfig,
|
|
73
|
+
parseBooleanEnv,
|
|
74
|
+
parseListEnv,
|
|
75
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const ROUTE_PATTERN = /\b(?:GET|POST|PUT|PATCH|DELETE)\s+\/v?\d*\/[^\s.]+\b/gi;
|
|
4
|
+
const ALIAS_PATTERN = /\bAlias:\s*[^\n.]+\.?/gi;
|
|
5
|
+
const INTERNAL_JARGON = /\bNo name sources\.?\b/gi;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @param {string} text
|
|
9
|
+
*/
|
|
10
|
+
const stripCountryPrefix = (text) => {
|
|
11
|
+
return String(text || "")
|
|
12
|
+
.replace(/^[A-Za-zÀ-ÿ\s]+?\s*[-—]\s*/u, "")
|
|
13
|
+
.trim();
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {string} text
|
|
18
|
+
*/
|
|
19
|
+
const stripParentheticalCountry = (text) => {
|
|
20
|
+
return String(text || "").replace(/\s*\([A-Za-zÀ-ÿ\s]+\)\s*/gu, " ").trim();
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {string} text
|
|
25
|
+
*/
|
|
26
|
+
const cleanDescriptionText = (text) => {
|
|
27
|
+
return String(text || "")
|
|
28
|
+
.replace(ROUTE_PATTERN, "")
|
|
29
|
+
.replace(ALIAS_PATTERN, "")
|
|
30
|
+
.replace(INTERNAL_JARGON, "")
|
|
31
|
+
.replace(/\s+/g, " ")
|
|
32
|
+
.trim();
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {number | null | undefined} price
|
|
37
|
+
*/
|
|
38
|
+
const formatPrice = (price) => {
|
|
39
|
+
const value = Number(price);
|
|
40
|
+
if (!Number.isFinite(value)) return null;
|
|
41
|
+
return value.toFixed(value % 1 === 0 ? 0 : 2);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {import("./types").AppFeature} feature
|
|
46
|
+
*/
|
|
47
|
+
const buildToolDescription = (feature) => {
|
|
48
|
+
const rawDescription = cleanDescriptionText(feature.description || "");
|
|
49
|
+
const rawName = cleanDescriptionText(feature.name || feature.code || "Verifik feature");
|
|
50
|
+
const withoutCountryInName = stripParentheticalCountry(stripCountryPrefix(rawName));
|
|
51
|
+
const descriptionBody = rawDescription || withoutCountryInName;
|
|
52
|
+
const price = formatPrice(feature.smartCheckPrice ?? feature.price);
|
|
53
|
+
|
|
54
|
+
const parts = [descriptionBody];
|
|
55
|
+
|
|
56
|
+
if (price != null) {
|
|
57
|
+
parts.push(`Costs ${price} credit${price === "1" ? "" : "s"} per successful call.`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return parts.join(" ");
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
module.exports = {
|
|
64
|
+
cleanDescriptionText,
|
|
65
|
+
buildToolDescription,
|
|
66
|
+
formatPrice,
|
|
67
|
+
};
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const SERVICE_UNAVAILABLE_MESSAGE =
|
|
4
|
+
"The data source is temporarily unavailable; try again later.";
|
|
5
|
+
|
|
6
|
+
const FEATURE_NOT_ENABLED_MESSAGE =
|
|
7
|
+
"This feature is not enabled on your Verifik account. Contact support to request access.";
|
|
8
|
+
|
|
9
|
+
const FEATURE_UNAVAILABLE_MESSAGE =
|
|
10
|
+
"This feature is currently unavailable; contact support.";
|
|
11
|
+
|
|
12
|
+
const NOT_FOUND_MESSAGE = "No record was found for the provided data.";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {unknown} body
|
|
16
|
+
*/
|
|
17
|
+
const isHtmlBody = (body) => {
|
|
18
|
+
if (typeof body !== "string") return false;
|
|
19
|
+
const trimmed = body.trim().toLowerCase();
|
|
20
|
+
return trimmed.startsWith("<!doctype") || trimmed.startsWith("<html");
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {unknown} body
|
|
25
|
+
*/
|
|
26
|
+
const isVerifikJsonBody = (body) => {
|
|
27
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return false;
|
|
28
|
+
return typeof body.code === "string" || typeof body.message === "string";
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Verifik API error envelope (code + message, often with id).
|
|
33
|
+
* @param {unknown} body
|
|
34
|
+
*/
|
|
35
|
+
const isVerifikBusinessErrorBody = (body) => {
|
|
36
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return false;
|
|
37
|
+
return typeof body.code === "string" && typeof body.message === "string";
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param {unknown} code
|
|
42
|
+
* @param {unknown} message
|
|
43
|
+
*/
|
|
44
|
+
const isEndpointOutOfService = (code, message) => {
|
|
45
|
+
const haystack = `${String(code || "")} ${String(message || "")}`.toLowerCase();
|
|
46
|
+
return haystack.includes("endpoint_out_of_service") || haystack.includes("endpoint out of service");
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {unknown} body
|
|
51
|
+
*/
|
|
52
|
+
const extractRequestId = (body) => {
|
|
53
|
+
if (!body || typeof body !== "object") return undefined;
|
|
54
|
+
if (typeof body.id === "string" && body.id) return body.id;
|
|
55
|
+
return undefined;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @param {unknown} body
|
|
60
|
+
*/
|
|
61
|
+
const extractCreditsCharged = (body) => {
|
|
62
|
+
if (!body || typeof body !== "object") return null;
|
|
63
|
+
|
|
64
|
+
const creditsCharged = body.creditsCharged;
|
|
65
|
+
|
|
66
|
+
if (typeof creditsCharged === "number" && Number.isFinite(creditsCharged)) {
|
|
67
|
+
return creditsCharged;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return null;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @param {unknown} message
|
|
75
|
+
*/
|
|
76
|
+
const isUpstreamGatewayMessage = (message) => {
|
|
77
|
+
if (typeof message !== "string") return false;
|
|
78
|
+
return /request failed with status code 50[234]/i.test(message) || /gateway timeout/i.test(message);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @param {number} status
|
|
83
|
+
* @param {unknown} body
|
|
84
|
+
*/
|
|
85
|
+
const classifyUpstreamError = (status, body) => {
|
|
86
|
+
const isJsonObject = isVerifikJsonBody(body);
|
|
87
|
+
const errorBody = isJsonObject ? body : {};
|
|
88
|
+
const apiCode = typeof errorBody.code === "string" ? errorBody.code : "";
|
|
89
|
+
const apiMessage = typeof errorBody.message === "string" ? errorBody.message : "";
|
|
90
|
+
const plainText = typeof body === "string" ? body.trim() : "";
|
|
91
|
+
|
|
92
|
+
if (status === 403 && apiMessage.toLowerCase().includes("no access to this feature")) {
|
|
93
|
+
return {
|
|
94
|
+
code: "FeatureNotEnabled",
|
|
95
|
+
message: FEATURE_NOT_ENABLED_MESSAGE,
|
|
96
|
+
retryable: false,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (
|
|
101
|
+
isEndpointOutOfService(apiCode, apiMessage) ||
|
|
102
|
+
isEndpointOutOfService("", plainText)
|
|
103
|
+
) {
|
|
104
|
+
return {
|
|
105
|
+
code: "ServiceUnavailable",
|
|
106
|
+
message: SERVICE_UNAVAILABLE_MESSAGE,
|
|
107
|
+
retryable: true,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (
|
|
112
|
+
status === 502 ||
|
|
113
|
+
status === 503 ||
|
|
114
|
+
status === 504 ||
|
|
115
|
+
status >= 500 ||
|
|
116
|
+
isUpstreamGatewayMessage(apiMessage) ||
|
|
117
|
+
isUpstreamGatewayMessage(plainText) ||
|
|
118
|
+
isHtmlBody(body)
|
|
119
|
+
) {
|
|
120
|
+
return {
|
|
121
|
+
code: "ServiceUnavailable",
|
|
122
|
+
message: SERVICE_UNAVAILABLE_MESSAGE,
|
|
123
|
+
retryable: true,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (status === 404) {
|
|
128
|
+
if (isVerifikBusinessErrorBody(body)) {
|
|
129
|
+
return {
|
|
130
|
+
code: "NotFound",
|
|
131
|
+
message: NOT_FOUND_MESSAGE,
|
|
132
|
+
retryable: false,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
code: "FeatureUnavailable",
|
|
138
|
+
message: FEATURE_UNAVAILABLE_MESSAGE,
|
|
139
|
+
retryable: false,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (apiCode || apiMessage) {
|
|
144
|
+
return {
|
|
145
|
+
code: apiCode || "RequestFailed",
|
|
146
|
+
message: apiMessage || "Request failed",
|
|
147
|
+
retryable: false,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (plainText) {
|
|
152
|
+
return {
|
|
153
|
+
code: "RequestFailed",
|
|
154
|
+
message: plainText,
|
|
155
|
+
retryable: false,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
code: "RequestFailed",
|
|
161
|
+
message: "Request failed",
|
|
162
|
+
retryable: false,
|
|
163
|
+
};
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* @param {{ status: number, statusText?: string, body: unknown, durationMs?: number }} result
|
|
168
|
+
*/
|
|
169
|
+
const buildProxyErrorPayload = (result) => {
|
|
170
|
+
const requestId = extractRequestId(result.body);
|
|
171
|
+
const classified = classifyUpstreamError(result.status, result.body);
|
|
172
|
+
|
|
173
|
+
const payload = {
|
|
174
|
+
ok: false,
|
|
175
|
+
httpStatus: result.status,
|
|
176
|
+
statusText: result.statusText,
|
|
177
|
+
durationMs: result.durationMs,
|
|
178
|
+
error: classified,
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
if (requestId) payload.requestId = requestId;
|
|
182
|
+
|
|
183
|
+
return payload;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* @param {unknown} body
|
|
188
|
+
*/
|
|
189
|
+
const sanitizeResponseData = (body) => {
|
|
190
|
+
if (isHtmlBody(body)) return null;
|
|
191
|
+
if (!body || typeof body !== "object") return body;
|
|
192
|
+
if ("data" in body) return body.data;
|
|
193
|
+
return body;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
module.exports = {
|
|
197
|
+
SERVICE_UNAVAILABLE_MESSAGE,
|
|
198
|
+
FEATURE_NOT_ENABLED_MESSAGE,
|
|
199
|
+
FEATURE_UNAVAILABLE_MESSAGE,
|
|
200
|
+
NOT_FOUND_MESSAGE,
|
|
201
|
+
isHtmlBody,
|
|
202
|
+
isVerifikJsonBody,
|
|
203
|
+
isVerifikBusinessErrorBody,
|
|
204
|
+
isEndpointOutOfService,
|
|
205
|
+
extractRequestId,
|
|
206
|
+
extractCreditsCharged,
|
|
207
|
+
classifyUpstreamError,
|
|
208
|
+
buildProxyErrorPayload,
|
|
209
|
+
sanitizeResponseData,
|
|
210
|
+
};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const path = require("node:path");
|
|
4
|
+
|
|
5
|
+
/** @type {import("./field-enrichment.types")} */
|
|
6
|
+
const enrichmentData = require(path.join(__dirname, "..", "data", "field-enrichment.json"));
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {string} featureCode
|
|
10
|
+
*/
|
|
11
|
+
const getFeatureEnrichment = (featureCode) => {
|
|
12
|
+
return enrichmentData.features[featureCode] || {};
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {string} fieldName
|
|
17
|
+
*/
|
|
18
|
+
const getDefaultFieldEnrichment = (fieldName) => {
|
|
19
|
+
return enrichmentData.defaultFields[fieldName] || {};
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {string} featureCode
|
|
24
|
+
* @param {string} fieldName
|
|
25
|
+
*/
|
|
26
|
+
const resolveFieldEnrichment = (featureCode, fieldName) => {
|
|
27
|
+
const featureFields = getFeatureEnrichment(featureCode).fields || {};
|
|
28
|
+
const featureField = featureFields[fieldName] || {};
|
|
29
|
+
const defaultField = getDefaultFieldEnrichment(fieldName);
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
...defaultField,
|
|
33
|
+
...featureField,
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {string} featureCode
|
|
39
|
+
*/
|
|
40
|
+
const getToolNameOverride = (featureCode) => {
|
|
41
|
+
return enrichmentData.toolNameOverrides[featureCode] || null;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {string} featureCode
|
|
46
|
+
*/
|
|
47
|
+
const getRequestOverride = (featureCode) => {
|
|
48
|
+
return enrichmentData.requestOverrides[featureCode] || null;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @param {string} featureCode
|
|
53
|
+
*/
|
|
54
|
+
const getParameterAliases = (featureCode) => {
|
|
55
|
+
return enrichmentData.parameterAliases[featureCode] || {};
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @param {string} fieldName
|
|
60
|
+
*/
|
|
61
|
+
const isHiddenField = (fieldName) => {
|
|
62
|
+
return enrichmentData.hiddenFields.includes(fieldName);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param {string} featureCode
|
|
67
|
+
*/
|
|
68
|
+
const getValidationRule = (featureCode) => {
|
|
69
|
+
const feature = getFeatureEnrichment(featureCode);
|
|
70
|
+
return feature.validation || null;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @param {string} fieldMeta
|
|
75
|
+
*/
|
|
76
|
+
const buildFieldDescription = (fieldMeta) => {
|
|
77
|
+
if (!fieldMeta?.description) return undefined;
|
|
78
|
+
|
|
79
|
+
const parts = [fieldMeta.description];
|
|
80
|
+
|
|
81
|
+
if (fieldMeta.format) {
|
|
82
|
+
parts.push(`Format: ${fieldMeta.format}.`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (fieldMeta.example) {
|
|
86
|
+
parts.push(`Example: ${fieldMeta.example}.`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return parts.join(" ");
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
module.exports = {
|
|
93
|
+
enrichmentData,
|
|
94
|
+
getFeatureEnrichment,
|
|
95
|
+
getDefaultFieldEnrichment,
|
|
96
|
+
resolveFieldEnrichment,
|
|
97
|
+
getToolNameOverride,
|
|
98
|
+
getRequestOverride,
|
|
99
|
+
getParameterAliases,
|
|
100
|
+
isHiddenField,
|
|
101
|
+
getValidationRule,
|
|
102
|
+
buildFieldDescription,
|
|
103
|
+
};
|