Skip to content

Commit fa73305

Browse files
validate: retry on 422, resolve $ref/examples, add --header, fix x402 validation, send explicit body upfront (#649)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> GIT_VALID_PII_OVERRIDE Committed-By-Agent: claude
1 parent a6969b8 commit fa73305

9 files changed

Lines changed: 228 additions & 25 deletions

File tree

.changeset/validate-small-fixes.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'mppx': patch
3+
---
4+
5+
`mppx validate`: retry on 422, resolve `$ref` in body generation, add `--header`/`-H` flag, fix x402 validation for non-EVM chains.

src/cli/validate/challenge.ts

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
fetchWithTimeout,
1414
isValidAddress,
1515
isValidIntegerAmount,
16+
parseHeaders,
1617
skip,
1718
warn,
1819
} from './helpers.js'
@@ -32,7 +33,9 @@ export async function validateChallenge(
3233
verbose: boolean,
3334
options?: {
3435
body?: string | undefined
36+
bodyIsAutogenerated?: boolean | undefined
3537
query?: string[] | undefined
38+
extraHeaders?: string[] | undefined
3639
discoveryDoc?: Record<string, unknown> | undefined
3740
},
3841
): Promise<{
@@ -42,20 +45,28 @@ export async function validateChallenge(
4245
}> {
4346
const results: CheckResult[] = []
4447
const url = buildUrl(baseUrl, endpoint, options?.query)
45-
const fetchHeaders: Record<string, string> = {}
48+
const fetchHeaders: Record<string, string> = parseHeaders(options?.extraHeaders)
4649
let fetchBody: string | undefined
4750

48-
// Make bare unauthenticated request first (no body)
51+
// If the user explicitly provided a body, include it on the first request
52+
if (options?.body && !options.bodyIsAutogenerated) {
53+
fetchBody = options.body
54+
if (!fetchHeaders['content-type']) fetchHeaders['content-type'] = 'application/json'
55+
}
56+
4957
let response: Response
5058
try {
51-
response = await fetchWithTimeout(url, { method: endpoint.method })
59+
const init: RequestInit = { method: endpoint.method }
60+
if (Object.keys(fetchHeaders).length > 0) init.headers = fetchHeaders
61+
if (fetchBody) init.body = fetchBody
62+
response = await fetchWithTimeout(url, init)
5263
} catch (error) {
5364
results.push(fail('Request failed', (error as Error).message))
5465
return { results }
5566
}
5667

57-
// If we got 400, retry with body (explicit --body or auto-generated from schema)
58-
if (response.status === 400) {
68+
// If we got 400/422, retry with body (explicit --body or auto-generated from schema)
69+
if (!fetchBody && (response.status === 400 || response.status === 422)) {
5970
const bodyToTry =
6071
options?.body ??
6172
(options?.discoveryDoc
@@ -99,7 +110,7 @@ export async function validateChallenge(
99110
fail(
100111
'Returns 402 without credentials',
101112
`Got ${response.status} instead`,
102-
response.status === 400
113+
response.status === 400 || response.status === 422
103114
? 'Server requires a valid request body before returning 402. Add a requestBody schema with examples to your OpenAPI doc, or use --body.'
104115
: 'Endpoints that require payment must return HTTP 402 with a WWW-Authenticate: Payment header when no valid credential is provided.',
105116
),
@@ -111,8 +122,8 @@ export async function validateChallenge(
111122

112123
// Check WWW-Authenticate header
113124
const wwwAuth = response.headers.get(Constants.Headers.wwwAuthenticate)
125+
const x402Results = checkX402Headers(response)
114126
if (!wwwAuth) {
115-
const x402Results = checkX402Headers(response)
116127
if (x402Results.length > 0) {
117128
results.push(
118129
skip('Not an MPP endpoint', 'No WWW-Authenticate header — x402 protocol detected'),
@@ -125,6 +136,7 @@ export async function validateChallenge(
125136
}
126137
return { results }
127138
}
139+
if (x402Results.length > 0) results.push(...x402Results)
128140
if (!wwwAuth.startsWith(`${Constants.Schemes.payment} `)) {
129141
results.push(skip('Not an MPP endpoint', `WWW-Authenticate scheme is not Payment`))
130142
return { results }
@@ -533,7 +545,7 @@ function checkX402Headers(response: Response): CheckResult[] {
533545
: 'X-Payment-Required'
534546

535547
try {
536-
const decoded = x402Header.decodePaymentRequired(paymentRequiredRaw)
548+
const decoded = x402Header.decodePaymentRequiredEnvelope(paymentRequiredRaw)
537549
results.push(
538550
check(
539551
'x402 payment challenge',

src/cli/validate/discovery.test.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,4 +304,132 @@ describe('extractRequestBodyFromDiscovery', () => {
304304
})
305305
expect(JSON.parse(body!)).toEqual({ model: 'gpt-4' })
306306
})
307+
308+
test('resolves $ref to components/schemas', () => {
309+
const doc = {
310+
paths: {
311+
'/api/orders': {
312+
post: {
313+
requestBody: {
314+
content: {
315+
'application/json': {
316+
schema: { $ref: '#/components/schemas/OrderRequest' },
317+
},
318+
},
319+
},
320+
},
321+
},
322+
},
323+
components: {
324+
schemas: {
325+
OrderRequest: {
326+
type: 'object',
327+
required: ['product', 'quantity'],
328+
properties: {
329+
product: { type: 'string', example: 'widget' },
330+
quantity: { type: 'integer', minimum: 1 },
331+
},
332+
},
333+
},
334+
},
335+
}
336+
const body = extractRequestBodyFromDiscovery(doc as Record<string, unknown>, {
337+
method: 'POST',
338+
path: '/api/orders',
339+
})
340+
expect(JSON.parse(body!)).toEqual({ product: 'widget', quantity: 1 })
341+
})
342+
343+
test('resolves nested $ref in properties', () => {
344+
const doc = {
345+
paths: {
346+
'/api/orders': {
347+
post: {
348+
requestBody: {
349+
content: {
350+
'application/json': {
351+
schema: { $ref: '#/components/schemas/OrderRequest' },
352+
},
353+
},
354+
},
355+
},
356+
},
357+
},
358+
components: {
359+
schemas: {
360+
OrderRequest: {
361+
type: 'object',
362+
required: ['customer'],
363+
properties: {
364+
customer: { $ref: '#/components/schemas/Customer' },
365+
},
366+
},
367+
Customer: {
368+
type: 'object',
369+
required: ['name', 'email'],
370+
properties: {
371+
name: { type: 'string' },
372+
email: { type: 'string', format: 'email' },
373+
},
374+
},
375+
},
376+
},
377+
}
378+
const body = extractRequestBodyFromDiscovery(doc as Record<string, unknown>, {
379+
method: 'POST',
380+
path: '/api/orders',
381+
})
382+
expect(JSON.parse(body!)).toEqual({ customer: { name: 'test', email: 'test@example.com' } })
383+
})
384+
385+
test('uses first named example from examples map', () => {
386+
const doc = {
387+
paths: {
388+
'/api/test': {
389+
post: {
390+
requestBody: {
391+
content: {
392+
'application/json': {
393+
examples: {
394+
basic: { value: { prompt: 'hello', model: 'gpt-4' } },
395+
advanced: { value: { prompt: 'complex', model: 'gpt-4', temp: 0.9 } },
396+
},
397+
schema: { type: 'object' },
398+
},
399+
},
400+
},
401+
},
402+
},
403+
},
404+
}
405+
const body = extractRequestBodyFromDiscovery(doc as Record<string, unknown>, {
406+
method: 'POST',
407+
path: '/api/test',
408+
})
409+
expect(JSON.parse(body!)).toEqual({ prompt: 'hello', model: 'gpt-4' })
410+
})
411+
412+
test('returns undefined for unresolvable $ref', () => {
413+
const doc = {
414+
paths: {
415+
'/api/test': {
416+
post: {
417+
requestBody: {
418+
content: {
419+
'application/json': {
420+
schema: { $ref: '#/components/schemas/DoesNotExist' },
421+
},
422+
},
423+
},
424+
},
425+
},
426+
},
427+
components: { schemas: {} },
428+
}
429+
const body = extractRequestBodyFromDiscovery(doc as Record<string, unknown>, {
430+
method: 'POST',
431+
path: '/api/test',
432+
})
433+
expect(body).toBeUndefined()
434+
})
307435
})

src/cli/validate/discovery.ts

Lines changed: 50 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -77,10 +77,19 @@ export function extractRequestBodyFromDiscovery(
7777

7878
if (jsonContent.example) return JSON.stringify(jsonContent.example)
7979

80-
const schema = jsonContent.schema as Record<string, unknown> | undefined
80+
if (jsonContent.examples && typeof jsonContent.examples === 'object') {
81+
const first = Object.values(jsonContent.examples as Record<string, unknown>)[0] as
82+
| Record<string, unknown>
83+
| undefined
84+
if (first?.value) return JSON.stringify(first.value)
85+
}
86+
87+
let schema = jsonContent.schema as Record<string, unknown> | undefined
88+
const seen = new Set<string>()
89+
schema = resolveRef(schema, doc, seen)
8190
if (!schema || schema.type !== 'object') return undefined
8291

83-
const result = generateValueFromSchema(schema)
92+
const result = generateValueFromSchema(schema, doc, seen)
8493
if (result && typeof result === 'object' && Object.keys(result as object).length > 0) {
8594
return JSON.stringify(result)
8695
}
@@ -149,34 +158,60 @@ function substitutePathParams(path: string, params: PathParameter[]): string {
149158
})
150159
}
151160

152-
function generateValueFromSchema(schema: Record<string, unknown>): unknown {
153-
if (schema.const !== undefined) return schema.const
154-
if (schema.example !== undefined) return schema.example
155-
if (schema.default !== undefined) return schema.default
161+
// Dereferences a JSON Schema $ref (e.g. "#/components/schemas/Foo") against the root doc.
162+
function resolveRef(
163+
schema: Record<string, unknown> | undefined,
164+
doc: Record<string, unknown>,
165+
seen?: Set<string>,
166+
): Record<string, unknown> | undefined {
167+
if (!schema || typeof schema.$ref !== 'string') return schema
168+
if (seen?.has(schema.$ref)) return undefined
169+
const path = schema.$ref.replace(/^#\//, '').split('/')
170+
let resolved: unknown = doc
171+
for (const segment of path) {
172+
if (resolved && typeof resolved === 'object') resolved = (resolved as any)[segment]
173+
else return undefined
174+
}
175+
return resolved as Record<string, unknown> | undefined
176+
}
156177

157-
switch (schema.type) {
178+
// Generates a plausible value for a JSON Schema node (required fields only for objects).
179+
function generateValueFromSchema(
180+
schema: Record<string, unknown>,
181+
doc: Record<string, unknown>,
182+
seen?: Set<string>,
183+
): unknown {
184+
const resolved = resolveRef(schema, doc, seen) ?? schema
185+
if (resolved.const !== undefined) return resolved.const
186+
if (resolved.example !== undefined) return resolved.example
187+
if (resolved.default !== undefined) return resolved.default
188+
189+
switch (resolved.type) {
158190
case 'string': {
159-
if (schema.enum && Array.isArray(schema.enum)) return schema.enum[0]
160-
if (schema.format === 'email') return 'test@example.com'
161-
if (schema.format === 'uuid') return '00000000-0000-0000-0000-000000000000'
162-
if (schema.format === 'uri' || schema.format === 'url') return 'https://example.com'
191+
if (resolved.enum && Array.isArray(resolved.enum)) return resolved.enum[0]
192+
if (resolved.format === 'email') return 'test@example.com'
193+
if (resolved.format === 'uuid') return '00000000-0000-0000-0000-000000000000'
194+
if (resolved.format === 'uri' || resolved.format === 'url') return 'https://example.com'
195+
if (resolved.format === 'date') return '2026-01-01'
196+
if (resolved.pattern === '^\\d{5}(?:-\\d{4})?$') return '10001'
197+
if (resolved.pattern === '^[A-Z]{2}$') return 'US'
163198
return 'test'
164199
}
165200
case 'number':
166201
case 'integer':
167-
return (schema.minimum as number) ?? 1
202+
return (resolved.minimum as number) ?? 1
168203
case 'boolean':
169204
return true
170205
case 'array':
171206
return []
172207
case 'object': {
173-
const properties = schema.properties as Record<string, Record<string, unknown>> | undefined
208+
const properties = resolved.properties as Record<string, Record<string, unknown>> | undefined
174209
if (!properties) return {}
175-
const required = (schema.required as string[]) || []
210+
const required = (resolved.required as string[]) || []
176211
const obj: Record<string, unknown> = {}
177212
for (const key of required) {
178213
const prop = properties[key]
179-
if (prop) obj[key] = generateValueFromSchema(prop)
214+
if (prop) obj[key] = generateValueFromSchema(prop, doc, seen)
180215
}
181216
return obj
182217
}

src/cli/validate/helpers.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,3 +154,13 @@ export function resolveBodyForEndpoint(
154154
} catch {}
155155
return rawBody
156156
}
157+
158+
export function parseHeaders(raw: string[] | undefined): Record<string, string> {
159+
if (!raw) return {}
160+
const headers: Record<string, string> = {}
161+
for (const h of raw) {
162+
const idx = h.indexOf(':')
163+
if (idx > 0) headers[h.slice(0, idx).trim().toLowerCase()] = h.slice(idx + 1).trim()
164+
}
165+
return headers
166+
}

src/cli/validate/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,14 @@ const validate = Cli.create('validate', {
1919
'Request body. With --endpoint, used directly. In discovery mode, JSON with / keys is a per-path mapping.',
2020
),
2121
query: z.array(z.string()).optional().describe('Query parameter (key=value, repeatable)'),
22+
header: z.array(z.string()).optional().describe('Request header (key:value, repeatable)'),
2223
verbose: z.number().default(0).meta({ count: true }).describe('Verbosity level'),
2324
yes: z.boolean().default(false).describe('Auto-approve mainnet payments'),
2425
outputJson: z.boolean().default(false).describe('Output results as JSON'),
2526
}),
2627
alias: {
2728
endpoint: 'e',
29+
header: 'H',
2830
verbose: 'v',
2931
yes: 'y',
3032
outputJson: 'j',
@@ -37,6 +39,7 @@ const validate = Cli.create('validate', {
3739
endpoint: c.options.endpoint,
3840
body: c.options.body,
3941
query: c.options.query,
42+
headers: c.options.header,
4043
verbose: c.options.verbose > 0,
4144
yes: c.options.yes,
4245
interactive: false,
@@ -66,6 +69,7 @@ const validate = Cli.create('validate', {
6669
endpoint: c.options.endpoint,
6770
body: c.options.body,
6871
query: c.options.query,
72+
headers: c.options.header,
6973
verbose: c.options.verbose > 0,
7074
yes: c.options.yes,
7175
interactive: !!process.stdin.isTTY,

0 commit comments

Comments
 (0)