JsonSchemaPrompt.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299
  1. import Ajv from 'ajv'
  2. import inquirer, { DistinctQuestion } from 'inquirer'
  3. import _ from 'lodash'
  4. import RefParser, { JSONSchema } from '@apidevtools/json-schema-ref-parser'
  5. import chalk from 'chalk'
  6. import { BOOL_PROMPT_OPTIONS } from './prompting'
  7. import { getSchemasLocation } from '@joystream/cd-schemas'
  8. import path from 'path'
  9. type CustomPromptMethod = () => Promise<any>
  10. type CustomPrompt = DistinctQuestion | CustomPromptMethod | { $item: CustomPrompt } | 'skip'
  11. // For the explaination of "string & { x: never }", see: https://github.com/microsoft/TypeScript/issues/29729
  12. // eslint-disable-next-line @typescript-eslint/ban-types
  13. export type JsonSchemaCustomPrompts<T = Record<string, unknown>> = [keyof T | (string & {}) | RegExp, CustomPrompt][]
  14. // Default schema path for resolving refs
  15. // TODO: Would be nice to skip the filename part (but without it it doesn't work)
  16. const DEFAULT_SCHEMA_PATH = getSchemasLocation('entities') + path.sep
  17. export class JsonSchemaPrompter<JsonResult> {
  18. schema: JSONSchema
  19. schemaPath: string
  20. customPropmpts?: JsonSchemaCustomPrompts
  21. ajv: Ajv.Ajv
  22. filledObject: Partial<JsonResult>
  23. constructor(
  24. schema: JSONSchema,
  25. defaults?: Partial<JsonResult>,
  26. customPrompts?: JsonSchemaCustomPrompts,
  27. schemaPath: string = DEFAULT_SCHEMA_PATH
  28. ) {
  29. this.customPropmpts = customPrompts
  30. this.schema = schema
  31. this.schemaPath = schemaPath
  32. // allErrors prevents .validate from setting only one error when in fact there are multiple
  33. this.ajv = new Ajv({ allErrors: true })
  34. this.filledObject = defaults || {}
  35. }
  36. private oneOfToOptions(oneOf: JSONSchema[], currentValue: any) {
  37. let defaultValue: any
  38. const choices: { name: string; value: number | string }[] = []
  39. oneOf.forEach((pSchema, index) => {
  40. if (pSchema.description) {
  41. choices.push({ name: pSchema.description, value: index.toString() })
  42. } else if (pSchema.type === 'object' && pSchema.properties) {
  43. choices.push({ name: `{ ${Object.keys(pSchema.properties).join(', ')} }`, value: index.toString() })
  44. // Supports defaults for enum variants:
  45. if (
  46. typeof currentValue === 'object' &&
  47. currentValue !== null &&
  48. Object.keys(currentValue).join(',') === Object.keys(pSchema.properties).join(',')
  49. ) {
  50. defaultValue = index.toString()
  51. }
  52. } else {
  53. choices.push({ name: index.toString(), value: index.toString() })
  54. }
  55. })
  56. return { choices, default: defaultValue }
  57. }
  58. private getCustomPrompt(propertyPath: string): CustomPrompt | undefined {
  59. const found = this.customPropmpts?.find(([pathToMatch]) =>
  60. pathToMatch instanceof RegExp ? pathToMatch.test(propertyPath) : propertyPath === pathToMatch
  61. )
  62. return found ? found[1] : undefined
  63. }
  64. private propertyDisplayName(propertyPath: string) {
  65. return chalk.green(propertyPath)
  66. }
  67. private async prompt(
  68. schema: JSONSchema,
  69. propertyPath = '',
  70. custom?: CustomPrompt,
  71. allPropsRequired = false
  72. ): Promise<any> {
  73. const customPrompt: CustomPrompt | undefined = custom || this.getCustomPrompt(propertyPath)
  74. const propDisplayName = this.propertyDisplayName(propertyPath)
  75. const currentValue = _.get(this.filledObject, propertyPath)
  76. const type = Array.isArray(schema.type) ? schema.type[0] : schema.type
  77. if (customPrompt === 'skip') {
  78. return
  79. }
  80. // Automatically handle "null" values (useful for enum variants)
  81. if (type === 'null') {
  82. _.set(this.filledObject, propertyPath, null)
  83. return null
  84. }
  85. // Custom prompt
  86. if (typeof customPrompt === 'function') {
  87. return await this.promptWithRetry(customPrompt, propertyPath, true)
  88. }
  89. // oneOf
  90. if (schema.oneOf) {
  91. const oneOf = schema.oneOf as JSONSchema[]
  92. const options = this.oneOfToOptions(oneOf, currentValue)
  93. const choosen = await this.inquirerSinglePrompt({
  94. message: propDisplayName,
  95. type: 'list',
  96. ...options,
  97. })
  98. if (choosen !== options.default) {
  99. _.set(this.filledObject, propertyPath, undefined) // Clear any previous value if different variant selected
  100. }
  101. return await this.prompt(oneOf[parseInt(choosen)], propertyPath)
  102. }
  103. // object
  104. if (type === 'object' && schema.properties) {
  105. const value: Record<string, any> = {}
  106. for (const [pName, pSchema] of Object.entries(schema.properties)) {
  107. const objectPropertyPath = propertyPath ? `${propertyPath}.${pName}` : pName
  108. const propertyCustomPrompt = this.getCustomPrompt(objectPropertyPath)
  109. if (propertyCustomPrompt === 'skip') {
  110. continue
  111. }
  112. let confirmed = true
  113. const required = allPropsRequired || (Array.isArray(schema.required) && schema.required.includes(pName))
  114. if (!required) {
  115. confirmed = await this.inquirerSinglePrompt({
  116. message: `Do you want to provide optional ${chalk.greenBright(objectPropertyPath)}?`,
  117. type: 'confirm',
  118. default:
  119. _.get(this.filledObject, objectPropertyPath) !== undefined &&
  120. _.get(this.filledObject, objectPropertyPath) !== null,
  121. })
  122. }
  123. if (confirmed) {
  124. value[pName] = await this.prompt(pSchema, objectPropertyPath)
  125. } else {
  126. _.set(this.filledObject, objectPropertyPath, null)
  127. }
  128. }
  129. return value
  130. }
  131. // array
  132. if (type === 'array' && schema.items) {
  133. return await this.promptWithRetry(() => this.promptArray(schema, propertyPath), propertyPath, true)
  134. }
  135. // "primitive" values:
  136. const basicPromptOptions: DistinctQuestion = {
  137. message: propDisplayName,
  138. default: currentValue !== undefined ? currentValue : schema.default,
  139. }
  140. let additionalPromptOptions: DistinctQuestion | undefined
  141. let normalizer: (v: any) => any = (v) => v
  142. // Prompt options
  143. if (schema.enum) {
  144. additionalPromptOptions = { type: 'list', choices: schema.enum as any[] }
  145. } else if (type === 'boolean') {
  146. additionalPromptOptions = BOOL_PROMPT_OPTIONS
  147. }
  148. // Normalizers
  149. if (type === 'integer') {
  150. normalizer = (v) => (parseInt(v).toString() === v ? parseInt(v) : v)
  151. }
  152. if (type === 'number') {
  153. normalizer = (v) => (Number(v).toString() === v ? Number(v) : v)
  154. }
  155. const promptOptions = { ...basicPromptOptions, ...additionalPromptOptions, ...customPrompt }
  156. // Need to wrap in retry, because "validate" will not get called if "type" is "list" etc.
  157. return await this.promptWithRetry(
  158. async () => normalizer(await this.promptSimple(promptOptions, propertyPath, normalizer)),
  159. propertyPath
  160. )
  161. }
  162. private setValueAndGetError(propertyPath: string, value: any, nestedErrors = false): string | null {
  163. _.set(this.filledObject as Record<string, unknown>, propertyPath, value)
  164. this.ajv.validate(this.schema, this.filledObject) as boolean
  165. return this.ajv.errors
  166. ? this.ajv.errors
  167. .filter((e) => (nestedErrors ? e.dataPath.startsWith(`.${propertyPath}`) : e.dataPath === `.${propertyPath}`))
  168. .map((e) => (e.dataPath.replace(`.${propertyPath}`, '') || 'This value') + ` ${e.message}`)
  169. .join(', ')
  170. : null
  171. }
  172. private async promptArray(schema: JSONSchema, propertyPath: string) {
  173. if (!schema.items) {
  174. return []
  175. }
  176. const { maxItems = Number.MAX_SAFE_INTEGER } = schema
  177. let currItem = 0
  178. const result = []
  179. while (currItem < maxItems) {
  180. const next = await this.inquirerSinglePrompt({
  181. ...BOOL_PROMPT_OPTIONS,
  182. message: `Do you want to add another item to ${this.propertyDisplayName(propertyPath)} array?`,
  183. default: _.get(this.filledObject, `${propertyPath}[${currItem}]`) !== undefined,
  184. })
  185. if (!next) {
  186. break
  187. }
  188. const itemSchema = Array.isArray(schema.items) ? schema.items[schema.items.length % currItem] : schema.items
  189. result.push(await this.prompt(typeof itemSchema === 'boolean' ? {} : itemSchema, `${propertyPath}[${currItem}]`))
  190. ++currItem
  191. }
  192. return result
  193. }
  194. private async promptSimple(promptOptions: DistinctQuestion, propertyPath: string, normalize?: (v: any) => any) {
  195. const result = await this.inquirerSinglePrompt({
  196. ...promptOptions,
  197. validate: (v) => {
  198. v = normalize ? normalize(v) : v
  199. return (
  200. this.setValueAndGetError(propertyPath, v) ||
  201. (promptOptions.validate ? promptOptions.validate(v) : true) ||
  202. true
  203. )
  204. },
  205. })
  206. return result
  207. }
  208. private async promptWithRetry(customMethod: CustomPromptMethod, propertyPath: string, nestedErrors = false) {
  209. let error: string | null
  210. let value: any
  211. do {
  212. value = await customMethod()
  213. error = this.setValueAndGetError(propertyPath, value, nestedErrors)
  214. if (error) {
  215. console.log('\n')
  216. console.log('Provided value:', value)
  217. console.warn(`ERROR: ${error}`)
  218. console.warn(`Try providing the input for ${propertyPath} again...`)
  219. }
  220. } while (error)
  221. return value
  222. }
  223. async getMainSchema() {
  224. return await RefParser.dereference(this.schemaPath, this.schema, {})
  225. }
  226. async promptAll(allPropsRequired = false) {
  227. await this.prompt(await this.getMainSchema(), '', undefined, allPropsRequired)
  228. return this.filledObject as JsonResult
  229. }
  230. async promptMultipleProps<P extends keyof JsonResult & string, PA extends readonly P[]>(
  231. props: PA
  232. ): Promise<{ [K in PA[number]]: Exclude<JsonResult[K], undefined> }> {
  233. const result: Partial<{ [K in PA[number]]: Exclude<JsonResult[K], undefined> }> = {}
  234. for (const prop of props) {
  235. result[prop] = await this.promptSingleProp(prop)
  236. }
  237. return result as { [K in PA[number]]: Exclude<JsonResult[K], undefined> }
  238. }
  239. async promptSingleProp<P extends keyof JsonResult & string>(
  240. p: P,
  241. customPrompt?: CustomPrompt
  242. ): Promise<Exclude<JsonResult[P], undefined>> {
  243. const mainSchema = await this.getMainSchema()
  244. await this.prompt(mainSchema.properties![p] as JSONSchema, p, customPrompt)
  245. return this.filledObject[p] as Exclude<JsonResult[P], undefined>
  246. }
  247. async inquirerSinglePrompt(question: DistinctQuestion) {
  248. const { result } = await inquirer.prompt([
  249. {
  250. ...question,
  251. name: 'result',
  252. prefix: '',
  253. },
  254. ])
  255. return result
  256. }
  257. }