create-solvapay 0.8.0 → 0.8.1-preview-b07b713629dff72ea6d257f1f5145cb01ac62f2c
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/package.json
CHANGED
|
@@ -198,7 +198,14 @@ export function listOperations(spec) {
|
|
|
198
198
|
const op = pathItem[method]
|
|
199
199
|
if (!op || typeof op !== 'object') continue
|
|
200
200
|
const operationId = op.operationId ?? defaultOperationId(method, path)
|
|
201
|
-
const
|
|
201
|
+
const merged = mergeParameters(pathLevelParams, op.parameters ?? [])
|
|
202
|
+
// Swagger 2.0 `type: file` form fields carry raw bytes the LLM can't
|
|
203
|
+
// supply, so they never become tool inputs; the operation is flagged
|
|
204
|
+
// instead (suggestTier skips it).
|
|
205
|
+
const hasFileUpload = merged.some(p => p.in === 'formData' && p.type === 'file')
|
|
206
|
+
const parameters = merged.filter(
|
|
207
|
+
p => p.in !== 'body' && !(p.in === 'formData' && p.type === 'file'),
|
|
208
|
+
)
|
|
202
209
|
operations.push({
|
|
203
210
|
operationId,
|
|
204
211
|
method: method.toUpperCase(),
|
|
@@ -208,7 +215,11 @@ export function listOperations(spec) {
|
|
|
208
215
|
deprecated: op.deprecated === true,
|
|
209
216
|
tags: Array.isArray(op.tags) ? op.tags : [],
|
|
210
217
|
parameters,
|
|
211
|
-
requestBody:
|
|
218
|
+
requestBody:
|
|
219
|
+
extractRequestBody(op.requestBody) ??
|
|
220
|
+
extractSwagger2Body(spec, op, pathLevelParams),
|
|
221
|
+
formEncoding: formEncodingFor(spec, op, parameters, hasFileUpload),
|
|
222
|
+
hasFileUpload,
|
|
212
223
|
security: op.security ?? spec.security ?? null,
|
|
213
224
|
raw: op,
|
|
214
225
|
})
|
|
@@ -313,6 +324,7 @@ function resolveOauth2ClientCredentialsFlow(scheme) {
|
|
|
313
324
|
export function suggestTier(operation) {
|
|
314
325
|
if (operation.deprecated) return 'skip'
|
|
315
326
|
if (operation.raw?.['x-internal'] === true) return 'skip'
|
|
327
|
+
if (operation.hasFileUpload) return 'skip'
|
|
316
328
|
if (operation.method === 'GET' || operation.method === 'HEAD') return 'free'
|
|
317
329
|
if (MUTATING_METHODS.has(operation.method)) return 'paid'
|
|
318
330
|
return 'free'
|
|
@@ -342,10 +354,10 @@ export function synthesizeExamples(operation) {
|
|
|
342
354
|
if (operation.requestBody.example !== undefined) {
|
|
343
355
|
// Media-type-level example takes precedence over schema synthesis.
|
|
344
356
|
const value = operation.requestBody.example
|
|
345
|
-
|
|
357
|
+
inputs.body = value
|
|
346
358
|
} else if (operation.requestBody.schema) {
|
|
347
359
|
const { value, source } = pickSchemaValue(operation.requestBody.schema)
|
|
348
|
-
|
|
360
|
+
inputs.body = value
|
|
349
361
|
if (source === 'placeholder') quality = 'placeholder'
|
|
350
362
|
}
|
|
351
363
|
}
|
|
@@ -562,7 +574,9 @@ function mergeParameters(pathLevel, opLevel) {
|
|
|
562
574
|
}
|
|
563
575
|
|
|
564
576
|
function normaliseParameter(param) {
|
|
565
|
-
|
|
577
|
+
// Swagger 2.0 non-body parameters declare type / format / enum /
|
|
578
|
+
// default on the parameter itself rather than under `schema`.
|
|
579
|
+
const schema = param.schema ?? param
|
|
566
580
|
// Parameter-level `description` takes precedence over the inner
|
|
567
581
|
// `schema.description` (OpenAPI 3 allows both; the parameter one is
|
|
568
582
|
// the author's primary intent — schema-level usually describes the
|
|
@@ -589,6 +603,31 @@ function normaliseParameter(param) {
|
|
|
589
603
|
}
|
|
590
604
|
}
|
|
591
605
|
|
|
606
|
+
// Swagger 2.0 carries the JSON body as an `in: body` parameter instead
|
|
607
|
+
// of `requestBody`. Lift it into the same shape so codegen sends it.
|
|
608
|
+
// Skipped when the operation only consumes non-JSON media types.
|
|
609
|
+
function extractSwagger2Body(spec, op, pathLevelParams) {
|
|
610
|
+
const params = [...(op.parameters ?? []), ...pathLevelParams]
|
|
611
|
+
const body = params.find(p => p && typeof p === 'object' && p.in === 'body')
|
|
612
|
+
if (!body) return null
|
|
613
|
+
const consumes = op.consumes ?? spec.consumes
|
|
614
|
+
if (Array.isArray(consumes) && consumes.length > 0 && !consumes.some(c => /json/i.test(c))) {
|
|
615
|
+
return null
|
|
616
|
+
}
|
|
617
|
+
return { required: body.required === true, schema: body.schema ?? null, example: undefined }
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// How Swagger 2.0 `in: formData` fields are sent: `multipart` when the
|
|
621
|
+
// operation only accepts multipart (or uploads a file), otherwise
|
|
622
|
+
// `urlencoded`. Null when there are no form fields.
|
|
623
|
+
function formEncodingFor(spec, op, parameters, hasFileUpload) {
|
|
624
|
+
if (!parameters.some(p => p.in === 'formData')) return null
|
|
625
|
+
const consumes = op.consumes ?? spec.consumes ?? []
|
|
626
|
+
const urlencoded = consumes.some(c => /x-www-form-urlencoded/i.test(c))
|
|
627
|
+
const multipart = consumes.some(c => /multipart\/form-data/i.test(c))
|
|
628
|
+
return multipart && (hasFileUpload || !urlencoded) ? 'multipart' : 'urlencoded'
|
|
629
|
+
}
|
|
630
|
+
|
|
592
631
|
function extractRequestBody(requestBody) {
|
|
593
632
|
if (!requestBody || typeof requestBody !== 'object') return null
|
|
594
633
|
const json = requestBody.content?.['application/json']
|
package/scripts/mcp/scaffold.mjs
CHANGED
|
@@ -629,7 +629,8 @@ function renderToolFile({ operation, schemes, tier, auth, serverBaseUrl, freeLim
|
|
|
629
629
|
const fnName = `register${toPascalIdentifier(operation.operationId)}`
|
|
630
630
|
const urlTemplate = renderUrlTemplate(serverBaseUrl, operation)
|
|
631
631
|
const headerLines = renderHeaderLines(auth, schemes, operation)
|
|
632
|
-
|
|
632
|
+
// Matches the aggregator, which passes `env` whenever auth isn't `none`.
|
|
633
|
+
const includesEnv = auth.kind !== 'none'
|
|
633
634
|
const needsAccessToken = auth.kind === 'oauth2-client-credentials'
|
|
634
635
|
const signature = includesEnv
|
|
635
636
|
? `${fnName}(ctx: AdditionalToolsContext, env: Env)`
|
|
@@ -654,11 +655,15 @@ function renderToolFile({ operation, schemes, tier, auth, serverBaseUrl, freeLim
|
|
|
654
655
|
// carries `{ status, contentType, bodySnippet }` on the thrown
|
|
655
656
|
// error so the MCP `isError` envelope tells the LLM (and the
|
|
656
657
|
// human) exactly why upstream rejected the call.
|
|
658
|
+
const fetchImports = fetchInit.usesStringifyLossless
|
|
659
|
+
? 'upstreamFetchJson, stringifyLossless'
|
|
660
|
+
: 'upstreamFetchJson'
|
|
657
661
|
const importLines = [
|
|
658
662
|
"import { z } from 'zod'",
|
|
659
663
|
"import type { AdditionalToolsContext } from '@solvapay/mcp'",
|
|
660
|
-
|
|
664
|
+
`import { ${fetchImports} } from '../lib/upstreamFetch'`,
|
|
661
665
|
]
|
|
666
|
+
if (operation.parameters.some(isInt64Param)) importLines.push("import { int64 } from '../lib/int64'")
|
|
662
667
|
if (needsAccessToken) importLines.push("import { getAccessToken } from '../lib/upstreamOAuth'")
|
|
663
668
|
if (includesEnv) importLines.push("import type { Env } from '../worker'")
|
|
664
669
|
|
|
@@ -672,7 +677,10 @@ function renderSchemaFields(operation, indent) {
|
|
|
672
677
|
lines.push(`${pad}${jsKey(param.name)}: ${zodForParam(param)},`)
|
|
673
678
|
}
|
|
674
679
|
if (operation.requestBody?.schema) {
|
|
675
|
-
|
|
680
|
+
const bodySchema = isArrayBody(operation)
|
|
681
|
+
? 'z.array(z.unknown())'
|
|
682
|
+
: 'z.record(z.string(), z.unknown())'
|
|
683
|
+
lines.push(`${pad}body: ${bodySchema}.optional(),`)
|
|
676
684
|
}
|
|
677
685
|
if (lines.length === 0) return ''
|
|
678
686
|
return lines.join('\n')
|
|
@@ -694,7 +702,8 @@ function renderSchemaFields(operation, indent) {
|
|
|
694
702
|
function zodForParam(param) {
|
|
695
703
|
const { type, format, required, enum: enumValues, description } = param
|
|
696
704
|
let z
|
|
697
|
-
if (
|
|
705
|
+
if (isInt64Param(param)) z = 'int64'
|
|
706
|
+
else if (type === 'integer') z = 'z.number().int()'
|
|
698
707
|
else if (type === 'number') z = 'z.number()'
|
|
699
708
|
else if (type === 'boolean') z = 'z.boolean()'
|
|
700
709
|
else if (type === 'array') z = 'z.array(z.unknown())'
|
|
@@ -713,6 +722,35 @@ function zodForParam(param) {
|
|
|
713
722
|
return required ? z : `${z}.optional()`
|
|
714
723
|
}
|
|
715
724
|
|
|
725
|
+
// 64-bit integers exceed JavaScript's exact range (2^53 - 1), so they use
|
|
726
|
+
// the template's `int64` schema (safe number or digit string) instead of
|
|
727
|
+
// `z.number().int()`.
|
|
728
|
+
function isInt64Param(param) {
|
|
729
|
+
return param.type === 'integer' && (param.format === 'int64' || param.format === 'uint64')
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// Names of every integer-typed property in a (dereferenced) request body
|
|
733
|
+
// schema, walking nested objects, array items and composition keywords.
|
|
734
|
+
// The generated tool passes them to `stringifyLossless` so int64 values
|
|
735
|
+
// the LLM sends as digit strings go upstream as exact JSON numbers.
|
|
736
|
+
// Collecting all integers (not only `int64`) is safe: the serializer only
|
|
737
|
+
// rewrites values that are already digit strings.
|
|
738
|
+
function collectIntegerPropertyNames(schema, names = new Set(), seen = new Set()) {
|
|
739
|
+
if (!schema || typeof schema !== 'object' || seen.has(schema)) return names
|
|
740
|
+
seen.add(schema)
|
|
741
|
+
for (const [name, prop] of Object.entries(schema.properties ?? {})) {
|
|
742
|
+
if (prop && typeof prop === 'object' && prop.type === 'integer') names.add(name)
|
|
743
|
+
collectIntegerPropertyNames(prop, names, seen)
|
|
744
|
+
}
|
|
745
|
+
collectIntegerPropertyNames(schema.items, names, seen)
|
|
746
|
+
for (const key of ['allOf', 'oneOf', 'anyOf']) {
|
|
747
|
+
for (const sub of Array.isArray(schema[key]) ? schema[key] : []) {
|
|
748
|
+
collectIntegerPropertyNames(sub, names, seen)
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
return names
|
|
752
|
+
}
|
|
753
|
+
|
|
716
754
|
function isScalarStringEnum(enumValues, type) {
|
|
717
755
|
if (!Array.isArray(enumValues) || enumValues.length === 0) return false
|
|
718
756
|
if (type !== undefined && type !== 'string') return false
|
|
@@ -732,6 +770,25 @@ function renderUrlTemplate(baseUrl, operation) {
|
|
|
732
770
|
return `\`${joinUrl(baseUrl, path)}\``
|
|
733
771
|
}
|
|
734
772
|
|
|
773
|
+
function isArrayBody(operation) {
|
|
774
|
+
return operation.requestBody?.schema?.type === 'array'
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// Swagger 2.0 `in: formData` fields, sent as URLSearchParams or FormData
|
|
778
|
+
// per `operation.formEncoding`. fetch sets the matching content-type
|
|
779
|
+
// (including the multipart boundary) from the body type.
|
|
780
|
+
function renderFormAssign(operation) {
|
|
781
|
+
const fields = operation.parameters.filter(p => p.in === 'formData')
|
|
782
|
+
if (fields.length === 0 || !operation.formEncoding) return ''
|
|
783
|
+
const ctor = operation.formEncoding === 'multipart' ? 'FormData' : 'URLSearchParams'
|
|
784
|
+
const lines = [` const form = new ${ctor}()`]
|
|
785
|
+
for (const field of fields) {
|
|
786
|
+
const access = `input.${jsAccessor(field.name)}`
|
|
787
|
+
lines.push(` if (${access} !== undefined) form.set('${field.name}', String(${access}))`)
|
|
788
|
+
}
|
|
789
|
+
return lines.join('\n')
|
|
790
|
+
}
|
|
791
|
+
|
|
735
792
|
function renderFetchInit(operation, headerLines) {
|
|
736
793
|
const method = operation.method
|
|
737
794
|
const hasBody = !!operation.requestBody?.schema && method !== 'GET' && method !== 'HEAD'
|
|
@@ -741,19 +798,30 @@ function renderFetchInit(operation, headerLines) {
|
|
|
741
798
|
const access = `input.${jsAccessor(p.name)}`
|
|
742
799
|
return ` if (${access} !== undefined) url.searchParams.set('${p.name}', String(${access}))`
|
|
743
800
|
})
|
|
801
|
+
.concat(renderFormAssign(operation) || [])
|
|
744
802
|
.join('\n')
|
|
745
803
|
|
|
746
804
|
const headersBlock = headerLines.length > 0
|
|
747
805
|
? ` headers: { ${headerLines.join(', ')} },\n`
|
|
748
806
|
: ''
|
|
749
|
-
const
|
|
807
|
+
const integerKeys = hasBody
|
|
808
|
+
? [...collectIntegerPropertyNames(operation.requestBody.schema)].sort()
|
|
809
|
+
: []
|
|
810
|
+
const usesStringifyLossless = integerKeys.length > 0
|
|
811
|
+
const emptyBody = isArrayBody(operation) ? '[]' : '{}'
|
|
812
|
+
const bodyBlock = !hasBody
|
|
813
|
+
? operation.formEncoding
|
|
814
|
+
? ' body: form,\n'
|
|
815
|
+
: ''
|
|
816
|
+
: usesStringifyLossless
|
|
817
|
+
? ` body: stringifyLossless(input.body ?? ${emptyBody}, new Set(${JSON.stringify(integerKeys)})),\n`
|
|
818
|
+
: ` body: JSON.stringify(input.body ?? ${emptyBody}),\n`
|
|
750
819
|
const methodBlock = ` method: '${method}',\n`
|
|
751
820
|
|
|
752
|
-
return { method, queryAssign, headersBlock, bodyBlock, methodBlock }
|
|
821
|
+
return { method, queryAssign, headersBlock, bodyBlock, methodBlock, usesStringifyLossless }
|
|
753
822
|
}
|
|
754
823
|
|
|
755
824
|
function renderHeaderLines(auth, schemes, operation) {
|
|
756
|
-
if (auth.kind === 'none') return []
|
|
757
825
|
// Pick the first supported scheme this operation requires; fall back
|
|
758
826
|
// to the top-level auth kind when the spec has no operation-level
|
|
759
827
|
// security override.
|
|
@@ -93,7 +93,14 @@ export function listOperations(spec) {
|
|
|
93
93
|
const op = pathItem[method]
|
|
94
94
|
if (!op || typeof op !== 'object') continue
|
|
95
95
|
const operationId = op.operationId ?? defaultOperationId(method, path)
|
|
96
|
-
const
|
|
96
|
+
const merged = mergeParameters(pathLevelParams, op.parameters ?? [])
|
|
97
|
+
// Swagger 2.0 `type: file` form fields carry raw bytes the LLM can't
|
|
98
|
+
// supply, so they never become tool inputs; the operation is flagged
|
|
99
|
+
// instead (suggestTier skips it).
|
|
100
|
+
const hasFileUpload = merged.some(p => p.in === 'formData' && p.type === 'file')
|
|
101
|
+
const parameters = merged.filter(
|
|
102
|
+
p => p.in !== 'body' && !(p.in === 'formData' && p.type === 'file'),
|
|
103
|
+
)
|
|
97
104
|
operations.push({
|
|
98
105
|
operationId,
|
|
99
106
|
method: method.toUpperCase(),
|
|
@@ -103,7 +110,11 @@ export function listOperations(spec) {
|
|
|
103
110
|
deprecated: op.deprecated === true,
|
|
104
111
|
tags: Array.isArray(op.tags) ? op.tags : [],
|
|
105
112
|
parameters,
|
|
106
|
-
requestBody:
|
|
113
|
+
requestBody:
|
|
114
|
+
extractRequestBody(op.requestBody) ??
|
|
115
|
+
extractSwagger2Body(spec, op, pathLevelParams),
|
|
116
|
+
formEncoding: formEncodingFor(spec, op, parameters, hasFileUpload),
|
|
117
|
+
hasFileUpload,
|
|
107
118
|
security: op.security ?? spec.security ?? null,
|
|
108
119
|
raw: op,
|
|
109
120
|
})
|
|
@@ -159,6 +170,7 @@ export function resolveSecuritySchemes(spec) {
|
|
|
159
170
|
export function suggestTier(operation) {
|
|
160
171
|
if (operation.deprecated) return 'skip'
|
|
161
172
|
if (operation.raw?.['x-internal'] === true) return 'skip'
|
|
173
|
+
if (operation.hasFileUpload) return 'skip'
|
|
162
174
|
if (operation.method === 'GET' || operation.method === 'HEAD') return 'free'
|
|
163
175
|
if (MUTATING_METHODS.has(operation.method)) return 'paid'
|
|
164
176
|
return 'free'
|
|
@@ -188,10 +200,10 @@ export function synthesizeExamples(operation) {
|
|
|
188
200
|
if (operation.requestBody.example !== undefined) {
|
|
189
201
|
// Media-type-level example takes precedence over schema synthesis.
|
|
190
202
|
const value = operation.requestBody.example
|
|
191
|
-
|
|
203
|
+
inputs.body = value
|
|
192
204
|
} else if (operation.requestBody.schema) {
|
|
193
205
|
const { value, source } = pickSchemaValue(operation.requestBody.schema)
|
|
194
|
-
|
|
206
|
+
inputs.body = value
|
|
195
207
|
if (source === 'placeholder') quality = 'placeholder'
|
|
196
208
|
}
|
|
197
209
|
}
|
|
@@ -252,7 +264,9 @@ function mergeParameters(pathLevel, opLevel) {
|
|
|
252
264
|
}
|
|
253
265
|
|
|
254
266
|
function normaliseParameter(param) {
|
|
255
|
-
|
|
267
|
+
// Swagger 2.0 non-body parameters declare type / format / enum /
|
|
268
|
+
// default on the parameter itself rather than under `schema`.
|
|
269
|
+
const schema = param.schema ?? param
|
|
256
270
|
return {
|
|
257
271
|
name: param.name,
|
|
258
272
|
in: param.in,
|
|
@@ -267,6 +281,31 @@ function normaliseParameter(param) {
|
|
|
267
281
|
}
|
|
268
282
|
}
|
|
269
283
|
|
|
284
|
+
// Swagger 2.0 carries the JSON body as an `in: body` parameter instead
|
|
285
|
+
// of `requestBody`. Lift it into the same shape so codegen sends it.
|
|
286
|
+
// Skipped when the operation only consumes non-JSON media types.
|
|
287
|
+
function extractSwagger2Body(spec, op, pathLevelParams) {
|
|
288
|
+
const params = [...(op.parameters ?? []), ...pathLevelParams]
|
|
289
|
+
const body = params.find(p => p && typeof p === 'object' && p.in === 'body')
|
|
290
|
+
if (!body) return null
|
|
291
|
+
const consumes = op.consumes ?? spec.consumes
|
|
292
|
+
if (Array.isArray(consumes) && consumes.length > 0 && !consumes.some(c => /json/i.test(c))) {
|
|
293
|
+
return null
|
|
294
|
+
}
|
|
295
|
+
return { required: body.required === true, schema: body.schema ?? null, example: undefined }
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// How Swagger 2.0 `in: formData` fields are sent: `multipart` when the
|
|
299
|
+
// operation only accepts multipart (or uploads a file), otherwise
|
|
300
|
+
// `urlencoded`. Null when there are no form fields.
|
|
301
|
+
function formEncodingFor(spec, op, parameters, hasFileUpload) {
|
|
302
|
+
if (!parameters.some(p => p.in === 'formData')) return null
|
|
303
|
+
const consumes = op.consumes ?? spec.consumes ?? []
|
|
304
|
+
const urlencoded = consumes.some(c => /x-www-form-urlencoded/i.test(c))
|
|
305
|
+
const multipart = consumes.some(c => /multipart\/form-data/i.test(c))
|
|
306
|
+
return multipart && (hasFileUpload || !urlencoded) ? 'multipart' : 'urlencoded'
|
|
307
|
+
}
|
|
308
|
+
|
|
270
309
|
function extractRequestBody(requestBody) {
|
|
271
310
|
if (!requestBody || typeof requestBody !== 'object') return null
|
|
272
311
|
const json = requestBody.content?.['application/json']
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tool input schema for 64-bit integers (`format: int64`). JavaScript
|
|
5
|
+
* numbers are only exact up to 2^53 - 1, so larger values travel as a
|
|
6
|
+
* string of digits — the same form `upstreamFetchJson` returns them in.
|
|
7
|
+
* Unsafe numbers are rejected instead of being silently rounded.
|
|
8
|
+
*/
|
|
9
|
+
export const int64 = z.union([
|
|
10
|
+
z
|
|
11
|
+
.number()
|
|
12
|
+
.int()
|
|
13
|
+
.refine(Number.isSafeInteger, 'Integers this large must be passed as a string of digits'),
|
|
14
|
+
z.string().regex(/^-?\d+$/, 'Expected an integer as a string of digits'),
|
|
15
|
+
])
|
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
* - Throws `UpstreamError` on non-2xx OR JSON parse failure. The
|
|
13
13
|
* error message names the method, URL, status, content-type, and
|
|
14
14
|
* a body snippet so the LLM has enough context to recover or stop.
|
|
15
|
+
* - Keeps integers beyond `Number.MAX_SAFE_INTEGER` (int64 IDs,
|
|
16
|
+
* snowflakes) as their exact decimal string instead of silently
|
|
17
|
+
* rounding them. Send them back with `stringifyLossless`.
|
|
15
18
|
*
|
|
16
19
|
* The free-tool path (`ctx.server.registerTool`), the free-capped path
|
|
17
20
|
* (`ctx.registerFree`), and the paid-tool path (`ctx.registerPayable`)
|
|
@@ -27,6 +30,41 @@
|
|
|
27
30
|
|
|
28
31
|
const BODY_SNIPPET_MAX_CHARS = 500
|
|
29
32
|
|
|
33
|
+
/**
|
|
34
|
+
* `JSON.parse` reviver that returns the exact source text for integers
|
|
35
|
+
* `JSON.parse` would round. Relies on JSON.parse source access (V8 11.4+:
|
|
36
|
+
* workerd, Node 21+); on older runtimes `context` is undefined and values
|
|
37
|
+
* pass through unchanged.
|
|
38
|
+
*/
|
|
39
|
+
function losslessIntReviver(_key: string, value: unknown, context?: { source?: string }): unknown {
|
|
40
|
+
if (
|
|
41
|
+
typeof value === 'number' &&
|
|
42
|
+
Number.isInteger(value) &&
|
|
43
|
+
!Number.isSafeInteger(value) &&
|
|
44
|
+
context?.source &&
|
|
45
|
+
/^-?\d+$/.test(context.source)
|
|
46
|
+
) {
|
|
47
|
+
return context.source
|
|
48
|
+
}
|
|
49
|
+
return value
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const rawJSON = (JSON as unknown as { rawJSON?: (text: string) => unknown }).rawJSON
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* `JSON.stringify` that writes digit strings back as bare JSON numbers for
|
|
56
|
+
* the given keys (the spec's integer properties), so int64 values returned
|
|
57
|
+
* as strings by `upstreamFetchJson` round-trip exactly. Keys not listed are
|
|
58
|
+
* left alone, so digit-only strings such as phone numbers stay strings.
|
|
59
|
+
*/
|
|
60
|
+
export function stringifyLossless(body: unknown, integerKeys: ReadonlySet<string>): string {
|
|
61
|
+
return JSON.stringify(body, (key, value) =>
|
|
62
|
+
integerKeys.has(key) && typeof value === 'string' && /^-?\d+$/.test(value) && rawJSON
|
|
63
|
+
? rawJSON(value)
|
|
64
|
+
: value,
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
30
68
|
export interface UpstreamErrorInit {
|
|
31
69
|
method: string
|
|
32
70
|
url: string
|
|
@@ -100,7 +138,7 @@ export async function upstreamFetchJson<T = unknown>(
|
|
|
100
138
|
}
|
|
101
139
|
|
|
102
140
|
try {
|
|
103
|
-
return JSON.parse(text) as T
|
|
141
|
+
return JSON.parse(text, losslessIntReviver as Parameters<typeof JSON.parse>[1]) as T
|
|
104
142
|
} catch (err) {
|
|
105
143
|
throw new UpstreamError({
|
|
106
144
|
method,
|