diff --git a/package.json b/package.json index 57edacc93..d1e50ba05 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,8 @@ "memoizee": "^0.4.15", "pinejs-client-core": "^6.10.0", "randomstring": "^1.2.2", - "typed-error": "^3.2.1" + "typed-error": "^3.2.1", + "odata-openapi": "^0.12.0" }, "devDependencies": { "@balena/lint": "^6.2.0", diff --git a/src/database-layer/db.ts b/src/database-layer/db.ts index a4ecf7534..12146fa03 100644 --- a/src/database-layer/db.ts +++ b/src/database-layer/db.ts @@ -475,7 +475,6 @@ if (maybePg != null) { config.keepAlive ??= env.db.keepAlive; // @ts-expect-error maxLifetimeSeconds is valid for PgPool but isn't currently in the typings config.maxLifetimeSeconds ??= env.db.maxLifetimeSeconds; - // @ts-expect-error maxUses is valid for PgPool but isn't currently in the typings config.maxUses ??= env.db.maxUses; const p = new pg.Pool(config); if (PG_SCHEMA != null) { diff --git a/src/odata-metadata/odata-metadata-generator.ts b/src/odata-metadata/odata-metadata-generator.ts index da11738b1..8e67aad58 100644 --- a/src/odata-metadata/odata-metadata-generator.ts +++ b/src/odata-metadata/odata-metadata-generator.ts @@ -4,10 +4,105 @@ import type { } from '@balena/abstract-sql-compiler'; import * as sbvrTypes from '@balena/sbvr-types'; +import { PermissionLookup } from '../sbvr-api/permissions'; +import * as odataMetadata from 'odata-openapi'; // tslint:disable-next-line:no-var-requires const { version }: { version: string } = require('../../package.json'); +type dict = { [key: string]: any }; +interface OdataCsdl { + $Version: string; + $EntityContainer: string; + [key: string]: any; +} + +interface ODataNameSpaceType { + $Alias: string; + '@Core.DefaultNamespace': boolean; + [key: string]: any; +} +interface ODataEntityContainerType { + $Kind: 'EntityContainer'; + [key: string]: any; +} + +interface ODataEntityContainerEntryType { + $Kind: 'EntityType' | 'ComplexType' | 'NavigationProperty'; + [key: string]: any; +} + +interface AbstractModel { + abstractSqlModel: AbstractSqlModel; + permissionLookup: PermissionLookup; +} + +/** OData JSON v4 CSDL Vocabulary constants + * + * http://docs.oasis-open.org/odata/odata-vocabularies/v4.0/odata-vocabularies-v4.0.html + * + */ + +const odataVocabularyReferences = { + 'https://oasis-tcs.github.io/odata-vocabularies/vocabularies/Org.OData.Core.V1.json': + { + $Include: [ + { + $Namespace: 'Org.OData.Core.V1', + $Alias: 'Core', + '@Core.DefaultNamespace': true, + }, + ], + }, + 'https://oasis-tcs.github.io/odata-vocabularies/vocabularies/Org.OData.Measures.V1.json': + { + $Include: [ + { + $Namespace: 'Org.OData.Measures.V1', + $Alias: 'Measures', + }, + ], + }, + 'https://oasis-tcs.github.io/odata-vocabularies/vocabularies/Org.OData.Aggregation.V1.json': + { + $Include: [ + { + $Namespace: 'Org.OData.Aggregation.V1', + $Alias: 'Aggregation', + }, + ], + }, + 'https://oasis-tcs.github.io/odata-vocabularies/vocabularies/Org.OData.Capabilities.V1.json': + { + $Include: [ + { + $Namespace: 'Org.OData.Capabilities.V1', + $Alias: 'Capabilities', + }, + ], + }, +}; + +// https://github.com/oasis-tcs/odata-vocabularies/blob/main/vocabularies/Org.OData.Capabilities.V1.md +const restrictionsLookup = { + update: { + capability: 'UpdateRestrictions', + ValueIdentifier: 'Updatable', + }, + delete: { + capability: 'DeleteRestrictions', + ValueIdentifier: 'Deletable', + }, + create: { + capability: 'InsertRestrictions', + ValueIdentifier: 'Insertable', + }, + read: { + capability: 'ReadRestrictions', + ValueIdentifier: 'Readable', + }, +}; + const getResourceName = (resourceName: string): string => resourceName .split('-') @@ -15,31 +110,75 @@ const getResourceName = (resourceName: string): string => .join('__'); const forEachUniqueTable = ( - model: AbstractSqlModel['tables'], - callback: (tableName: string, table: AbstractSqlTable) => T, + model: AbstractModel, + callback: ( + tableName: string, + table: AbstractSqlTable & { referenceScheme: string }, + ) => T, ): T[] => { const usedTableNames: { [tableName: string]: true } = {}; const result = []; - for (const key in model) { - if (model.hasOwnProperty(key)) { - const table = model[key]; - if ( - typeof table !== 'string' && - !table.primitive && - !usedTableNames[table.name] - ) { - usedTableNames[table.name] = true; - result.push(callback(key, table)); - } + + for (const key of Object.keys(model.abstractSqlModel.tables)) { + const table = model.abstractSqlModel.tables[key] as AbstractSqlTable & { + referenceScheme: string; + }; + if ( + typeof table !== 'string' && + !table.primitive && + !usedTableNames[table.name] && + model.permissionLookup + ) { + usedTableNames[table.name] = true; + result.push(callback(key, table)); } } return result; }; +/** + * parsing dictionary of vocabulary.resource.operation permissions string + * into dictionary of resource to operation for later lookup + */ +const preparePermissionsLookup = (permissionLookup: PermissionLookup): dict => { + const pathsAndOps: dict = {}; + + for (const pathOpsAuths of Object.keys(permissionLookup)) { + const [vocabulary, path, rule] = pathOpsAuths.split('.'); + + pathsAndOps[vocabulary] = Object.assign( + { [path]: {} }, + pathsAndOps[vocabulary], + ); + if (rule === 'all') { + pathsAndOps[vocabulary][path] = Object.assign( + { + ['read']: true, + ['create']: true, + ['update']: true, + ['delete']: true, + }, + pathsAndOps[vocabulary][path], + ); + } else if (rule === undefined) { + // just true no operation to be named + pathsAndOps[vocabulary][path] = true; + } else { + pathsAndOps[vocabulary][path] = Object.assign( + { [rule]: true }, + pathsAndOps[vocabulary][path], + ); + } + } + + return pathsAndOps; +}; + export const generateODataMetadata = ( vocabulary: string, abstractSqlModel: AbstractSqlModel, + permissionsLookup?: PermissionLookup, ) => { const complexTypes: { [fieldType: string]: string } = {}; const resolveDataType = (fieldType: string): string => { @@ -54,132 +193,182 @@ export const generateODataMetadata = ( return sbvrTypes[fieldType].types.odata.name; }; - const model = abstractSqlModel.tables; - const associations: Array<{ - name: string; - ends: Array<{ - resourceName: string; - cardinality: '1' | '0..1' | '*'; - }>; - }> = []; - forEachUniqueTable(model, (_key, { name: resourceName, fields }) => { - resourceName = getResourceName(resourceName); - for (const { dataType, required, references } of fields) { - if (dataType === 'ForeignKey' && references != null) { - const { resourceName: referencedResource } = references; - associations.push({ - name: resourceName + referencedResource, - ends: [ - { resourceName, cardinality: required ? '1' : '0..1' }, - { resourceName: referencedResource, cardinality: '*' }, - ], - }); - } - } - }); + const prepPermissionsLookup = permissionsLookup + ? preparePermissionsLookup(permissionsLookup) + : {}; - return ( - ` - - - - - - ` + - forEachUniqueTable( - model, - (_key, { idField, name: resourceName, fields }) => { - resourceName = getResourceName(resourceName); - return ( - ` - - - - - - ` + - fields - .filter(({ dataType }) => dataType !== 'ForeignKey') - .map(({ dataType, fieldName, required }) => { - dataType = resolveDataType(dataType); - fieldName = getResourceName(fieldName); - return ``; - }) - .join('\n') + - '\n' + - fields - .filter( - ({ dataType, references }) => - dataType === 'ForeignKey' && references != null, - ) - .map(({ fieldName, references }) => { - const { resourceName: referencedResource } = references!; - fieldName = getResourceName(fieldName); - return ``; - }) - .join('\n') + - '\n' + - ` - ` - ); - }, - ).join('\n\n') + - associations - .map(({ name, ends }) => { - name = getResourceName(name); - return ( - `` + - '\n\t' + - ends - .map( - ({ resourceName, cardinality }) => - ``, - ) - .join('\n\t') + - '\n' + - `` - ); - }) - .join('\n') + - ` - + const model: AbstractModel = { + abstractSqlModel, + permissionLookup: prepPermissionsLookup[vocabulary], + }; + + let metaBalena: ODataNameSpaceType = { + $Alias: vocabulary, + '@Core.DefaultNamespace': true, + }; - ` + - forEachUniqueTable(model, (_key, { name: resourceName }) => { + let metaBalenaEntries: dict = {}; + let entityContainerEntries: dict = {}; + forEachUniqueTable( + model, + (_key, { idField, name: resourceName, fields, referenceScheme }) => { resourceName = getResourceName(resourceName); - return ``; - }).join('\n') + - '\n' + - associations - .map(({ name, ends }) => { - name = getResourceName(name); - return ( - `` + - '\n\t' + - ends - .map( - ({ resourceName }) => - ``, - ) - .join('\n\t') + - ` - ` + // no path nor entity when permissions not contain resource + if (!model?.permissionLookup?.[resourceName]) { + return; + } + + const uniqueTable: ODataEntityContainerEntryType = { + $Kind: 'EntityType', + $Key: [idField], + '@Core.LongDescription': + '{"x-ref-scheme": ["' + referenceScheme + '"]}', + }; + + fields + .filter(({ dataType }) => dataType !== 'ForeignKey') + .map(({ dataType, fieldName, required }) => { + dataType = resolveDataType(dataType); + fieldName = getResourceName(fieldName); + + uniqueTable[fieldName] = { + $Type: dataType, + $Nullable: !required, + '@Core.Computed': + fieldName === 'created_at' || fieldName === 'modified_at' + ? true + : false, + }; + }); + + fields + .filter( + ({ dataType, references }) => + dataType === 'ForeignKey' && references != null, + ) + .map(({ fieldName, references, required }) => { + const { resourceName: referencedResource } = references!; + const referencedResourceName = + model.abstractSqlModel.tables[referencedResource]?.name; + const typeReference = referencedResourceName || referencedResource; + + fieldName = getResourceName(fieldName); + uniqueTable[fieldName] = { + $Kind: 'NavigationProperty', + $Partner: resourceName, + $Nullable: !required, + $Type: vocabulary + '.' + getResourceName(typeReference), + }; + }); + + metaBalenaEntries[resourceName] = uniqueTable; + + entityContainerEntries[resourceName] = { + $Collection: true, + $Type: vocabulary + '.' + resourceName, + }; + + for (const [key, value] of Object.entries(restrictionsLookup)) { + let restrictionValue = false; + if (model?.permissionLookup?.[resourceName]?.hasOwnProperty(key)) { + restrictionValue = true; + } + const restriction = { + ['@Capabilities.' + value.capability]: { + [value.ValueIdentifier]: restrictionValue, + }, + }; + + entityContainerEntries[resourceName] = Object.assign( + entityContainerEntries[resourceName], + restriction, ); - }) - .join('\n') + - ` - ` + - Object.values(complexTypes).join('\n') + - ` - - - ` + } + }, ); + + metaBalenaEntries = Object.keys(metaBalenaEntries) + .sort() + .reduce((r, k) => ((r[k] = metaBalenaEntries[k]), r), {} as dict); + + metaBalena = { ...metaBalena, ...metaBalenaEntries }; + + let oDataApi: ODataEntityContainerType = { + $Kind: 'EntityContainer', + '@Capabilities.BatchSupported': false, + }; + + const odataCsdl: OdataCsdl = { + $Version: '4.01', // because of odata2openapi transformer has a hacky switch on === 4.0 that we don't want. Other checks are checking for >=4.0. + $EntityContainer: vocabulary + '.ODataApi', + $Reference: odataVocabularyReferences, + }; + + entityContainerEntries = Object.keys(entityContainerEntries) + .sort() + .reduce((r, k) => ((r[k] = entityContainerEntries[k]), r), {} as dict); + + oDataApi = { ...oDataApi, ...entityContainerEntries }; + + metaBalena['ODataApi'] = oDataApi; + + odataCsdl[vocabulary] = metaBalena; + + return odataCsdl; +}; + +export const generateODataOpenAPI = ( + vocabulary: string, + abstractSqlModel: AbstractSqlModel, + permissionsLookup?: PermissionLookup, + versionBasePathUrl: string = '', + hostname: string = '', +) => { + const odataCsdl = generateODataMetadata( + vocabulary, + abstractSqlModel, + permissionsLookup, + ); + const openAPIJson: any = odataMetadata.csdl2openapi(odataCsdl, { + scheme: 'https', + host: hostname, + basePath: versionBasePathUrl, + diagram: false, + maxLevels: 5, + }); + + /** + * HACK + * Rewrite odata body response schema properties from `value:` to `d:` + * Currently pinejs is returning `d:` + * https://www.odata.org/documentation/odata-version-2-0/json-format/ (6. Representing Collections of Entries) + * https://www.odata.org/documentation/odata-version-3-0/json-verbose-format/ (6.1 Response body) + * + * New v4 odata specifies the body response with `value:` + * http://docs.oasis-open.org/odata/odata-json-format/v4.01/odata-json-format-v4.01.html#sec_IndividualPropertyorOperationRespons + * + * Used oasis translator generates openapi according to v4 spec (`value:`) + */ + + Object.keys(openAPIJson.paths).forEach((i) => { + if ( + openAPIJson?.paths[i]?.get?.responses?.['200']?.content?.[ + 'application/json' + ]?.schema?.properties?.value + ) { + openAPIJson.paths[i].get.responses['200'].content[ + 'application/json' + ].schema.properties['d'] = + openAPIJson.paths[i].get.responses['200'].content[ + 'application/json' + ].schema.properties.value; + delete openAPIJson.paths[i].get.responses['200'].content[ + 'application/json' + ].schema.properties.value; + } + }); + + return openAPIJson; }; generateODataMetadata.version = version; diff --git a/src/sbvr-api/permissions.ts b/src/sbvr-api/permissions.ts index f55634ddf..689fbe73f 100644 --- a/src/sbvr-api/permissions.ts +++ b/src/sbvr-api/permissions.ts @@ -307,7 +307,7 @@ const namespaceRelationships = ( }); }; -type PermissionLookup = _.Dictionary; +export type PermissionLookup = _.Dictionary; const getPermissionsLookup = env.createCache( 'permissionsLookup', @@ -1545,7 +1545,7 @@ const getGuestPermissions = memoize( { promise: true }, ); -const getReqPermissions = async ( +export const getReqPermissions = async ( req: PermissionReq, odataBinds: ODataBinds = [], ) => { diff --git a/src/sbvr-api/sbvr-utils.ts b/src/sbvr-api/sbvr-utils.ts index 864d9b2ed..7e6aec6ce 100644 --- a/src/sbvr-api/sbvr-utils.ts +++ b/src/sbvr-api/sbvr-utils.ts @@ -34,7 +34,10 @@ import { PinejsClientCore, PromiseResultTypes } from 'pinejs-client-core'; import { ExtendedSBVRParser } from '../extended-sbvr-parser/extended-sbvr-parser'; import * as migrator from '../migrator/migrator'; -import { generateODataMetadata } from '../odata-metadata/odata-metadata-generator'; +import { + generateODataMetadata, + generateODataOpenAPI, +} from '../odata-metadata/odata-metadata-generator'; // tslint:disable-next-line:no-var-requires const devModel = require('./dev.sbvr'); @@ -1477,9 +1480,28 @@ const respondGet = async ( return { body: { d }, headers: { contentType: 'application/json' } }; } else { if (request.resourceName === '$metadata') { + const { openapi } = req.body; + const permLookup = await permissions.getReqPermissions(req); + let specJson = {}; + if (openapi) { + specJson = generateODataOpenAPI( + vocab, + models[vocab].abstractSql, + permLookup, + req.originalUrl.replace('/$metadata', ''), + req.hostname, + ); + } else { + specJson = generateODataMetadata( + vocab, + models[vocab].abstractSql, + permLookup, + ); + } + return { - body: models[vocab].odataMetadata, - headers: { contentType: 'xml' }, + body: specJson, + headers: { contentType: 'json' }, }; } else { // TODO: request.resourceName can be '$serviceroot' or a resource and we should return an odata xml document based on that @@ -1490,6 +1512,8 @@ const respondGet = async ( } }; +// paths./any/.get.responses.200.content.application/json.schema.d + const runPost = async ( _req: Express.Request, request: uriParser.ODataRequest, diff --git a/typings/odata-openapi.d.ts b/typings/odata-openapi.d.ts new file mode 100644 index 000000000..b91ee894d --- /dev/null +++ b/typings/odata-openapi.d.ts @@ -0,0 +1,6 @@ +declare module 'odata-openapi' { + export const csdl2openapi: ( + csdl, + { scheme, host, basePath, diagram, maxLevels } = {}, + ) => object; +}