Repository navigation
Expand file tree
/
Copy pathreference.ts
More file actions
205 lines (195 loc) · 7.63 KB
/
Copy pathreference.ts
File metadata and controls
205 lines (195 loc) · 7.63 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
import { isTruncationField } from '#sim-cli/output/truncation'
export interface ReferenceSchema {
$ref?: string
type?: string | string[]
properties?: Record<string, ReferenceSchema>
items?: ReferenceSchema
anyOf?: ReferenceSchema[]
oneOf?: ReferenceSchema[]
allOf?: ReferenceSchema[]
nullable?: boolean
enum?: unknown[]
const?: unknown
required?: string[]
}
export interface ReferenceDocument {
paths: Record<
string,
Record<
string,
{
responses?: Record<string, { content?: Record<string, { schema?: ReferenceSchema }> }>
requestBody?: { content?: Record<string, { schema?: ReferenceSchema }> }
}
>
>
components?: { schemas?: Record<string, ReferenceSchema> }
}
function resolve(doc: ReferenceDocument, schema: ReferenceSchema): ReferenceSchema {
const visited = new Set<string>()
let current = schema
while (current.$ref) {
const reference = current.$ref
if (visited.has(reference)) throw new Error(`Circular schema alias: ${reference}`)
visited.add(reference)
const name = reference.split('/').pop()!
const target = doc.components?.schemas?.[name]
if (!target) throw new Error(`Unresolved reference: ${reference}`)
current = target
}
return current
}
/** Bounded expansion preserves unions as alternatives and always groups array elements. */
function label(
doc: ReferenceDocument,
schema: ReferenceSchema,
depth: number,
brief: boolean,
ancestors: ReadonlySet<ReferenceSchema> = new Set()
): string {
const value = resolve(doc, schema)
if (ancestors.has(value)) return schema.$ref?.split('/').pop() ?? 'recursive'
const visited = new Set([...ancestors, value])
const render = (item: ReferenceSchema, childDepth = depth): string =>
label(doc, item, childDepth, brief, visited)
let text: string
if (value.const !== undefined) text = JSON.stringify(value.const)
else if (value.enum) {
text =
brief && value.enum.length > 2
? `${JSON.stringify(value.enum[0])}|…(${value.enum.length})`
: value.enum.map((item) => JSON.stringify(item)).join('|')
} else if (value.anyOf || value.oneOf)
text = (value.anyOf ?? value.oneOf)!.map((item) => render(item)).join('|')
else if (value.allOf) text = value.allOf.map((item) => `(${render(item)})`).join('&')
else if (Array.isArray(value.type))
text = value.type.map((type) => render({ ...value, type, nullable: false })).join('|')
else if (value.type === 'array') {
const item = value.items ? render(value.items) : 'unknown'
const element = value.items ? resolve(doc, value.items) : undefined
const grouped =
element &&
(element.anyOf ||
element.oneOf ||
element.allOf ||
element.nullable ||
Array.isArray(element.type) ||
(element.enum && element.enum.length > 1))
text = `${grouped ? `(${item})` : item}[]`
} else if (value.type === 'object' || value.properties) {
const keys = Object.keys(value.properties ?? {})
const required = new Set(value.required ?? [])
text =
keys.length === 0
? 'object'
: `{${keys
.map((key) =>
depth > 0
? `${key}${required.has(key) ? '' : '?'}:${render(value.properties![key]!, depth - 1)}`
: key
)
.join(',')}}`
} else text = value.type ?? 'unknown'
return value.nullable ? `${text}|null` : text
}
/** `result.data ?? result` retains the envelope when data is null (files share get). */
function coalescedData(
doc: ReferenceDocument,
envelope: ReferenceSchema,
schema: ReferenceSchema
): ReferenceSchema {
const value = resolve(doc, schema)
const variants = value.anyOf ?? value.oneOf
if (variants) return { anyOf: variants.map((item) => coalescedData(doc, envelope, item)) }
if (value.type === 'null')
return { ...envelope, properties: { ...envelope.properties, data: value } }
return value
}
/** The ordinary CLI unwraps one data envelope, including each alternate response form. */
function payload(doc: ReferenceDocument, schema: ReferenceSchema): ReferenceSchema {
const value = resolve(doc, schema)
if (value.anyOf) return { anyOf: value.anyOf.map((item) => payload(doc, item)) }
if (value.oneOf) return { oneOf: value.oneOf.map((item) => payload(doc, item)) }
return value.properties?.data ? coalescedData(doc, value, value.properties.data) : value
}
/** Paged machine output retains rows, the continuation cursor and only rendered clipping flags. */
function pagePayload(doc: ReferenceDocument, schema: ReferenceSchema): ReferenceSchema {
const value = resolve(doc, schema)
if (value.anyOf) return { anyOf: value.anyOf.map((item) => pagePayload(doc, item)) }
if (value.oneOf) return { oneOf: value.oneOf.map((item) => pagePayload(doc, item)) }
const fields = value.properties ?? {}
if (!fields.data || !fields.nextCursor)
throw new Error('Paged CLI response must declare data and nextCursor')
const flags = Object.entries(fields).filter(
([name, field]) => isTruncationField(name) && resolve(doc, field).type === 'boolean'
)
const required = new Set(value.required ?? [])
return {
type: 'object',
properties: {
data: fields.data,
nextCursor: fields.nextCursor,
...Object.fromEntries(flags),
},
required: [
'data',
'nextCursor',
...flags.filter(([name]) => required.has(name)).map(([name]) => name),
],
}
}
/** Scalar flags already describe themselves; retain the JSON-valued fields within every alternative. */
function request(
doc: ReferenceDocument,
schema: ReferenceSchema,
fields: ReadonlyMap<string, string>
): ReferenceSchema {
const value = resolve(doc, schema)
if (value.anyOf) return { anyOf: value.anyOf.map((item) => request(doc, item, fields)) }
if (value.oneOf) return { oneOf: value.oneOf.map((item) => request(doc, item, fields)) }
if (value.allOf) return { allOf: value.allOf.map((item) => request(doc, item, fields)) }
if (!value.properties) return value
const entries = Object.entries(value.properties).filter(([name]) => fields.has(name))
const properties = Object.fromEntries(
entries.map(([name, schema]) => [fields.get(name)!, schema])
)
const required = (value.required ?? [])
.filter((key) => fields.has(key))
.map((key) => fields.get(key)!)
return { type: 'object', properties, required }
}
export interface CommandReference {
/** JSON stdout after the CLI's paged or resource rendering projection. */
shape?: string
/** JSON-valued request fields; unions retain mutually alternative bodies. */
body?: string
}
/** Extract every documented successful JSON response, including 201/202 and alternate bodies. */
export function commandReference(
documents: readonly ReferenceDocument[],
operation: { path: string; method: string },
jsonFields: ReadonlyMap<string, string>,
paged = false
): CommandReference {
const path = operation.path.replace(/\[([^\]]+)\]/g, '{$1}')
for (const doc of documents) {
const entry = doc.paths[path]?.[operation.method.toLowerCase()]
if (!entry) continue
const shapes = Object.entries(entry.responses ?? {})
.filter(([status]) => /^2\d\d$/.test(status))
.flatMap(([, response]) => {
const schema = response.content?.['application/json']?.schema
return schema
? [label(doc, paged ? pagePayload(doc, schema) : payload(doc, schema), 1, true)]
: []
})
const schema = entry.requestBody?.content?.['application/json']?.schema
return {
...(shapes.length ? { shape: [...new Set(shapes)].join('|') } : {}),
...(schema && jsonFields.size
? { body: label(doc, request(doc, schema, jsonFields), 3, false) }
: {}),
}
}
return {}
}