UNPKG

@firebolt-js/openrpc

Version:
1,303 lines (1,117 loc) 98.1 kB
/* * Copyright 2021 Comcast Cable Communications Management, LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * * SPDX-License-Identifier: Apache-2.0 */ import helpers from 'crocks/helpers/index.js' const { compose, getPathOr } = helpers import safe from 'crocks/Maybe/safe.js' import find from 'crocks/Maybe/find.js' import getPath from 'crocks/Maybe/getPath.js' import pointfree from 'crocks/pointfree/index.js' const { chain, filter, option, map, reduce } = pointfree import logic from 'crocks/logic/index.js' const { and, not } = logic import isString from 'crocks/core/isString.js' import predicates from 'crocks/predicates/index.js' const { isObject, isArray, propEq, pathSatisfies, propSatisfies } = predicates import { isRPCOnlyMethod, isProviderInterfaceMethod, getProviderInterface, getPayloadFromEvent, providerHasNoParameters, isTemporalSetMethod, hasMethodAttributes, getMethodAttributes, isEventMethodWithContext, getSemanticVersion, getSetterFor, getProvidedCapabilities, isPolymorphicPullMethod, hasPublicAPIs, isAllowFocusMethod, hasAllowFocusMethods, createPolymorphicMethods, isExcludedMethod, isCallsMetricsMethod } from '../shared/modules.mjs' import isEmpty from 'crocks/core/isEmpty.js' import { getPath as getJsonPath, getLinkedSchemaPaths, getSchemaConstraints, isSchema, localizeDependencies, isDefinitionReferencedBySchema, mergeAnyOf, mergeOneOf, getSafeEnumKeyName } from '../shared/json-schema.mjs' // util for visually debugging crocks ADTs const _inspector = obj => { if (obj.inspect) { console.log(obj.inspect()) } else { console.log(obj) } } // getSchemaType(schema, module, options = { destination: 'file.txt', title: true }) // getSchemaShape(schema, module, options = { name: 'Foo', destination: 'file.txt' }) // getJsonType(schema, module, options = { name: 'Foo', prefix: '', descriptions: false, level: 0 }) // getSchemaInstantiation(schema, module, options = {type: 'params' | 'result' | 'callback.params'| 'callback.result' | 'callback.response'}) let types = { getSchemaShape: () => null, getSchemaType: () => null } let config = { copySchemasIntoModules: false, extractSubSchemas: false, unwrapResultObjects: false, excludeDeclarations: false, extractProviderSchema: false, } const state = { destination: undefined, typeTemplateDir: 'types', section: undefined } const capitalize = str => str[0].toUpperCase() + str.substr(1) const indent = (str, paddingStr, repeat = 1, endRepeat = 0) => { let first = true let padding = '' for (let i = 0; i < repeat; i++) { padding += paddingStr } let length = str.split('\n').length - 1 let endPadding = '' for (let i = 0; length && i < endRepeat; i++) { endPadding += paddingStr } return str.split('\n').map((line, index) => { if (first) { first = false return line } else if (index === length && endPadding) { return endPadding + line } else { return padding + line } }).join('\n') } const setTyper = (t) => { types = t } const setConfig = (c) => { config = c } const getTemplate = (name, templates) => { return templates[Object.keys(templates).find(k => k === name)] || templates[Object.keys(templates).find(k => k.startsWith(name + '.'))] || '' } const getTemplateTypeForMethod = (method, type, templates) => { const name = method.tags ? (isAllowFocusMethod(method) && Object.keys(templates).find(name => name.startsWith(`/${type}/allowsFocus.`))) ? 'allowsFocus' : (method.tags.map(tag => tag.name.split(":").shift()).find(tag => Object.keys(templates).find(name => name.startsWith(`/${type}/${tag}.`)))) || 'default' : 'default' const path = `/${type}/${name}` return getTemplate(path, templates) } const getTemplateForMethod = (method, templates, templateDir) => { return getTemplateTypeForMethod(method, templateDir, templates) } const getTemplateForDeclaration = (method, templates, templateDir) => { return getTemplateTypeForMethod(method, templateDir, templates) } const getTemplateForExample = (method, templates) => { return getTemplateTypeForMethod(method, 'examples', templates) } const getTemplateForExampleResult = (method, templates) => { const template = getTemplateTypeForMethod(method, 'examples/results', templates) return template || JSON.stringify(method.examples[0].result.value, null, '\t') } const getLinkForSchema = (schema, json) => { const dirs = config.createModuleDirectories const copySchemasIntoModules = config.copySchemasIntoModules const type = types.getSchemaType(schema, json, { templateDir: state.typeTemplateDir, destination: state.destination, section: state.section }) // local - insert a bogus link, that we'll update later based on final table-of-contents if (json.components.schemas[type]) { return `#\$\{LINK:schema:${type}\}` } else { const [group, schema] = Object.entries(json['x-schemas']).find(([key, value]) => json['x-schemas'][key] && json['x-schemas'][key][type]) || [null, null] if (group && schema) { if (copySchemasIntoModules) { return `#\$\{LINK:schema:${type}\}` } else { const base = dirs ? '..' : '.' if (dirs) { return `${base}/${group}/schemas/#${type}` } else { return `${base}/schemas/${group}.md#${type}` } } } } return '#' } const getComponentExternalSchema = (json) => { let refSchemas = [] if (json.components && json.components.schemas) { Object.entries(json.components.schemas).forEach(([name, schema]) => { let refs = getLinkedSchemaPaths(schema).map(path => getPathOr(null, path, schema)) refs.map(ref => { let title = '' if (ref.includes('x-schemas')) { if (ref.split('/')[2] !== json.info.title) { title = ref.split('/')[2] } } title && !refSchemas.includes(title) ? refSchemas.push(title) : null }) }) } return (refSchemas) } // Maybe methods array of objects const getMethods = compose( map(filter(isObject)), chain(safe(isArray)), getPath(['methods']) ) const getSchemas = compose( map(Object.entries), // Maybe Array<Array<key, value>> chain(safe(isObject)), // Maybe Object getPath(['components', 'schemas']) // Maybe any ) // TODO: import from shared/modules.mjs const isDeprecatedMethod = compose( option(false), map(_ => true), chain(find(propEq('name', 'deprecated'))), getPath(['tags']) ) const getAlternativeMethod = compose( option(null), map(tag => tag['x-alternative']), chain(find(propSatisfies('x-alternative', not(isEmpty)))), getPath(['tags']) ) // TODO: import from shared/modules.mjs const isPublicEventMethod = and( compose( option(true), map(_ => false), chain(find(propEq('name', 'rpc-only'))), getPath(['tags']) ), compose( option(false), map(_ => true), chain( find( and( propEq('name', 'event'), propSatisfies('x-provides', isEmpty) ) ) ), getPath(['tags']) ) ) // TODO: import from shared/modules.mjs const isEventMethod = compose( option(false), map(_ => true), chain(find(propEq('name', 'event'))), getPath(['tags']) ) const isSynchronousMethod = compose( option(false), map(_ => true), chain(find(propEq('name', 'synchronous'))), getPath(['tags']) ) const methodHasExamples = compose( option(false), map(isObject), getPath(['examples', 0]) ) const validEvent = and( pathSatisfies(['name'], isString), pathSatisfies(['name'], x => x.match(/on[A-Z]/)) ) const hasTag = (method, tag) => { return method.tags && method.tags.filter(t => t.name === tag).length > 0 } const isPropertyMethod = (m) => { return hasTag(m, 'property') || hasTag(m, 'property:immutable') || hasTag(m, 'property:readonly') } const eventHasOptionalParam = (event) => { return event.params.length && event.params.find(param => !(param.required && param.required === true)) } const isGlobalSubscriber = (method) => { return method.tags && method.tags.some(tag => tag['x-subscriber-type'] === 'global'); } const isOptionalParam = (param) => { return (!(param.required && param.required === true)) } // Pick methods that call RCP out of the methods array const rpcMethodsOrEmptyArray = compose( option([]), map(filter(not(isSynchronousMethod))), getMethods ) // Pick events out of the methods array const eventsOrEmptyArray = compose( option([]), map(filter(validEvent)), // Maintain the side effect of process.exit here if someone is violating the rules map(map(e => { if (!e.name.match(/on[A-Z]/)) { console.error(`ERROR: ${e.name} method is tagged as an event, but does not match the pattern "on[A-Z]"`) process.kill(process.pid) // Using process.kill so that other worspaces all exit (and don't bury this error w/ logs) } return e })), map(filter(isPublicEventMethod)), getMethods ) const temporalSets = compose( option([]), map(filter(isTemporalSetMethod)), getMethods ) const callsMetrics = compose( option([]), map(filter(not(isExcludedMethod))), map(filter(isCallsMetricsMethod)), getMethods ) const methodsWithXMethodsInResult = compose( option([]), map(filter(hasMethodAttributes)), getMethods ) // Find all provided capabilities const providedCapabilitiesOrEmptyArray = compose( option([]), map(caps => [... new Set(caps)]), map(map(m => m.tags.find(t => t['x-provides'])['x-provides'])), // grab the capabilty it provides map(filter(isProviderInterfaceMethod)), getMethods ) // Pick providers out of the methods array const providersOrEmptyArray = compose( option([]), map(filter(validEvent)), // Maintain the side effect of process.exit here if someone is violating the rules map(map(e => { if (!e.name.match(/on[A-Z]/)) { console.error(`ERROR: ${e.name} method is tagged as an event, but does not match the pattern "on[A-Z]"`) process.exit(1) // Non-zero exit since we don't want to continue. Useful for CI/CD pipelines. } return e })), map(filter(isProviderInterfaceMethod)), getMethods ) // Pick deprecated methods out of the methods array const deprecatedOrEmptyArray = compose( option([]), map(filter(isDeprecatedMethod)), getMethods ) const getGlobalSubscribers = compose( option([]), map(filter(isGlobalSubscriber)), getMethods ) const props = compose( option([]), map(filter(m => isPropertyMethod(m))), getMethods ) const getModuleName = json => { return json ? (json.title || (json.info ? json.info.title : 'Unknown')) : 'Unknown' } const makeEventName = x => x.name[2].toLowerCase() + x.name.substr(3) // onFooBar becomes fooBar const makeProviderMethod = x => x.name["onRequest".length].toLowerCase() + x.name.substr("onRequest".length + 1) // onRequestChallenge becomes challenge //import { default as platform } from '../Platform/defaults' const generateAggregateMacros = (openrpc, modules, templates, library) => Object.values(modules) .reduce((acc, module) => { let template = getTemplate('/codeblocks/export', templates) if (template) { acc.exports += insertMacros(template + '\n', generateMacros(module, templates)) } template = getTemplate('/codeblocks/mock-import', templates) if (template) { acc.mockImports += insertMacros(template + '\n', generateMacros(module, templates)) } template = getTemplate('/codeblocks/mock-parameter', templates) if (template) { acc.mockObjects += insertMacros(template + '\n', generateMacros(module, templates)) } return acc }, { exports: '', mockImports: '', mockObjects: '', version: getSemanticVersion(openrpc), library: library }) const addContentDescriptorSubSchema = (descriptor, prefix, obj) => { const title = getPromotionNameFromContentDescriptor(descriptor, prefix) promoteSchema(descriptor, 'schema', title, obj, "#/components/schemas") } const getPromotionNameFromContentDescriptor = (descriptor, prefix) => { const subtitle = descriptor.schema.title || descriptor.name.charAt(0).toUpperCase() + descriptor.name.substring(1) return (prefix ? prefix.charAt(0).toUpperCase() + prefix.substring(1) : '') + subtitle } const promoteSchema = (location, property, title, document, destinationPath) => { const destination = getJsonPath(destinationPath, document) destination[title] = location[property] destination[title].title = title location[property] = { $ref: `${destinationPath}/${title}` } } // only consider sub-objects and sub enums to be sub-schemas const isSubSchema = (schema) => schema.type === 'object' || (schema.type === 'string' && schema.enum) // check schema is sub enum of array const isSubEnumOfArraySchema = (schema) => (schema.type === 'array' && schema.items.enum) const promoteAndNameSubSchemas = (obj) => { // make a copy so we don't polute our inputs obj = JSON.parse(JSON.stringify(obj)) // find anonymous method param or result schemas and name/promote them obj.methods && obj.methods.forEach(method => { method.params && method.params.forEach(param => { if (isSubSchema(param.schema)) { addContentDescriptorSubSchema(param, '', obj) } }) if (isSubSchema(method.result.schema)) { addContentDescriptorSubSchema(method.result, '', obj) } else if (isEventMethod(method) && isSubSchema(getPayloadFromEvent(method))) { // TODO: the `1` below is brittle... should find the index of the non-ListenResponse schema promoteSchema(method.result.schema.anyOf, 1, getPromotionNameFromContentDescriptor(method.result, ''), obj, '#/components/schemas') } if (method.tags.find(t => t['x-error'])) { method.tags.forEach(tag => { if (tag['x-error']) { const descriptor = { name: obj.info.title + 'Error', schema: tag['x-error'] } addContentDescriptorSubSchema(descriptor, '', obj) } }) } }) // find non-primitive sub-schemas of components.schemas and name/promote them if (obj.components && obj.components.schemas) { let more = true while (more) { more = false Object.entries(obj.components.schemas).forEach(([key, schema]) => { let componentSchemaProperties = schema.allOf ? schema.allOf : [schema] componentSchemaProperties.forEach((componentSchema) => { if ((componentSchema.type === "object") && componentSchema.properties) { Object.entries(componentSchema.properties).forEach(([name, propSchema]) => { if (isSubSchema(propSchema)) { more = true const descriptor = { name: name, schema: propSchema } addContentDescriptorSubSchema(descriptor, key, obj) componentSchema.properties[name] = descriptor.schema } if (isSubEnumOfArraySchema(propSchema)) { const descriptor = { name: name, schema: propSchema.items } addContentDescriptorSubSchema(descriptor, key, obj) componentSchema.properties[name].items = descriptor.schema } }) } }) if (!schema.title) { schema.title = capitalize(key) } }) } } return obj } const generateMacros = (obj, templates, languages, options = {}) => { if (options.createPolymorphicMethods) { let methods = [] obj.methods && obj.methods.forEach(method => { let polymorphicMethods = createPolymorphicMethods(method, obj) if (polymorphicMethods.length > 1) { polymorphicMethods.forEach(polymorphicMethod => { methods.push(polymorphicMethod) }) } else { methods.push(method) } }) obj.methods = methods } // for languages that don't support nested schemas, let's promote them to first-class schemas w/ titles if (config.extractSubSchemas) { obj = promoteAndNameSubSchemas(obj) } // grab the options so we don't have to pass them from method to method Object.assign(state, options) const macros = { schemas: {}, types: {}, enums: {}, methods: {}, events: {}, methodList: '', eventList: '' } Array.from(new Set(['types'].concat(config.additionalSchemaTemplates))).filter(dir => dir).forEach(dir => { state.typeTemplateDir = dir const schemasArray = generateSchemas(obj, templates, { baseUrl: '', section: 'schemas' }).filter(s => (options.copySchemasIntoModules || !s.uri)) macros.schemas[dir] = getTemplate('/sections/schemas', templates).replace(/\$\{schema.list\}/g, schemasArray.map(s => s.body).filter(body => body).join('\n')) macros.types[dir] = getTemplate('/sections/types', templates).replace(/\$\{schema.list\}/g, schemasArray.filter(x => !x.enum).map(s => s.body).filter(body => body).join('\n')) macros.enums[dir] = getTemplate('/sections/enums', templates).replace(/\$\{schema.list\}/g, schemasArray.filter(x => x.enum).map(s => s.body).filter(body => body).join('\n')) }) state.typeTemplateDir = 'types' const imports = generateImports(obj, templates, { destination: (options.destination ? options.destination : '') }) const initialization = generateInitialization(obj, templates) const eventsEnum = generateEvents(obj, templates) const examples = generateExamples(obj, templates, languages) const allMethodsArray = generateMethods(obj, examples, templates, languages, options.type) Array.from(new Set(['methods'].concat(config.additionalMethodTemplates))).filter(dir => dir).forEach(dir => { if (dir.includes('declarations')) { const declarationsArray = allMethodsArray.filter(m => m.declaration[dir] && (!config.excludeDeclarations || (!options.hideExcluded || !m.excluded))) macros.methods[dir] = declarationsArray.length ? getTemplate('/sections/declarations', templates).replace(/\$\{declaration\.list\}/g, declarationsArray.map(m => m.declaration[dir]).join('\n')) : '' } else if (dir.includes('methods')) { const publicMethodsArray = allMethodsArray.filter(m => m.body[dir] && !m.event && (!options.hideExcluded || !m.excluded) && !m.private) const privateMethodsArray = allMethodsArray.filter(m => m.body[dir] && !m.event && (!options.hideExcluded || !m.excluded) && m.private) const methodSection = (template, arr) => { const regex = template.endsWith('events') ? /\$\{event.list\}/g : /\$\{method.list\}/g return arr.length ? getTemplate('/sections/' + template, templates).replace(regex, arr.map(m => m.body[dir]).join('\n')) : '' } macros.methods.methods = methodSection('methods', publicMethodsArray) macros.methods.private = methodSection('private-methods', privateMethodsArray) const publicEventsArray = allMethodsArray.filter(m => m.body[dir] && m.event && (!options.hideExcluded || !m.excluded) && !m.private) const privateEventsArray = allMethodsArray.filter(m => m.body[dir] && m.event && (!options.hideExcluded || !m.excluded && m.private)) macros.events.methods = methodSection('events', publicEventsArray) macros.events.private = methodSection('private-events', privateEventsArray) if (dir === 'methods') { macros.methodList = publicMethodsArray.filter(m => m.body).map(m => m.name) macros.eventList = publicEventsArray.map(m => makeEventName(m)) } } }) const xusesInterfaces = generateXUsesInterfaces(obj, templates) const providerSubscribe = generateProviderSubscribe(obj, templates) const providerInterfaces = generateProviderInterfaces(obj, templates) const defaults = generateDefaults(obj, templates) const suffix = options.destination ? options.destination.split('.').pop().trim() : '' const module = getTemplate('/codeblocks/module', templates) const moduleInclude = getTemplate(suffix ? `/codeblocks/module-include.${suffix}` : '/codeblocks/module-include', templates) const moduleIncludePrivate = getTemplate(suffix ? `/codeblocks/module-include-private.${suffix}` : '/codeblocks/module-include-private', templates) const moduleInit = getTemplate(suffix ? `/codeblocks/module-init.${suffix}` : '/codeblocks/module-init', templates) Object.assign(macros, { imports, initialization, eventsEnum, defaults, examples, xusesInterfaces, providerInterfaces, providerSubscribe, version: getSemanticVersion(obj), title: obj.info.title, description: obj.info.description, module: module, moduleInclude: moduleInclude, moduleIncludePrivate: moduleIncludePrivate, moduleInit: moduleInit, public: hasPublicAPIs(obj) }) return macros } const clearMacros = (fContents = '') => { fContents = fContents.replace(/\$\{module\.includes\}/g, "") fContents = fContents.replace(/\$\{module\.includes\.private\}/g, "") fContents = fContents.replace(/\$\{module\.init\}/g, "") return fContents } const insertAggregateMacros = (fContents = '', aggregateMacros = {}) => { fContents = fContents.replace(/[ \t]*\/\* \$\{EXPORTS\} \*\/[ \t]*\n/, aggregateMacros.exports) fContents = fContents.replace(/[ \t]*\/\* \$\{MOCK_IMPORTS\} \*\/[ \t]*\n/, aggregateMacros.mockImports) fContents = fContents.replace(/[ \t]*\/\* \$\{MOCK_OBJECTS\} \*\/[ \t]*\n/, aggregateMacros.mockObjects) fContents = fContents.replace(/\$\{readable\}/g, aggregateMacros.version.readable) fContents = fContents.replace(/\$\{package.name\}/g, aggregateMacros.library) return fContents } const insertMacros = (fContents = '', macros = {}) => { if (macros.append && macros.module) { fContents += '\n' + macros.module } const quote = config.operators ? config.operators.stringQuotation : '"' const or = config.operators ? config.operators.or : ' | ' fContents = fContents.replace(/\$\{if\.types\}(.*?)\$\{end\.if\.types\}/gms, macros.types.types.trim() ? '$1' : '') fContents = fContents.replace(/\$\{if\.schemas\}(.*?)\$\{end\.if\.schemas\}/gms, macros.schemas.types.trim() ? '$1' : '') fContents = fContents.replace(/\$\{if\.enums\}(.*?)\$\{end\.if\.enums\}/gms, macros.enums.types.trim() ? '$1' : '') fContents = fContents.replace(/\$\{if\.declarations\}(.*?)\$\{end\.if\.declarations\}/gms, (macros.methods.declarations && macros.methods.declarations.trim() || macros.enums.types.trim()) || macros.types.types.trim()? '$1' : '') fContents = fContents.replace(/\$\{module\.list\}/g, macros.module) fContents = fContents.replace(/\$\{module\.includes\}/g, macros.moduleInclude) fContents = fContents.replace(/\$\{module\.includes\.private\}/g, macros.moduleIncludePrivate) fContents = fContents.replace(/\$\{module\.init\}/g, macros.moduleInit) let methods = '' Array.from(new Set(['methods'].concat(config.additionalMethodTemplates))).filter(dir => dir).every(dir => { if (macros.methods[dir]) { methods = macros.methods[dir] return false } return true }) fContents = fContents.replace(/\$\{if\.methods\}(.*?)\$\{end\.if\.methods\}/gms, methods.trim() || macros.events.methods.trim() ? '$1' : '') fContents = fContents.replace(/\$\{if\.implementations\}(.*?)\$\{end\.if\.implementations\}/gms, (methods.trim() || macros.events.methods.trim() || macros.schemas.types.trim()) ? '$1' : '') fContents = fContents.replace(/\$\{if\.modules\}(.*?)\$\{end\.if\.modules\}/gms, (methods.trim() || macros.events.methods.trim()) ? '$1' : '') fContents = fContents.replace(/\$\{if\.xuses\}(.*?)\$\{end\.if\.xuses\}/gms, macros.xusesInterfaces.trim() ? '$1' : '') fContents = fContents.replace(/\$\{if\.providers\}(.*?)\$\{end\.if\.providers\}/gms, macros.providerInterfaces.trim() ? '$1' : '') // Output the originally supported non-configurable methods & events macros fContents = fContents.replace(/[ \t]*\/\* \$\{METHODS\} \*\/[ \t]*\n/, macros.methods.methods) fContents = fContents.replace(/[ \t]*\/\* \$\{PRIVATE_METHODS\} \*\/[ \t]*\n/, macros.methods.private) fContents = fContents.replace(/[ \t]*\/\* \$\{METHOD_LIST\} \*\/[ \t]*\n/, macros.methodList.join(',\n')) fContents = fContents.replace(/[ \t]*\/\* \$\{EVENTS\} \*\/[ \t]*\n/, macros.events.methods) fContents = fContents.replace(/[ \t]*\/\* \$\{PRIVATE_EVENTS\} \*\/[ \t]*\n/, macros.events.private) fContents = fContents.replace(/[ \t]*\/\* \$\{EVENT_LIST\} \*\/[ \t]*\n/, macros.eventList.join(',')) fContents = fContents.replace(/[ \t]*\/\* \$\{EVENTS_ENUM\} \*\/[ \t]*\n/, macros.eventsEnum) // Output all declarations, methods & events with all dynamically configured templates Array.from(new Set(['methods'].concat(config.additionalMethodTemplates))).filter(dir => dir).forEach(dir => { ['METHODS', 'EVENTS'].forEach(type => { const regex = new RegExp('[ \\t]*\\/\\* \\$\\{' + type + '\\:' + dir + '\\} \\*\\/[ \\t]*\\n', 'g') fContents = fContents.replace(regex, macros[type.toLowerCase()][dir]) }) }) // Output the originally supported non-configurable schema macros fContents = fContents.replace(/[ \t]*\/\* \$\{SCHEMAS\} \*\/[ \t]*\n/, macros.schemas.types) fContents = fContents.replace(/[ \t]*\/\* \$\{TYPES\} \*\/[ \t]*\n/, macros.types.types) fContents = fContents.replace(/[ \t]*\/\* \$\{ENUMS\} \*\/[ \t]*\n/, macros.enums.types) // Output all schemas with all dynamically configured templates Array.from(new Set(['types'].concat(config.additionalSchemaTemplates))).filter(dir => dir).forEach(dir => { ['SCHEMAS', 'TYPES', 'ENUMS'].forEach(type => { const regex = new RegExp('[ \\t]*\\/\\* \\$\\{' + type + '\\:' + dir + '\\} \\*\\/[ \\t]*\\n', 'g') fContents = fContents.replace(regex, macros[type.toLowerCase()][dir]) }) }) fContents = fContents.replace(/[ \t]*\/\* \$\{PROVIDERS\} \*\/[ \t]*\n/, macros.providerInterfaces) fContents = fContents.replace(/[ \t]*\/\* \$\{XUSES\} \*\/[ \t]*\n/, macros.xusesInterfaces) fContents = fContents.replace(/[ \t]*\/\* \$\{PROVIDERS_SUBSCRIBE\} \*\/[ \t]*\n/, macros.providerSubscribe) fContents = fContents.replace(/[ \t]*\/\* \$\{IMPORTS\} \*\/[ \t]*\n/, macros.imports) fContents = fContents.replace(/[ \t]*\/\* \$\{INITIALIZATION\} \*\/[ \t]*\n/, macros.initialization) fContents = fContents.replace(/[ \t]*\/\* \$\{DEFAULTS\} \*\/[ \t]*\n/, macros.defaults) fContents = fContents.replace(/\$\{events.array\}/g, JSON.stringify(macros.eventList)) fContents = fContents.replace(/\$\{events\}/g, macros.eventList.map(e => `${quote}${e}${quote}`).join(or)) fContents = fContents.replace(/\$\{major\}/g, macros.version.major) fContents = fContents.replace(/\$\{minor\}/g, macros.version.minor) fContents = fContents.replace(/\$\{patch\}/g, macros.version.patch) fContents = fContents.replace(/\$\{info\.title\}/g, macros.title) fContents = fContents.replace(/\$\{info\.title\.lowercase\}/g, macros.title.toLowerCase()) fContents = fContents.replace(/\$\{info\.Title\}/g, capitalize(macros.title)) fContents = fContents.replace(/\$\{info\.TITLE\}/g, macros.title.toUpperCase()) fContents = fContents.replace(/\$\{info\.description\}/g, macros.description) fContents = fContents.replace(/\$\{info\.version\}/g, macros.version.readable) if (macros.public) { fContents = fContents.replace(/\$\{if\.public\}(.*?)\$\{end\.if\.public\}/gms, '$1') } else { fContents = fContents.replace(/\$\{if\.public\}.*?\$\{end\.if\.public\}/gms, '') } if (macros.eventList.length) { fContents = fContents.replace(/\$\{if\.events\}(.*?)\$\{end\.if\.events\}/gms, '$1') } else { fContents = fContents.replace(/\$\{if\.events\}.*?\$\{end\.if\.events\}/gms, '') } const examples = [...fContents.matchAll(/0 \/\* \$\{EXAMPLE\:(.*?)\} \*\//g)] examples.forEach((match) => { fContents = fContents.replace(match[0], JSON.stringify(macros.examples[match[1]][0].value)) }) fContents = insertTableofContents(fContents) return fContents } function insertTableofContents(content) { let toc = '' const count = {} const slugger = title => title.toLowerCase().replace(/ /g, '-').replace(/-+/g, '-').replace(/[^a-zA-Z-]/g, '') let collapsedContentLevel = null content.split('\n').filter(line => line.match(/^\#/)).map(line => { const match = line.match(/^(\#+) (.*)/) if (match) { const level = match[1].length if (level > 1 && level < 4) { if (collapsedContentLevel === level) { // we are back to the level we started the collapsed content, end the collapse toc += ' ' + ' '.repeat(collapsedContentLevel) + '</details>\n' collapsedContentLevel = null } const title = match[2] const slug = slugger(title) if (count.hasOwnProperty(slug)) { count[slug] += 1 } else { count[slug] = 0 } const link = '#' + slug + (count[slug] ? `-${count[slug]}` : '') toc += ' ' + ' '.repeat(level - 1) + `- [${title}](${link})` if (title === 'Private Methods' || title === 'Private Events') { let anchor = title === 'Private Methods' ? 'private-methods-details' : 'private-events-details' toc += '<details ontoggle="document.getElementById(\'' + anchor + '\').open=this.open"><summary>Show</summary>\n' collapsedContentLevel = level } else { toc += '\n' } } } }).join('\n') content = content.replace(/\$\{toc\}/g, toc) const matches = [...content.matchAll(/\$\{LINK\:([a-zA-Z]+)\:([a-zA-Z]+)\}/g)] matches.forEach(match => { const candidates = toc.split('\n').filter(line => line.indexOf(`](#${slugger(match[2])}`) >= 0) const index = candidates.findIndex(line => line.indexOf(`- [${match[2]}](`) >= 0) let extra = '' // add '-1' to schemas when there's more than once match if (index > 0 && match[1] === 'schema') { extra = '-1' } content = content.replace(match[0], `${slugger(match[2])}${extra}`) }) // replace empty links with normal text content = content.replace(/\[(.*?)\]\(\#\)/g, '$1') return content } const convertEnumTemplate = (schema, templateName, templates) => { let enumSchema = isArraySchema(schema) ? schema.items : schema const template = getTemplate(templateName, templates).split('\n') for (var i = 0; i < template.length; i++) { if (template[i].indexOf('${key}') >= 0) { template[i] = enumSchema.enum.map((value, id) => { const safeName = getSafeEnumKeyName(value) return template[i].replace(/\$\{key\}/g, safeName) .replace(/\$\{value\}/g, value) .replace(/\$\{delimiter\}(.*?)\$\{end.delimiter\}/g, id === enumSchema.enum.length - 1 ? '' : '$1') }).join('\n') } } return template.join('\n') .replace(/\$\{title\}/g, capitalize(schema.title)) .replace(/\$\{description\}/g, schema.description ? ('- ' + schema.description) : '') .replace(/\$\{name\}/g, schema.title) .replace(/\$\{NAME\}/g, schema.title.toUpperCase()) } const enumFinder = compose( filter(x => isEnum(x)), map(([_, val]) => val), filter(([_key, val]) => isObject(val)) ) const generateEnums = (json, templates, options = { destination: '' }) => { const suffix = options.destination.split('.').pop() return compose( option(''), map(val => { let template = val ? getTemplate(`/sections/enum.${suffix}`, templates) : val return template ? template.replace(/\$\{schema.list\}/g, val.trimEnd()) : val }), map(reduce((acc, val) => acc.concat(val).concat('\n'), '')), map(map((schema) => convertEnumTemplate(schema, suffix ? `/types/enum.${suffix}` : '/types/enum', templates))), map(enumFinder), getSchemas )(json) } const generateEvents = (json, templates) => { const eventNames = eventsOrEmptyArray(json).map(makeEventName) const obj = eventNames.reduce((acc, val, i, arr) => { if (!acc) { acc = { components: { schemas: { events: { title: "events", type: "string", enum: [] } } } } } acc.components.schemas.events.enum.push(val) return acc }, null) return generateEnums(obj, templates) } function generateDefaults(json = {}, templates) { const reducer = compose( reduce((acc, val, i, arr) => { if (isPropertyMethod(val)) { acc += insertMethodMacros(getTemplate('/defaults/property', templates), val, json, templates) } else if (val.tags.find(t => t.name === "setter")) { acc += insertMethodMacros(getTemplate('/defaults/setter', templates), val, json, templates) } else { acc += insertMethodMacros(getTemplate('/defaults/default', templates), val, json, templates) } if (i < arr.length - 1) { acc = acc.concat(',\n') } else { acc = acc.concat('\n') } return acc }, ''), compose( option([]), map(filter(and(not(isEventMethod), methodHasExamples))), getMethods ), ) return reducer(json) } function sortSchemasByReference(schemas = []) { let indexA = 0; while (indexA < schemas.length) { let swapped = false for (let indexB = indexA + 1; indexB < schemas.length; ++indexB) { const bInA = isDefinitionReferencedBySchema('#/components/schemas/' + schemas[indexB][0], schemas[indexA][1]) if ((isEnum(schemas[indexB][1]) && !isEnum(schemas[indexA][1])) || (bInA === true)) { [schemas[indexA], schemas[indexB]] = [schemas[indexB], schemas[indexA]] swapped = true break } } indexA = swapped ? indexA : ++indexA } return schemas } const isArraySchema = x => x.type && x.type === 'array' && x.items const isEnum = x => { let schema = isArraySchema(x) ? x.items : x return schema.type && schema.type === 'string' && Array.isArray(schema.enum) && x.title } function generateSchemas(json, templates, options) { let results = [] const schemas = JSON.parse(JSON.stringify(json.definitions || (json.components && json.components.schemas) || {})) const generate = (name, schema, uri, { prefix = '' } = {}) => { // these are internal schemas used by the fireboltize-openrpc tooling, and not meant to be used in code/doc generation if (['ListenResponse', 'ProviderRequest', 'ProviderResponse', 'FederatedResponse', 'FederatedRequest'].includes(name)) { return } let content = getTemplate('/schemas/default', templates) if (!schema.examples || schema.examples.length === 0) { content = content.replace(/\$\{if\.examples\}.*?\{end\.if\.examples\}/gms, '') } else { content = content.replace(/\$\{if\.examples\}(.*?)\{end\.if\.examples\}/gms, '$1') } if (!schema.description) { content = content.replace(/\$\{if\.description\}.*?\{end\.if\.description\}/gms, '') } else { content = content.replace(/\$\{if\.description\}(.*?)\{end\.if\.description\}/gms, '$1') } // Schema title is requuired for proper documentation generation if (!schema.title) schema.title = name const schemaShape = types.getSchemaShape(schema, json, { templateDir: state.typeTemplateDir, destination: state.destination, section: options.section, primitive: config.primitives ? Object.keys(config.primitives).length > 0 : false }) content = content .replace(/\$\{schema.title\}/, (schema.title || name)) .replace(/\$\{schema.description\}/, schema.description || '') .replace(/\$\{schema.shape\}/, schemaShape) if (schema.examples) { content = content.replace(/\$\{schema.example\}/, schema.examples.map(ex => JSON.stringify(ex, null, ' ')).join('\n\n')) } let seeAlso = getRelatedSchemaLinks(schema, json, templates, options) if (seeAlso) { content = content.replace(/\$\{schema.seeAlso\}/, '\n\n' + seeAlso) } else { content = content.replace(/.*\$\{schema.seeAlso\}/, '') } content = content.trim().length ? content : content.trim() const isEnum = x => x.type && Array.isArray(x.enum) && x.title && ((x.type === 'string') || (x.type[0] === 'string')) const result = uri ? { uri: uri, name: schema.title || name, body: content, enum: isEnum(schema) } : { name: schema.title || name, body: content, enum: isEnum(schema) } results.push(result) } let list = [] // schemas may be 1 or 2 levels deeps Object.entries(schemas).forEach(([name, schema]) => { if (isSchema(schema)) { list.push([name, schema]) } }) list = sortSchemasByReference(list) list.forEach(item => generate(...item)) return results } function getRelatedSchemaLinks(schema = {}, json = {}, templates = {}, options = {}) { const seen = {} // Generate list of links to other Firebolt docs // - get all $ref nodes that point to external files // - dedupe them // - convert them to the $ref value (which are paths to other schema files), instead of the path to the ref node itself // - convert those into markdown links of the form [Schema](Schema#/link/to/element) let links = getLinkedSchemaPaths(schema) .map(path => getPathOr(null, path, schema)) .filter(path => seen.hasOwnProperty(path) ? false : (seen[path] = true)) .map(path => path.substring(2).split('/')) .map(path => getPathOr(null, path, json)) .filter(schema => schema.title) .map(schema => '[' + types.getSchemaType(schema, json, { templateDir: state.typeTemplateDir, destination: state.destination, section: state.section }) + '](' + getLinkForSchema(schema, json) + ')') // need full module here, not just the schema .filter(link => link) .join('\n') return links } function getTemplateFromDestination(destination, templateName, templates) { const destinationArray = destination.split('/').pop().split(/[_.]+/) let template = '' destinationArray.filter(value => value).every((suffix) => { template = getTemplate(templateName +`.${suffix}`, templates) return template ? false: true }) if (!template) { template = getTemplate(templateName, templates) } return template } const generateImports = (json, templates, options = { destination: '' }) => { let imports = '' if (rpcMethodsOrEmptyArray(json).length) { imports += getTemplate('/imports/rpc', templates) } if (eventsOrEmptyArray(json).length) { imports += getTemplate('/imports/event', templates) } if (eventsOrEmptyArray(json).find(m => m.params.length > 1)) { imports += getTemplate('/imports/context-event', templates) } if (providersOrEmptyArray(json).length) { imports += getTemplate('/imports/provider', templates) } if (props(json).length) { imports += getTemplate('/imports/property', templates) } if (temporalSets(json).length) { imports += getTemplate('/imports/temporal-set', templates) } if (methodsWithXMethodsInResult(json).length) { imports += getTemplate('/imports/x-method', templates) } if (callsMetrics(json).length) { imports += getTemplateFromDestination(options.destination, '/imports/calls-metrics', templates) } let template = getTemplateFromDestination(options.destination, '/imports/default', templates) if (json['x-schemas'] && Object.keys(json['x-schemas']).length > 0 && !json.info['x-uri-titles']) { imports += Object.keys(json['x-schemas']).map(shared => template.replace(/\$\{info.title.lowercase\}/g, shared.toLowerCase())).join('') } let componentExternalSchema = getComponentExternalSchema(json) if (componentExternalSchema.length && json.info['x-uri-titles']) { imports += componentExternalSchema.map(shared => template.replace(/\$\{info.title.lowercase\}/g, shared.toLowerCase())).join('') } return imports } const generateInitialization = (json, templates) => generateEventInitialization(json, templates) + '\n' + generateProviderInitialization(json, templates) + '\n' + generateDeprecatedInitialization(json, templates) const generateEventInitialization = (json, templates) => { const events = eventsOrEmptyArray(json) if (events.length > 0) { return getTemplate('/initializations/event', templates) } else { return '' } } const getProviderInterfaceNameFromRPC = name => name.charAt(9).toLowerCase() + name.substr(10) // Drop onRequest prefix // TODO: this passes a JSON object to the template... might be hard to get working in non JavaScript languages. const generateProviderInitialization = (json, templates) => compose( reduce((acc, capability, i, arr) => { const methods = providersOrEmptyArray(json) .filter(m => m.tags.find(t => t['x-provides'] === capability)) .map(m => ({ name: getProviderInterfaceNameFromRPC(m.name), focus: ((m.tags.find(t => t['x-allow-focus']) || { 'x-allow-focus': false })['x-allow-focus']), response: ((m.tags.find(t => t['x-response']) || { 'x-response': null })['x-response']) !== null, parameters: !providerHasNoParameters(localizeDependencies(getPayloadFromEvent(m), json)) })) return acc + getTemplate('/initializations/provider', templates) .replace(/\$\{capability\}/g, capability) .replace(/\$\{interface\}/g, JSON.stringify(methods)) }, ''), providedCapabilitiesOrEmptyArray )(json) const generateDeprecatedInitialization = (json, templates) => { return compose( reduce((acc, method, i, arr) => { if (i === 0) { acc = '' } let alternative = method.tags.find(t => t.name === 'deprecated')['x-alternative'] || '' if (alternative && alternative.indexOf(' ') === -1) { alternative = `Use ${alternative} instead.` } return acc + insertMethodMacros(getTemplate('/initializations/deprecated', templates), method, json, templates) }, ''), deprecatedOrEmptyArray )(json) } function generateExamples(json = {}, mainTemplates = {}, languages = {}) { const examples = {} json && json.methods && json.methods.forEach(method => { examples[method.name] = method.examples.map(example => ({ json: example, value: example.result.value, languages: Object.fromEntries(Object.entries(languages).map(([lang, templates]) => ([lang, { langcode: templates['__config'].langcode, code: getTemplateForExample(method, templates) .replace(/\$\{rpc\.example\.params\}/g, JSON.stringify(Object.fromEntries(example.params.map(param => [param.name, param.value])))), result: getTemplateForExampleResult(method, templates) .replace(/\$\{example\.result\}/g, JSON.stringify(example.result.value, null, '\t')) .replace(/\$\{example\.result\.item\}/g, Array.isArray(example.result.value) ? JSON.stringify(example.result.value[0], null, '\t') : ''), template: lang === 'JSON-RPC' ? getTemplate('/examples/jsonrpc', mainTemplates) : getTemplateForExample(method, mainTemplates) // getTemplate('/examples/default', mainTemplates) }]))) })) // delete non RPC examples from rpc-only methods if (isRPCOnlyMethod(method)) { examples[method.name] = examples[method.name].map(example => ({ json: example.json, value: example.value, languages: Object.fromEntries(Object.entries(example.languages).filter(([k, v]) => k === 'JSON-RPC')) })) } // clean up JSON-RPC indentation, because it's easy and we can. examples[method.name].map(example => { if (example.languages['JSON-RPC']) { try { example.languages['JSON-RPC'].code = JSON.stringify(JSON.parse(example.languages['JSON-RPC'].code), null, '\t') example.languages['JSON-RPC'].result = JSON.stringify(JSON.parse(example.languages['JSON-RPC'].result), null, '\t') } catch (error) { } } }) }) return examples } function generateMethodResult(type, templates) { const result = { name: type, body: {}, declaration: {}, } Array.from(new Set(['methods'].concat(config.additionalMethodTemplates))).filter(dir => dir).forEach(dir => { const template = getTemplate(('/' + dir + '/' + type), templates) if (template) { if (dir.includes('declarations')) { result.declaration[dir] = template } else if (dir.includes('methods')) { result.body[dir] = template } } }) return result } function generateMethods(json = {}, examples = {}, templates = {}, languages = [], type = '') { const methods = compose( option([]), getMethods )(json) // Code to generate methods const results = reduce((acc, methodObj, i, arr) => { const result = { name: methodObj.name, body: {}, declaration: {}, excluded: methodObj.tags.find(t => t.name === 'exclude-from-sdk'), event: isEventMethod(methodObj), private: isRPCOnlyMethod(methodObj) } /** * Extracts the suffix from a given file path. * * The suffix is determined by the last underscore or period in the filename. * If the filename contains an underscore, the portion after the last underscore * is considered the suffix. If no underscore is found but there is a period, * the portion after the last period (typically the file extension) is considered the suffix. * If neither an underscore nor a period is found, an empty string is returned. * * @param {string} path - The full file path from which to extract the suffix. * @returns {string} - The extracted suffix or an empty string if no suffix is found. */ const getSuffix = (path) => { // Extract the last part of the path (the filename) const filename = path.split('/').pop() // Get the last part of the path // Check for underscores or periods in the filename and handle accordingly if (filename.includes('_')) { return filename.split('_').pop() // Return the last part after the last underscore } else if (filename.includes('.')) { return filename.split('.').pop() // Return the extension after the last period } else { return '' // Return empty if no suffix can be determined } } const suffix = state.destination && config.templateExtensionMap ? getSuffix(state.destination) : '' // Generate implementation of methods/events for both dynamic and static configured templates Array.from(new Set(['methods'].concat(config.additionalMethodTemplates))).filter(dir => dir).forEach(dir => { if (dir.includes('declarations') && (suffix && config.templateExtensionMap[dir] ? config.templateExtensionMap[dir].includes(suffix) : true)) { const template = getTemplateForDeclaration(methodObj, templates, dir) if (template && template.length) { result.declaration[dir] = insertMethodMacros(template, methodObj, json, templates, '', examples) } } else if (dir.includes('methods') && (suffix && config.templateExtensionMap[dir] ? config.templateExtensionMap[dir].includes(suffix) : true)) { const template = getTemplateForMethod(methodObj, templates, dir) if (template && template.length) { result.body[dir] = insertMethodMacros(template, methodObj, json, templates, type, examples, languages) } } }) acc.push(result) return acc }, [], methods) // TODO: might be useful to pass in local macro for an array with all capability & provider interface names if (json.methods && json.methods.find(isProviderInterfaceMethod)) { results.push(generateMethodResult('provide', templates)) } // TODO: might be useful to pass in local macro for an array with all event names if (json.methods && json.methods.find(isPublicEventMethod)) { ['listen', 'once', 'clear'].forEach(type => { results.push(generateMethodResult(type, templates)) }) } results.sort((a, b) => a.name.localeCompare(b.name)) return results } // TODO: this is called too many places... let's reduce that to just generateMethods function insertMethodMacros(template, methodObj, json, templates, type = '', examples = {}, languages = {}) { const moduleName = getModuleName(json) const info = { title: moduleName } const method = { name: methodObj.name, params: methodObj.params.map(p => p.name).join(', '), transforms: null, alternative: null, deprecated: isDeprecatedMethod(methodObj), context: [] } if (isEventMethod(methodObj) && methodObj.params.length > 1) { method.context = methodObj.params.filter(p => p.name !== 'listen').map(p => p.name) } if (getAlternativeMethod(methodObj)) { method.alternative = getAlternativeMethod(methodObj) } const flattenedMethod = localizeDependencies(methodObj, json) if (hasMethodAttributes(flattenedMethod)) { method.transforms = { methods: getMethodAttributes(flattenedMethod) } } const paramDelimiter = config.operators ? config.operators.paramDelimiter : '' const temporalItemName = isTemporalSetMethod(methodObj) ? methodObj.result.schema.items && methodObj.result.schema.items.title || 'Item' : '' const temporalAddName = isTemporalSetMethod(methodObj) ? `on${temporalItemName}Available` : '' const temporalRemoveName = isTemporalSetMethod(methodObj) ? `on${temporalItemName}Unvailable` : '' const params = methodObj.params && methodObj.params.length ? getTemplate('/sections/parameters', template