UNPKG

@firebolt-js/openrpc

Version:
1,540 lines (1,301 loc) 54.6 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, setPath } = 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 } = pointfree import logic from 'crocks/logic/index.js' import isEmpty from 'crocks/core/isEmpty.js' const { and, not } = logic import isString from 'crocks/core/isString.js' import predicates from 'crocks/predicates/index.js' import { getExternalSchemaPaths, isDefinitionReferencedBySchema, isNull, localizeDependencies, isSchema, getLocalSchemaPaths, replaceRef, getPropertySchema, dereferenceAndMergeAllOfs } from './json-schema.mjs' import { getPath as getRefDefinition } from './json-schema.mjs' const { isObject, isArray, propEq, pathSatisfies, hasProp, propSatisfies } = predicates // TODO remove these when major/rpc branch is merged const name = method => method.name.split('.').pop() const rename = (method, renamer) => method.name.split('.').map((x, i, arr) => i === (arr.length-1) ? renamer(x) : x).join('.') // util for visually debugging crocks ADTs const inspector = obj => { if (obj.inspect) { console.log(obj.inspect()) } else { console.log(obj) } } const isEnum = compose( filter(x => x.type === 'string' && Array.isArray(x.enum) && x.title), map(([_, val]) => val), filter(([_key, val]) => isObject(val)) ) // Maybe methods array of objects const getMethods = compose( option([]), map(filter(isObject)), chain(safe(isArray)), getPath(['methods']) ) const isProviderInterfaceMethod = compose( and( compose( propSatisfies('name', name => name.startsWith('onRequest')) ), compose( option(false), map(_ => true), chain( find( and( propEq('name', 'capabilities'), propSatisfies('x-provides', not(isEmpty)) ) ) ), getPath(['tags']) ) ) ) const getProvidedCapabilities = (json) => { return Array.from(new Set([...getMethods(json).filter(isProviderInterfaceMethod).map(method => method.tags.find(tag => tag['x-provides'])['x-provides'])])) } const getProviderInterfaceMethods = (capability, json) => { return getMethods(json).filter(method => method.name.startsWith("onRequest") && method.tags && method.tags.find(tag => tag['x-provides'] === capability)) } function getProviderInterface(capability, module, extractProviderSchema = false) { module = JSON.parse(JSON.stringify(module)) const iface = getProviderInterfaceMethods(capability, module).map(method => dereferenceAndMergeAllOfs(method, module)) iface.forEach(method => { const payload = getPayloadFromEvent(method) const focusable = method.tags.find(t => t['x-allow-focus']) // remove `onRequest` method.name = method.name.charAt(9).toLowerCase() + method.name.substr(10) const schema = getPropertySchema(payload, 'properties.parameters', module) method.params = [ { "name": "parameters", "required": true, "schema": schema } ] if (!extractProviderSchema) { let exampleResult = null if (method.tags.find(tag => tag['x-response'])) { const result = method.tags.find(tag => tag['x-response'])['x-response'] method.result = { "name": "result", "schema": result } if (result.examples && result.examples[0]) { exampleResult = result.examples[0] } } else { method.result = { "name": "result", "schema": { "const": null } } } method.examples = method.examples.map( example => ( { params: [ { name: "parameters", value: example.result.value.parameters }, { name: "correlationId", value: example.result.value.correlationId } ], result: { name: "result", value: exampleResult } } )) // remove event tag method.tags = method.tags.filter(tag => tag.name !== 'event') } }) return iface } const addMissingTitles = ([k, v]) => { if (v && !v.hasOwnProperty('title')) { v.title = k } return v } // Maybe an array of <key, value> from the schema const getSchemas = compose( option([]), chain(safe(isArray)), map(Object.entries), // Maybe Array<Array<key, value>> chain(safe(isObject)), // Maybe Object getPath(['components', 'schemas']) // Maybe any ) const getEnums = compose( filter(x => x[1].enum), getSchemas ) const getTypes = compose( // filter(x => !x.enum), getSchemas ) const isEventMethod = compose( option(false), map(_ => true), chain(find(propEq('name', 'event'))), getPath(['tags']) ) const isEventMethodWithContext = compose( and( compose( option(false), map(_ => true), chain(find(propEq('name', 'event'))), getPath(['tags']) ), compose( map(params => { return params.length > 1 }), //propSatisfies('length', length => length > 1), getPath(['params']) ) ) ) const isPolymorphicPullMethod = compose( option(false), map(_ => true), chain(find(hasProp('x-pulls-for'))), getPath(['tags']) ) const isTemporalSetMethod = compose( option(false), map(_ => true), chain(find(propEq('name', 'temporal-set'))), getPath(['tags']) ) const isCallsMetricsMethod = compose( option(false), map(_ => true), chain(find(propEq('name', 'calls-metrics'))), getPath(['tags']) ) const getMethodAttributes = compose( option(null), map(props => props.reduce( (val, item) => { val[item['__key']] = item; delete item['__key']; return val }, {})), map(filter(hasProp('x-method'))), map(props => props.map(([k, v]) => ({ "__key": k, ...v}))), map(Object.entries), map(schema => schema.items ? schema.items.properties || {} : schema.properties || {}), getPath(['result', 'schema']) ) const hasMethodAttributes = compose( option(false), map(_ => true), chain(find(hasProp('x-method'))), map(Object.values), map(schema => schema.items ? schema.items.properties || {} : schema.properties || {}), getPath(['result', 'schema']) ) const isPublicEventMethod = and( compose( option(true), map(_ => false), chain(find(propEq('name', 'rpc-only'))), getPath(['tags']) ), compose( option(false), map(_ => true), chain(find(propEq('name', 'event'))), getPath(['tags']) ) ) const isExcludedMethod = compose( option(false), map(_ => true), chain(find(propEq('name', 'exclude-from-sdk'))), getPath(['tags']) ) const isRPCOnlyMethod = compose( option(false), map(_ => true), chain(find(propEq('name', 'rpc-only'))), getPath(['tags']) ) const isPolymorphicReducer = compose( option(false), map(_ => true), chain(find(propEq('name', 'polymorphic-reducer'))), getPath(['tags']) ) const isAllowFocusMethod = compose( option(false), map(_ => true), chain(find(and( hasProp('x-uses'), propSatisfies('x-allow-focus', focus => (focus === true)) ))), getPath(['tags']) ) const hasTitle = compose( option(false), map(isString), getPath(['info', 'title']) ) const hasExamples = compose( option(false), map(isObject), getPath(['examples', 0]) ) const getParamsFromMethod = compose( option([]), getPath(['params']) ) const getPayloadFromEvent = (event) => { const choices = (event.result.schema.oneOf || event.result.schema.anyOf) const choice = choices.find(schema => schema.title !== 'ListenResponse' && !(schema['$ref'] || '').endsWith('/ListenResponse')) return choice } const getSetterFor = (property, json) => json.methods && json.methods.find(m => m.tags && m.tags.find(t => t['x-setter-for'] === property)) const getSubscriberFor = (property, json) => json.methods.find(m => m.tags && m.tags.find(t => t['x-alternative'] === property)) const providerHasNoParameters = (schema) => { if (schema.allOf || schema.oneOf) { return !!(schema.allOf || schema.oneOf).find(schema => providerHasNoParameters(schema)) } else if (schema.properties && schema.properties.parameters) { return isNull(schema.properties.parameters) } else { console.dir(schema, {depth: 10}) throw "Invalid ProviderRequest" } } const validEvent = and( pathSatisfies(['name'], isString), pathSatisfies(['name'], x => x.match(/on[A-Z]/)) ) // Pick events out of the methods array const getEvents = 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 })), inspector, map(filter(isEventMethod)), getMethods ) const getPublicEvents = compose( map(filter(isPublicEventMethod)), getEvents ) const hasPublicInterfaces = json => json.methods && json.methods.filter(m => m.tags && m.tags.find(t=>t['x-provides'])).length > 0 const hasPublicAPIs = json => hasPublicInterfaces(json) || (json.methods && json.methods.filter( method => !method.tags.find(tag => tag.name === 'rpc-only')).length > 0) const hasAllowFocusMethods = json => json.methods && json.methods.filter(m => isAllowFocusMethod(m)).length > 0 const eventDefaults = event => { event.tags = [ { 'name': 'event' } ] return event } const createEventResultSchemaFromProperty = (property, type='') => { const subscriberType = property.tags.map(t => t['x-subscriber-type']).find(t => typeof t === 'string') || 'context' const caps = property.tags.find(t => t.name === 'capabilities') let name = caps['x-provided-by'] ? caps['x-provided-by'].split('.').pop().replace('onRequest', '') : property.name name = name.charAt(0).toUpperCase() + name.substring(1) if ( subscriberType === 'global') { // wrap the existing result and the params in a new result object const schema = { title: name + type + 'Info', type: "object", properties: { }, required: [] } // add all of the params property.params.filter(p => p.name !== 'listen').forEach(p => { schema.properties[p.name] = p.schema schema.required.push(p.name) }) // add the result (which might override a param of the same name) schema.properties[property.result.name] = property.result.schema !schema.required.includes(property.result.name) && schema.required.push(property.result.name) return schema } } const createEventFromProperty = (property, type='', alternative, json) => { const provider = (property.tags.find(t => t['x-provided-by']) || {})['x-provided-by'] const pusher = provider ? provider.replace('onRequest', '').split('.').map((x, i, arr) => (i === arr.length-1) ? x.charAt(0).toLowerCase() + x.substr(1) : x).join('.') : undefined const event = eventDefaults(JSON.parse(JSON.stringify(property))) // event.name = (module ? module + '.' : '') + 'on' + event.name.charAt(0).toUpperCase() + event.name.substr(1) + type event.name = provider ? provider.split('.').pop().replace('onRequest', '') : event.name.charAt(0).toUpperCase() + event.name.substr(1) + type event.name = event.name.split('.').map((x, i, arr) => (i === arr.length-1) ? 'on' + x.charAt(0).toUpperCase() + x.substr(1) : x).join('.') const subscriberFor = pusher || (json.info.title + '.' + property.name) const old_tags = JSON.parse(JSON.stringify(property.tags)) alternative && (event.tags[0]['x-alternative'] = alternative) !provider && event.tags.unshift({ name: "subscriber", 'x-subscriber-for': subscriberFor }) const subscriberType = property.tags.map(t => t['x-subscriber-type']).find(t => typeof t === 'string') || 'context' // if the subscriber type is global, zap all of the parameters and change the result type to the schema that includes them if (subscriberType === 'global') { // wrap the existing result and the params in a new result object const result = { name: "data", schema: { $ref: "#/components/schemas/" + event.name.substring(2) + 'Info' } } event.examples.map(example => { const result = {} example.params.filter(p => p.name !== 'listen').forEach(p => { result[p.name] = p.value }) result[example.result.name] = example.result.value example.params = example.params.filter(p => p.name === 'listen') example.result.name = "data" example.result.value = result }) event.result = result // remove the params event.params = event.params.filter(p => p.name === 'listen') } old_tags.forEach(t => { if (t.name !== 'property' && !t.name.startsWith('property:') && t.name !== 'push-pull') { event.tags.push(t) } }) provider && (event.tags.find(t => t.name === 'capabilities')['x-provided-by'] = subscriberFor) return event } // create foo() notifier from onFoo() event const createNotifierFromEvent = (event, json) => { const push = JSON.parse(JSON.stringify(event)) const caps = push.tags.find(t => t.name === 'capabilities') push.name = caps['x-provided-by'] delete caps['x-provided-by'] caps['x-provides'] = caps['x-uses'].pop() delete caps['x-uses'] push.tags = push.tags.filter(t => t.name !== 'event') push.result.required = true push.params.push(push.result) push.result = { "name": "result", "schema": { "type": "null" } } push.examples.forEach(example => { example.params.push(example.result) example.result = { "name": "result", "value": null } }) return push } const createPushEvent = (requestor, json) => { return createEventFromProperty(requestor, '', undefined, json) } const createPullEventFromPush = (pusher, json) => { const event = eventDefaults(JSON.parse(JSON.stringify(pusher))) event.params = [] event.name = 'onPull' + event.name.charAt(0).toUpperCase() + event.name.substr(1) const old_tags = JSON.parse(JSON.stringify(pusher.tags)) event.tags[0]['x-pulls-for'] = pusher.name event.tags.unshift({ name: 'polymorphic-pull-event' }) const requestType = (pusher.name.charAt(0).toUpperCase() + pusher.name.substr(1)) + "FederatedRequest" event.result.name = "request" event.result.summary = "A " + requestType + " object." event.result.schema = { "$ref": "#/components/schemas/" + requestType } const exampleResult = { name: "result", value: JSON.parse(JSON.stringify(getPathOr(null, ['components', 'schemas', requestType, 'examples', 0], json))) } event.examples && event.examples.forEach(example => { example.result = exampleResult example.params = [] }) old_tags.forEach(t => { if (t.name !== 'polymorphic-pull' && t.name) { event.tags.push(t) } }) return event } const createPullProvider = (requestor, params) => { const event = eventDefaults(JSON.parse(JSON.stringify(requestor))) event.name = requestor.tags.find(t => t['x-provided-by'])['x-provided-by'] const old_tags = JSON.parse(JSON.stringify(requestor.tags)) const value = event.result event.tags[0]['x-response'] = value.schema event.tags[0]['x-response'].examples = event.examples.map(e => e.result.value) event.result = { "name": "request", "schema": { "type": "object", "required": ["correlationId", "parameters"], "properties":{ "correlationId": { "type": "string", }, "parameters": { "$ref": "#/components/schemas/" + params } }, "additionalProperties": false } } event.params = [] event.examples = event.examples.map(example => { example.result = { "name": "request", "value": { "correlationId": "xyz", "parameters": {} } } example.params.forEach(p => { example.result.value.parameters[p.name] = p.value }) example.params = [] return example }) old_tags.forEach(t => { if (t.name !== 'push-pull') { event.tags.push(t) } }) const caps = event.tags.find(t => t.name === 'capabilities') caps['x-provides'] = caps['x-uses'].pop() || caps['x-manages'].pop() caps['x-requestor'] = requestor.name delete caps['x-uses'] delete caps['x-manages'] delete caps['x-provided-by'] return event } const createPullProviderParams = (requestor) => { const copy = JSON.parse(JSON.stringify(requestor)) // grab onRequest<foo> and turn into <foo> const name = copy.tags.find(t => t['x-provided-by'])['x-provided-by'].split('.').pop().substring(9) const paramsSchema = { "title": name.charAt(0).toUpperCase() + name.substr(1) + "ProviderParameters", "type": "object", "required": [], "properties": { }, "additionalProperties": false } copy.params.forEach(p => { paramsSchema.properties[p.name] = p.schema if (p.required) { paramsSchema.required.push(p.name) } }) return paramsSchema } const createPullRequestor = (pusher, json) => { const module = pusher.tags.find(t => t.name === 'push-pull')['x-requesting-interface'] const requestor = JSON.parse(JSON.stringify(pusher)) requestor.name = (module ? module + '.' : '') + 'request' + requestor.name.charAt(0).toUpperCase() + requestor.name.substr(1) const value = requestor.params.pop() delete value.required requestor.tags = requestor.tags.filter(t => t.name !== 'push-pull') requestor.tags.unshift({ "name": "requestor", "x-requestor-for": json.info.title + '.' + pusher.name }) const caps = requestor.tags.find(t => t.name === 'capabilities') caps['x-provided-by'] = json.info.title + '.' + pusher.name caps['x-uses'] = [ caps['x-provides'] ] delete caps['x-provides'] requestor.tags.find(t => t.name === 'capabilities')['x-provided-by'] = json.info.title + '.' + pusher.name requestor.result = value requestor.examples.forEach(example => { example.result = example.params.pop() }) return requestor } const createTemporalEventMethod = (method, json, name) => { const event = createEventFromMethod(method, json, name, 'x-temporal-for', ['temporal-set']) // copy the array items schema to the main result for individual events event.result.schema = method.result.schema.items event.tags = event.tags.filter(t => t.name !== 'temporal-set') event.params.unshift({ name: "correlationId", required: true, schema: { type: "string" } }) event.examples && event.examples.forEach(example => { example.params.unshift({ name: "correlationId", value: "xyz" }) example.result.value = example.result.value[0] }) return event } const createEventFromMethod = (method, json, name, correlationExtension, tagsToRemove = []) => { const event = eventDefaults(JSON.parse(JSON.stringify(method))) event.name = 'on' + name const old_tags = JSON.parse(JSON.stringify(method.tags)) event.tags[0][correlationExtension] = method.name event.tags.unshift({ name: 'rpc-only' }) old_tags.forEach(t => { if (!tagsToRemove.find(t => tagsToRemove.includes(t.name))) { event.tags.push(t) } }) return event } const createTemporalStopMethod = (method, jsoname) => { const stop = JSON.parse(JSON.stringify(method)) stop.name = 'stop' + method.name.charAt(0).toUpperCase() + method.name.substr(1) stop.tags = stop.tags.filter(tag => tag.name !== 'temporal-set') stop.tags.unshift({ name: "rpc-only" }) // copy the array items schema to the main result for individual events stop.result.name = "result" stop.result.schema = { type: "null" } stop.params = [{ name: "correlationId", required: true, schema: { type: "string" } }] stop.examples && stop.examples.forEach(example => { example.params = [{ name: "correlationId", value: "xyz" }] example.result = { name: "result", value: null } }) return stop } const createSetterFromProperty = property => { const setter = JSON.parse(JSON.stringify(property)) setter.name = 'set' + setter.name.charAt(0).toUpperCase() + setter.name.substr(1) const old_tags = setter.tags setter.tags = [ { 'name': 'setter', 'x-setter-for': property.name } ] const param = setter.result param.name = 'value' param.required = true setter.params.push(param) setter.result = { name: 'result', schema: { type: "null" } } setter.examples && setter.examples.forEach(example => { example.params.push({ name: 'value', value: example.result.value }) example.result.value = null }) old_tags.forEach(t => { if (t.name !== 'property' && !t.name.startsWith('property:')) { if (t.name === 'capabilities') { setter.tags.push({ name: 'capabilities', 'x-manages': t['x-uses'] || t['x-manages'] }) } else { setter.tags.push(t) } } }) return setter } const createFocusFromProvider = provider => { if (!name(provider).startsWith('onRequest')) { throw "Methods with the `x-provider` tag extension MUST start with 'onRequest'." } const ready = JSON.parse(JSON.stringify(provider)) ready.name = rename(ready, n => n.charAt(9).toLowerCase() + n.substr(10) + 'Focus') ready.summary = `Internal API for ${name(provider).substr(9)} Provider to request focus for UX purposes.` ready.tags = ready.tags.filter(t => t.name !== 'event') ready.tags.find(t => t.name === 'capabilities')['x-allow-focus-for'] = provider.name ready.params = [] ready.result = { name: 'result', schema: { type: "null" } } ready.examples = [ { name: "Example", params: [], result: { name: "result", value: null } } ] return ready } // type = Response | Error const createResponseFromProvider = (provider, type, json) => { if (!name(provider).startsWith('onRequest')) { throw "Methods with the `x-provider` tag extension MUST start with 'onRequest'." } const response = JSON.parse(JSON.stringify(provider)) response.name = rename(response, n => n.charAt(9).toLowerCase() + n.substr(10) + type) response.summary = `Internal API for ${provider.name.substr(9)} Provider to send back ${type.toLowerCase()}.` response.tags = response.tags.filter(t => t.name !== 'event') response.tags.find(t => t.name === 'capabilities')[`x-${type.toLowerCase()}-for`] = provider.name const paramExamples = [] if (provider.tags.find(t => t[`x-${type.toLowerCase()}`])) { response.params = [ { name: "correlationId", schema: { type: "string" }, required: true }, { name: type === 'Error' ? 'error' : "result", schema: provider.tags.find(t => t[`x-${type.toLowerCase()}`])[`x-${type.toLowerCase()}`], required: true } ] if (!provider.tags.find(t => t['x-error'])) { provider.tags.find(t => t.name === 'event')['x-error'] = { //"$ref": "https://meta.open-rpc.org/#definitions/errorObject" // TODO: replace this with ref above (requires merge of `fix/rpc.discover`) "type": "object", "additionalProperties": false, "required": [ "code", "message" ], "properties": { "code": { "title": "errorObjectCode", "description": "A Number that indicates the error type that occurred. This MUST be an integer. The error codes from and including -32768 to -32000 are reserved for pre-defined errors. These pre-defined errors SHOULD be assumed to be returned from any JSON-RPC api.", "type": "integer" }, "message": { "title": "errorObjectMessage", "description": "A String providing a short description of the error. The message SHOULD be limited to a concise single sentence.", "type": "string" }, "data": { "title": "errorObjectData", "description": "A Primitive or Structured value that contains additional information about the error. This may be omitted. The value of this member is defined by the Server (e.g. detailed error information, nested errors etc.)." } } } } const schema = localizeDependencies(provider.tags.find(t => t[`x-${type.toLowerCase()}`])[`x-${type.toLowerCase()}`], json) let n = 1 if (schema.examples && schema.examples.length) { paramExamples.push(... (schema.examples.map( param => ({ name: schema.examples.length === 1 ? "Example" : `Example #${n++}`, params: [ { name: 'correlationId', value: '123' }, { name: 'result', value: param } ], result: { name: 'result', value: null } })) || [])) delete schema.examples } else if (schema['$ref']) { paramExamples.push({ name: 'Generated Example', params: [ { name: `${type.toLowerCase()}`, value: { correlationId: "123", result: { '$ref': schema['$ref'] + '/examples/0' } } } ], result: { name: 'result', value: null } }) } } if (paramExamples.length === 0) { const value = type === 'Error' ? { code: 1, message: 'Error' } : {} paramExamples.push( { name: 'Example 1', params: [ { name: 'correlationId', value: '123' }, { name: type === 'Error' ? 'error' : 'result', value } ], result: { name: 'result', value: null } }) } response.result = { name: 'result', schema: { type: 'null' } } response.examples = paramExamples return response } const copyAllowFocusTags = (json) => { // for each allow focus provider method, set the value on any `use` methods that share the same capability json.methods.filter(m => m.tags.find(t => t['x-allow-focus'] && t['x-provides'])).forEach(method => { const cap = method.tags.find(t => t.name === "capabilities")['x-provides'] json.methods.filter(m => m.tags.find(t => t['x-uses'] && t['x-uses'].includes(cap))).forEach(useMethod => { useMethod.tags.find(t => t.name === "capabilities")['x-allow-focus'] = true }) }) return json } const generatePropertyEvents = json => { const properties = json.methods.filter( m => m.tags && m.tags.find( t => t.name == 'property')) || [] const readonlies = json.methods.filter( m => m.tags && m.tags.find( t => t.name == 'property:readonly')) || [] properties.forEach(property => { json.methods.push(createEventFromProperty(property, 'Changed', property.name, json)) const schema = createEventResultSchemaFromProperty(property, 'Changed') if (schema) { json.components.schemas[schema.title] = schema } }) readonlies.forEach(property => { json.methods.push(createEventFromProperty(property, 'Changed', property.name, json)) const schema = createEventResultSchemaFromProperty(property, 'Changed') if (schema) { json.components.schemas[schema.title] = schema } }) return json } const generatePropertySetters = json => { const properties = json.methods.filter( m => m.tags && m.tags.find( t => t.name == 'property')) || [] properties.forEach(property => json.methods.push(createSetterFromProperty(property))) return json } const generatePolymorphicPullEvents = json => { const pushers = json.methods.filter( m => m.tags && m.tags.find( t => t.name == 'polymorphic-pull')) || [] pushers.forEach(pusher => json.methods.push(createPullEventFromPush(pusher, json))) return json } const generatePushPullMethods = json => { const requestors = json.methods.filter( m => m.tags && m.tags.find( t => t.name == 'push-pull')) || [] requestors.forEach(requestor => { json.methods.push(createPushEvent(requestor, json)) const schema = createEventResultSchemaFromProperty(requestor) if (schema) { json.components = json.components || {} json.components.schemas = json.components.schemas || {} json.components.schemas[schema.title] = schema } }) return json } const generateProvidedByMethods = json => { const requestors = json.methods.filter(m => !m.tags.find(t => t.name === 'event')).filter( m => m.tags && m.tags.find( t => t['x-provided-by'])) || [] const events = json.methods .filter(m => m.tags.find(t => t.name === 'event')) .filter( m => m.tags && m.tags.find( t => t['x-provided-by'])) .filter(e => !json.methods.find(m => m.name === e.tags.find(t => t['x-provided-by'])['x-provided-by'])) const pushers = events.map(m => createNotifierFromEvent(m, json)) pushers.forEach(m => json.methods.push(m)) requestors.forEach(requestor => { const schema = createPullProviderParams(requestor) json.methods.push(createPullProvider(requestor, schema.title)) json.components = json.components || {} json.components.schemas = json.components.schemas || {} json.components.schemas[schema.title] = schema }) return json } const generateTemporalSetMethods = json => { const temporals = json.methods.filter( m => m.tags && m.tags.find( t => t.name == 'temporal-set')) || [] temporals.forEach(temporal => json.methods.push(createTemporalEventMethod(temporal, json, (temporal.result.schema.items.title || 'Item') + 'Available'))) temporals.forEach(temporal => json.methods.push(createTemporalEventMethod(temporal, json, (temporal.result.schema.items.title || 'Item') + 'Unavailable'))) temporals.forEach(temporal => json.methods.push(createTemporalStopMethod(temporal, json))) return json } const generateProviderMethods = json => { const providers = json.methods.filter( m => name(m).startsWith('onRequest') && m.tags && m.tags.find( t => t.name == 'capabilities' && t['x-provides'])) || [] providers.forEach(provider => { if (! isRPCOnlyMethod(provider)) { provider.tags.unshift({ "name": "rpc-only" }) } // only create the ready method for providers that require a handshake if (provider.tags.find(t => t['x-allow-focus'])) { json.methods.push(createFocusFromProvider(provider, json)) } }) providers.forEach(provider => { json.methods.push(createResponseFromProvider(provider, 'Response', json)) json.methods.push(createResponseFromProvider(provider, 'Error', json)) }) return json } const generateEventListenerParameters = json => { const events = json.methods.filter( m => m.tags && m.tags.find(t => t.name == 'event')) || [] events.forEach(event => { event.params = event.params || [] event.params.push({ "name": "listen", "required": true, "schema": { "type": "boolean" } }) event.examples = event.examples || [] event.examples.forEach(example => { example.params = example.params || [] example.params.push({ "name": "listen", "value": true }) }) }) return json } const generateEventListenResponse = json => { const events = json.methods.filter( m => m.tags && m.tags.find(t => t.name == 'event')) || [] events.forEach(event => { // only want or and xor here (might even remove xor) const anyOf = event.result.schema.oneOf || event.result.schema.anyOf const ref = { "$ref": "https://meta.comcast.com/firebolt/types#/definitions/ListenResponse" } if (anyOf) { anyOf.splice(0, 0, ref) } else { event.result.schema = { anyOf: [ ref, event.result.schema ] } } }) return json } const getAnyOfSchema = (inType, json) => { let anyOfTypes = [] let outType = localizeDependencies(inType, json) if (outType.schema.anyOf) { let definition = '' if (inType.schema['$ref'] && (inType.schema['$ref'][0] === '#')) { definition = getRefDefinition(inType.schema['$ref'], json, json['x-schemas']) } else { definition = outType.schema } definition.anyOf.forEach(anyOf => { anyOfTypes.push(anyOf) }) outType.schema.anyOf = anyOfTypes } return outType } const generateAnyOfSchema = (anyOf, name, summary) => { let anyOfType = {} anyOfType["name"] = name; anyOfType["summary"] = summary anyOfType["schema"] = anyOf return anyOfType } const generateParamsAnyOfSchema = (methodParams, anyOf, anyOfTypes, title, summary) => { let params = [] methodParams.forEach(p => { if (p.schema.anyOf === anyOfTypes) { let anyOfType = generateAnyOfSchema(anyOf, p.name, summary) anyOfType.required = p.required params.push(anyOfType) } else { params.push(p) } }) return params } const generateResultAnyOfSchema = (method, methodResult, anyOf, anyOfTypes, title, summary) => { let methodResultSchema = {} if (methodResult.schema.anyOf === anyOfTypes) { let anyOfType = generateAnyOfSchema(anyOf, title, summary) let index = 0 if (isEventMethod(method)) { index = (method.result.schema.anyOf || method.result.schema.oneOf).indexOf(getPayloadFromEvent(method)) } else { index = (method.result.schema.anyOf || method.result.schema.oneOf).indexOf(anyOfType) } if (method.result.schema.anyOf) { methodResultSchema["anyOf"] = Object.assign([], method.result.schema.anyOf) methodResultSchema.anyOf[index] = anyOfType.schema } else if (method.result.schema.oneOf) { methodResultSchema["oneOf"] = Object.assign([], method.result.schema.oneOf) methodResultSchema.oneOf[index] = anyOfType.schema } else { methodResultSchema = anyOfType.schema } } return methodResultSchema } const createPolymorphicMethods = (method, json) => { let anyOfTypes let methodParams = [] let methodResult = Object.assign({}, method.result) method.params.forEach(p => { if (p.schema) { let param = getAnyOfSchema(p, json) if (param.schema.anyOf && anyOfTypes) { //anyOf is allowed with only one param in the params list throw `WARNING anyOf is repeated with param:${p}` } else if (param.schema.anyOf) { anyOfTypes = param.schema.anyOf } methodParams.push(param) } }) let foundAnyOfParams = anyOfTypes ? true : false if (isEventMethod(method)) { methodResult.schema = getPayloadFromEvent(method) } methodResult = getAnyOfSchema(methodResult, json) let foundAnyOfResult = methodResult.schema.anyOf ? true : false if (foundAnyOfParams === true && foundAnyOfResult === true) { throw `WARNING anyOf is already with param schema, it is repeated with ${method.name} result too` } else if (foundAnyOfResult === true) { anyOfTypes = methodResult.schema.anyOf } let polymorphicMethodSchemas = [] //anyOfTypes will be allowed either in any one of the params or in result if (anyOfTypes) { let polymorphicMethodSchema = { name: {}, tags: {}, summary: `${method.summary}`, params: {}, result: {}, examples: {} } anyOfTypes.forEach(anyOf => { let localized = localizeDependencies(anyOf, json) let title = localized.title || localized.name || '' let summary = localized.summary || localized.description || '' polymorphicMethodSchema.rpc_name = method.name polymorphicMethodSchema.name = foundAnyOfResult && isEventMethod(method) ? `${method.name}${title}` : method.name polymorphicMethodSchema.tags = method.tags polymorphicMethodSchema.params = foundAnyOfParams ? generateParamsAnyOfSchema(methodParams, anyOf, anyOfTypes, title, summary) : methodParams polymorphicMethodSchema.result = Object.assign({}, method.result) polymorphicMethodSchema.result.schema = foundAnyOfResult ? generateResultAnyOfSchema(method, methodResult, anyOf, anyOfTypes, title, summary) : methodResult.schema polymorphicMethodSchema.examples = method.examples polymorphicMethodSchemas.push(Object.assign({}, polymorphicMethodSchema)) }) } else { polymorphicMethodSchemas = method } return polymorphicMethodSchemas } const isSubSchema = (schema) => schema.type === 'object' || (schema.type === 'string' && schema.enum) const isSubEnumOfArraySchema = (schema) => (schema.type === 'array' && schema.items.enum) const addComponentSubSchemasNameForProperties = (key, schema) => { if ((schema.type === "object") && schema.properties) { Object.entries(schema.properties).forEach(([name, propSchema]) => { if (isSubSchema(propSchema)) { key = key + name.charAt(0).toUpperCase() + name.substring(1) if (!propSchema.title) { propSchema.title = key } propSchema = addComponentSubSchemasNameForProperties(key, propSchema) } else if (isSubEnumOfArraySchema(propSchema)) { key = key + name.charAt(0).toUpperCase() + name.substring(1) if (!propSchema.items.title) { propSchema.items.title = key } } }) } return schema } const addComponentSubSchemasName = (obj, schemas) => { Object.entries(schemas).forEach(([key, schema]) => { let componentSchemaProperties = schema.allOf ? schema.allOf : [schema] componentSchemaProperties.forEach((componentSchema) => { key = key.charAt(0).toUpperCase() + key.substring(1) componentSchema = addComponentSubSchemasNameForProperties(key, componentSchema) }) }) return schemas } const promoteAndNameXSchemas = (obj) => { obj = JSON.parse(JSON.stringify(obj)) if (obj['x-schemas']) { Object.entries(obj['x-schemas']).forEach(([name, schemas]) => { schemas = addComponentSubSchemasName(obj, schemas) }) } return obj } const getPathFromModule = (module, path) => { console.error("DEPRECATED: getPathFromModule") if (!path) return null let item = module try { path = path.split('#').pop().split('/') path.shift() path.forEach(node => { item = item[node] }) } catch (err) { return null } return item } const fireboltize = (json) => { json = generatePropertyEvents(json) json = generatePropertySetters(json) // TODO: we don't use this yet... consider removing? // json = generatePushPullMethods(json) // json = generateProvidedByMethods(json) json = generatePolymorphicPullEvents(json) json = generateProviderMethods(json) json = generateTemporalSetMethods(json) json = generateEventListenerParameters(json) json = generateEventListenResponse(json) return json } const fireboltizeMerged = (json) => { json = copyAllowFocusTags(json) return json } const getExternalMarkdownPaths = obj => { return getExternalSchemaPaths(obj) .filter(x => /^file:/.test(getPathOr(null, x, obj))) } const addExternalMarkdown = (data = {}, descriptions = {}) => { const paths = getExternalMarkdownPaths(data) paths.map(path => { const urn = getPathOr(null, path, data) const url = urn.indexOf("file:../") == 0 ? urn.substr("file:../".length) : urn.substr("file:".length) const markdownContent = descriptions[url] path.pop() // last element is expected to be `$ref` const field = path.pop() // relies on this position being the field name const objectNode = getPathOr(null, path, data) objectNode[field] = markdownContent // This mutates `data` by reference because JavaScript! }) return data } // grab a schema from another file in this project (which must be loaded into the schemas parameter as Map<$id, json-schema-document>) const getExternalPath = (uri = '', schemas = {}) => { if (!schemas) { return } const [mainPath, subPath] = uri.split('#') const json = schemas[mainPath] || schemas[mainPath + '/'] // copy to avoid side effects let result try { result = JSON.parse(JSON.stringify(subPath ? getPathOr(null, subPath.slice(1).split('/'), json) : json)) } catch (err) { console.log(`Error loading ${uri}`) console.log(err) process.exit(100) } return result } const getExternalSchemas = (json = {}, schemas = {}) => { // make a copy for safety! json = JSON.parse(JSON.stringify(json)) let refs = getExternalSchemaPaths(json) const returnedSchemas = {} const unresolvedRefs = [] while (refs.length > 0) { for (let i=0; i<refs.length; i++) { let path = refs[i] const ref = getPathOr(null, path, json) path.pop() // drop ref let resolvedSchema = getExternalPath(ref, schemas) if (!resolvedSchema) { // rename it so the while loop ends throw "Unresolved schema: " + ref } // replace the ref so we can recursively grab more refs if needed... else if (path.length) { returnedSchemas[ref] = JSON.parse(JSON.stringify(resolvedSchema)) // use a copy, so we don't pollute the returned schemas json = setPath(path, JSON.parse(JSON.stringify(resolvedSchema)), json) } else { delete json['$ref'] Object.assign(json, resolvedSchema) } } refs = getExternalSchemaPaths(json) } return returnedSchemas } const addExternalSchemas = (json, sharedSchemas) => { json = JSON.parse(JSON.stringify(json)) let searching = true while (searching) { searching = false const externalSchemas = getExternalSchemas(json, sharedSchemas) Object.entries(externalSchemas).forEach( ([name, schema]) => { const group = sharedSchemas[name.split('#')[0]].title const id = sharedSchemas[name.split('#')[0]].$id const refs = getLocalSchemaPaths(schema) refs.forEach(ref => { ref.pop() // drop the actual '$ref' so we can modify it getPathOr(null, ref, schema).$ref = id + getPathOr(null, ref, schema).$ref }) // if this schema is a child of some other schema that will be copied in this batch, then skip it if (Object.keys(externalSchemas).find(s => name.startsWith(s+'/') && s.length < name.length)) { console.log('Skipping: ' + name) console.log('Because of: ' + Object.keys(externalSchemas).find(s => name.startsWith(s) && s.length < name.length)) throw "Skipping sub schema" return } searching = true json['x-schemas'] = json['x-schemas'] || {} json['x-schemas'][group] = json['x-schemas'][group] || { uri: name.split("#")[0]} json['x-schemas'][group][name.split("/").pop()] = schema }) //update references to external schemas to be local Object.keys(externalSchemas).forEach(ref => { const group = sharedSchemas[ref.split('#')[0]].title replaceRef(ref, `#/x-schemas/${group}/${ref.split("#").pop().substring('/definitions/'.length)}`, json) }) } return json } // TODO: make this recursive, and check for group vs schema const removeUnusedSchemas = (json) => { const schema = JSON.parse(JSON.stringify(json)) const recurse = (schema, path) => { let deleted = false Object.keys(schema).forEach(name => { if (isSchema(schema[name])) { const used = isDefinitionReferencedBySchema(path + '/' + name, json) if (!used) { delete schema[name] deleted = true } else { } } else if (typeof schema[name] === 'object') { deleted = deleted || recurse(schema[name], path + '/' + name) } }) return deleted } if (schema.components.schemas) { while(recurse(schema.components.schemas, '#/components/schemas')) {} } if (schema['x-schemas']) { while(recurse(schema['x-schemas'], '#/x-schemas')) {} } return schema } const getModule = (name, json, copySchemas, extractSubSchemas) => { let openrpc = JSON.parse(JSON.stringify(json)) openrpc.methods = openrpc.methods .filter(method => method.name.toLowerCase().startsWith(name.toLowerCase() + '.')) .map(method => Object.assign(method, { name: method.name.split('.').pop() })) openrpc.info.title = name if (json.info['x-module-descriptions'] && json.info['x-module-descriptions'][name]) { openrpc.info.description = json.info['x-module-descriptions'][name] } delete openrpc.info['x-module-descriptions'] const copy = JSON.parse(JSON.stringify(openrpc)) // zap all of the schemas openrpc.components.schem