diff --git a/.versionbot/CHANGELOG.yml b/.versionbot/CHANGELOG.yml index 98a3078ff..5fadbe45f 100644 --- a/.versionbot/CHANGELOG.yml +++ b/.versionbot/CHANGELOG.yml @@ -1,3 +1,23 @@ +- commits: + - subject: Fixing issue reported by tsc 4.8.2 + hash: 57b05baa09718525b4e0e85c99b2c6c8c5b387ae + body: > + Inferenced type of check could be I & null + + when I is not set in the overload. + + + tsc 4.8 https://devblogs.microsoft.com/typescript/announcing-typescript-4-8/#unconstrained-generics-no-longer-assignable-to + footer: + Change-type: patch + change-type: patch + Signed-off-by: Harald Fischer + signed-off-by: Harald Fischer + author: Harald Fischer + nested: [] + version: 14.49.2 + title: "'Fixing issue reported by tsc 4.8.2'" + date: 2022-08-26T11:33:52.884Z - commits: - subject: Lazily define read-only hook args to avoid doing so unnecessarily hash: 56eef2eb82ab4f0b2aa436e8b126d0013df7a790 diff --git a/CHANGELOG.md b/CHANGELOG.md index 768221a49..c56f144eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file automatically by Versionist. DO NOT EDIT THIS FILE MANUALLY! This project adheres to [Semantic Versioning](http://semver.org/). +# v14.49.2 +## (2022-08-26) + +* Fixing issue reported by tsc 4.8.2 [Harald Fischer] + # v14.49.1 ## (2022-08-08) diff --git a/VERSION b/VERSION index 754d84db0..a04c03e15 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -14.49.1 \ No newline at end of file +14.49.2 \ No newline at end of file diff --git a/package.json b/package.json index a40fbd154..f3be4fdc1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@balena/pinejs", - "version": "14.49.1", + "version": "14.49.2", "main": "out/server-glue/module", "repository": "git@github.com:balena-io/pinejs.git", "license": "Apache-2.0", @@ -27,7 +27,9 @@ "compose:postgres": "docker-compose -f docker-compose.test.yml up -d postgres", "compose:build": "docker-compose -f docker-compose.test.yml build", "compose:down": "docker-compose -f docker-compose.test.yml down", - "prettify": "balena-lint -e js -e ts --fix src build typings Gruntfile.ts" + "prettify": "balena-lint -e js -e ts --fix src build typings Gruntfile.ts", + "serve-openapi": "redoc-cli serve --ssr ./example-openapi.json", + "build-openapi": "redoc-cli build ./example-openapi.json" }, "dependencies": { "@balena/abstract-sql-compiler": "^7.20.0", @@ -61,6 +63,7 @@ "express-session": "^1.17.3", "lodash": "^4.17.21", "memoizee": "^0.4.15", + "odata-openapi": "^0.18.1", "pinejs-client-core": "^6.10.2", "randomstring": "^1.2.2", "typed-error": "^3.2.1" @@ -91,6 +94,7 @@ "load-grunt-tasks": "^5.1.0", "mocha": "^9.2.2", "raw-loader": "^4.0.2", + "redoc-cli": "^0.13.18", "require-npm4-to-publish": "^1.0.0", "supertest": "^6.2.4", "terser-webpack-plugin": "^5.3.3", @@ -144,6 +148,6 @@ "recursive": true }, "versionist": { - "publishedAt": "2022-08-08T12:02:26.211Z" + "publishedAt": "2022-08-26T11:33:53.323Z" } } diff --git a/src/metadata/metadata-generators.ts b/src/metadata/metadata-generators.ts new file mode 100644 index 000000000..06d9c335c --- /dev/null +++ b/src/metadata/metadata-generators.ts @@ -0,0 +1,453 @@ +import type { + AbstractSqlModel, + AbstractSqlTable, +} 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'); + +// OData JSON v4 CSDL Vocabulary constants +// http://docs.oasis-open.org/odata/odata-vocabularies/v4.0/odata-vocabularies-v4.0.html +const odataVocabularyReferences: ODataCsdlV4References = { + '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', + }, + ], + }, +}; + +/** + * Odata Common Schema Definition Language JSON format + * http://docs.oasis-open.org/odata/odata-json-format/v4.0/odata-json-format-v4.0.html + */ + +type ODataCsdlV4References = { + [URI: string]: { + $Include: Array<{ + $Namespace: string; + $Alias: string; + [annotation: string]: string | boolean; + }>; + }; +}; + +type ODataCsdlV4BaseProperty = { + [annotation: string]: string | boolean | undefined; + $Type?: string; + $Nullable?: boolean; +}; + +type ODataCsdlV4StructuralProperty = ODataCsdlV4BaseProperty & { + $Kind?: 'Property'; // This member SHOULD be omitted to reduce document size. +}; + +type ODataCsdlV4NavigationProperty = ODataCsdlV4BaseProperty & { + $Kind: 'NavigationProperty'; + $Partner?: string; +}; + +type ODataCsdlV4Property = + | ODataCsdlV4BaseProperty + | ODataCsdlV4StructuralProperty + | ODataCsdlV4NavigationProperty; + +type ODataCsdlV4EntityType = { + $Kind: 'EntityType'; + $Key: string[]; + [property: string]: + | true + | string[] + | string + | 'EntityType' + | ODataCsdlV4Property; +}; + +type ODataCsdlV4EntityContainerEntries = { + $Collection: true; + $Type: string; + [property: string]: true | string | ODataCapabilitiesUDIRRestrictionsMethod; +}; + +type ODataCsdlV4Entities = { + [resource: string]: ODataCsdlV4EntityType; +}; + +type ODataCsdlV4EntityContainer = { + $Kind: 'EntityContainer'; + '@Capabilities.BatchSupported'?: boolean; + [resourceOrAnnotation: string]: + | 'EntityContainer' + | boolean + | string + | ODataCsdlV4EntityContainerEntries + | undefined; +}; + +type ODataCsdlV4Schema = { + $Alias: string; + '@Core.DefaultNamespace': true; + [resource: string]: + | string + | boolean + | ODataCsdlV4EntityContainer + | ODataCsdlV4EntityType; +}; + +type OdataCsdlV4 = { + $Version: string; + $Reference: ODataCsdlV4References; + $EntityContainer: string; + [schema: string]: string | ODataCsdlV4References | ODataCsdlV4Schema; +}; + +type PreparedPermissionsLookup = { + [vocabulary: string]: { + [resource: string]: { + read: boolean; + create: boolean; + update: boolean; + delete: boolean; + }; + }; +}; + +type PreparedAbstractModel = { + vocabulary: string; + abstractSqlModel: AbstractSqlModel; + preparedPermissionLookup: PreparedPermissionsLookup; +}; + +type ODataCapabilitiesUDIRRestrictionsMethod = + | { Updatable: boolean } + | { Deletable: boolean } + | { Insertable: boolean } + | { Readable: boolean }; + +const restrictionsLookup = ( + method: keyof PreparedPermissionsLookup[string][string] | 'all', + value: boolean, +) => { + const lookup = { + update: { + '@Capabilities.UpdateRestrictions': { + Updatable: value, + }, + }, + delete: { + '@Capabilities.DeleteRestrictions': { + Deletable: value, + }, + }, + create: { + '@Capabilities.InsertRestrictions': { + Insertable: value, + }, + }, + read: { + '@Capabilities.ReadRestrictions': { + Readable: value, + }, + }, + }; + + if (method === 'all') { + return { + ...lookup['update'], + ...lookup['delete'], + ...lookup['create'], + ...lookup['read'], + }; + } else { + return lookup[method] ?? {}; + } +}; + +const getResourceName = (resourceName: string): string => + resourceName + .split('-') + .map((namePart) => namePart.split(' ').join('_')) + .join('__'); + +const forEachUniqueTable = ( + model: PreparedAbstractModel, + callback: ( + tableName: string, + table: AbstractSqlTable & { referenceScheme: string }, + ) => T, +): T[] => { + const usedTableNames: { [tableName: string]: true } = {}; + + const result = []; + + for (const key of Object.keys(model.abstractSqlModel.tables).sort()) { + const table = model.abstractSqlModel.tables[key] as AbstractSqlTable & { + referenceScheme: string; + }; + if ( + typeof table !== 'string' && + !table.primitive && + !usedTableNames[table.name] && + model.preparedPermissionLookup + ) { + 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, +): PreparedPermissionsLookup => { + const resourcesAndOps: PreparedPermissionsLookup = {}; + + for (const resourceOpsAuths of Object.keys(permissionLookup)) { + const [vocabulary, resource, rule] = resourceOpsAuths.split('.'); + resourcesAndOps[vocabulary] ??= {}; + resourcesAndOps[vocabulary][resource] ??= { + ['read']: false, + ['create']: false, + ['update']: false, + ['delete']: false, + }; + + if (rule === 'all' || (resource === 'all' && rule === undefined)) { + resourcesAndOps[vocabulary][resource] = { + ['read']: true, + ['create']: true, + ['update']: true, + ['delete']: true, + }; + } else if ( + rule === 'read' || + rule === 'create' || + rule === 'update' || + rule === 'delete' + ) { + resourcesAndOps[vocabulary][resource][rule] = true; + } + } + return resourcesAndOps; +}; + +export const generateMetadataOData = ( + vocabulary: string, + abstractSqlModel: AbstractSqlModel, + permissionsLookup?: PermissionLookup, +) => { + const complexTypes: { [fieldType: string]: string } = {}; + const resolveDataType = (fieldType: string): string => { + if (sbvrTypes[fieldType] == null) { + console.error('Could not resolve type', fieldType); + throw new Error('Could not resolve type' + fieldType); + } + const { complexType } = sbvrTypes[fieldType].types.odata; + if (complexType != null) { + complexTypes[fieldType] = complexType; + } + return sbvrTypes[fieldType].types.odata.name; + }; + + const prepPermissionsLookup = permissionsLookup + ? preparePermissionsLookup(permissionsLookup) + : {}; + + const model: PreparedAbstractModel = { + vocabulary, + abstractSqlModel, + preparedPermissionLookup: prepPermissionsLookup, + }; + + const metaBalenaEntries: ODataCsdlV4Entities = {}; + const entityContainer: ODataCsdlV4EntityContainer = { + $Kind: 'EntityContainer', + '@Capabilities.KeyAsSegmentSupported': false, + }; + + forEachUniqueTable( + model, + (_key, { idField, name: resourceName, fields, referenceScheme }) => { + resourceName = getResourceName(resourceName); + // no path nor entity when permissions not contain resource + const permissions: PreparedPermissionsLookup[string][string] = + model?.preparedPermissionLookup?.['resource']?.['all'] ?? + model?.preparedPermissionLookup?.[model.vocabulary]?.['all'] ?? + model?.preparedPermissionLookup?.[model.vocabulary]?.[resourceName]; + + if (!permissions) { + return; + } + + const uniqueTable: ODataCsdlV4EntityType = { + $Kind: 'EntityType', + $Key: [idField], + '@Core.LongDescription': + '{"x-internal-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; + + let entityCon: ODataCsdlV4EntityContainerEntries = { + $Collection: true, + $Type: vocabulary + '.' + resourceName, + }; + for (const [resKey, resValue] of Object.entries(permissions) as Array< + [keyof PreparedPermissionsLookup[string][string], boolean] + >) { + entityCon = { ...entityCon, ...restrictionsLookup(resKey, resValue) }; + } + + entityContainer[resourceName] = entityCon; + }, + ); + + const odataCsdl: OdataCsdlV4 = { + $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, + [vocabulary]: { + // schema + $Alias: vocabulary, + '@Core.DefaultNamespace': true, + ...metaBalenaEntries, + ['ODataApi']: entityContainer, + }, + }; + + return odataCsdl; +}; + +export const generateMetadataOpenApi = ( + vocabulary: string, + abstractSqlModel: AbstractSqlModel, + permissionsLookup?: PermissionLookup, + versionBasePathUrl: string = '', + hostname: string = '', +) => { + const odataCsdl = generateMetadataOData( + vocabulary, + abstractSqlModel, + permissionsLookup, + ); + + // console.log(`odataCsdl:${JSON.stringify(odataCsdl, null, 2)}`); + 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; +}; + +generateMetadataOData.version = version; diff --git a/src/odata-metadata/odata-metadata-generator.ts b/src/odata-metadata/odata-metadata-generator.ts index f36adfa2d..3da3f6c10 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,17 +110,25 @@ 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, table] of Object.entries(model)) { + + 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] + !usedTableNames[table.name] && + model.permissionLookup ) { usedTableNames[table.name] = true; result.push(callback(key, table)); @@ -34,9 +137,48 @@ const forEachUniqueTable = ( 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 => { @@ -51,132 +193,189 @@ 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] ?? prepPermissionsLookup['resource'], + }; - ` + - forEachUniqueTable(model, (_key, { name: resourceName }) => { + let metaBalena: ODataNameSpaceType = { + $Alias: vocabulary, + '@Core.DefaultNamespace': true, + }; + + 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] && + !(model?.permissionLookup?.['all'] === true) + ) { + 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 capabilitiesEnabled = false; + if ( + model?.permissionLookup?.[resourceName]?.hasOwnProperty(key) || + model?.permissionLookup?.['all'] === true + ) { + capabilitiesEnabled = true; + } + const restriction = { + ['@Capabilities.' + value.capability]: { + [value.ValueIdentifier]: capabilitiesEnabled, + }, + }; + + 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 1227be777..5bdcb9888 100644 --- a/src/sbvr-api/permissions.ts +++ b/src/sbvr-api/permissions.ts @@ -22,7 +22,7 @@ import type { } from '@balena/odata-parser'; import type { Tx } from '../database-layer/db'; import type { ApiKey, User } from '../sbvr-api/sbvr-utils'; -import type { AnyObject, Dictionary } from './common-types'; +import type { AnyObject } from './common-types'; import { isBindReference, @@ -175,19 +175,19 @@ const isAnd = (x: any): x is NestedCheckAnd => typeof x === 'object' && 'and' in x; const isOr = (x: any): x is NestedCheckOr => typeof x === 'object' && 'or' in x; -export function nestedCheck( +export function nestedCheck( check: string, stringCallback: (s: string) => O, ): O; -export function nestedCheck( +export function nestedCheck( check: boolean, stringCallback: (s: string) => O, ): boolean; -export function nestedCheck( +export function nestedCheck( check: NestedCheck, stringCallback: (s: string) => O, ): Exclude | O | MappedNestedCheck; -export function nestedCheck( +export function nestedCheck( check: NestedCheck, stringCallback: (s: string) => O, ): boolean | Exclude | O | MappedNestedCheck { diff --git a/src/sbvr-api/sbvr-utils.ts b/src/sbvr-api/sbvr-utils.ts index dbc7a84d4..9ade41f85 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 syncMigrator from '../migrator/sync'; -import { generateODataMetadata } from '../odata-metadata/odata-metadata-generator'; +import { + generateMetadataOData, + generateMetadataOpenApi, +} from '../metadata/metadata-generators'; // tslint:disable-next-line:no-var-requires const devModel = require('./dev.sbvr'); @@ -105,7 +108,7 @@ interface CompiledModel { lf?: LFModel | undefined; abstractSql: AbstractSQLCompiler.AbstractSqlModel; sql: AbstractSQLCompiler.SqlModel; - odataMetadata: ReturnType; + odataMetadata: ReturnType; } const models: { [vocabulary: string]: CompiledModel; @@ -452,9 +455,9 @@ export const generateModels = ( const odataMetadata = cachedCompile( 'metadata', - generateODataMetadata.version, + generateMetadataOData.version, { vocab, abstractSqlModel: abstractSql }, - () => generateODataMetadata(vocab, abstractSql), + () => generateMetadataOData(vocab, abstractSql), ); let sql: ReturnType; @@ -1533,11 +1536,34 @@ const respondGet = async ( return response; } else { if (request.resourceName === '$metadata') { - return { - statusCode: 200, - body: models[vocab].odataMetadata, - headers: { 'content-type': 'xml' }, - }; + const { openapi } = req.body; + const permLookup = await permissions.getReqPermissions(req); + let specJson = {}; + if (openapi) { + specJson = generateMetadataOpenApi( + vocab, + models[vocab].abstractSql, + permLookup, + req.originalUrl.replace('/$metadata', ''), + req.hostname, + ); + return { + statusCode: 200, + body: specJson, + headers: { 'content-type': 'application/json' }, + }; + } else { + specJson = generateMetadataOData( + vocab, + models[vocab].abstractSql, + permLookup, + ); + return { + statusCode: 200, + body: models[vocab].odataMetadata, + headers: { 'content-type': 'application/json' }, + }; + } } else { // TODO: request.resourceName can be '$serviceroot' or a resource and we should return an odata xml document based on that return { @@ -1547,6 +1573,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/test/03-metadata.test.ts b/test/03-metadata.test.ts new file mode 100644 index 000000000..589d32d86 --- /dev/null +++ b/test/03-metadata.test.ts @@ -0,0 +1,67 @@ +import { expect } from 'chai'; +import * as supertest from 'supertest'; +const fixturePath = __dirname + '/fixtures/03-metadata/config'; +import { testInit, testDeInit, testLocalServer } from './lib/test-init'; + +describe('00 basic tests', function () { + let pineServer: Awaited>; + before(async () => { + pineServer = await testInit(fixturePath, true); + }); + + after(async () => { + await testDeInit(pineServer); + }); + + describe('Basic', () => { + it('check /ping route is OK', async () => { + await supertest(testLocalServer).get('/ping').expect(200, 'OK'); + }); + }); + + // TODO Check deprecation for endpoints + + describe('get metadata', () => { + it('check /example/$metadata is served by pinejs', async () => { + const res = await supertest(testLocalServer) + .get('/example/$metadata') + .send({ openapi: true }) + .expect(200); + expect(res.body.paths).to.be.an('object'); + + // full CRUD access for device resource + + expect(res.body.paths).to.have.property('/device'); + const devicePath = res.body.paths['/device']; + expect(devicePath).to.have.all.keys(['get', 'post']); + + expect(res.body.paths).to.have.property('/device({id})'); + const deviceIdPath = res.body.paths['/device({id})']; + expect(deviceIdPath).to.have.all.keys([ + 'get', + 'patch', + 'delete', + 'parameters', + ]); + + // only CRU access for application resource - no delete + expect(res.body.paths).to.have.property('/application'); + const applicationPath = res.body.paths['/application']; + expect(applicationPath).to.have.all.keys(['get', 'post']); + + expect(res.body.paths).to.have.property('/application({id})'); + const applicationIdPath = res.body.paths['/application({id})']; + expect(applicationIdPath).to.have.all.keys([ + 'get', + 'patch', + 'parameters', + ]); + + // Read only access for gateway resource + expect(res.body.paths).to.have.property('/gateway'); + const gatewayPath = res.body.paths['/gateway']; + expect(gatewayPath).to.have.keys(['get']); + expect(gatewayPath).to.not.have.any.keys(['post']); + }); + }); +}); diff --git a/test/fixtures/03-metadata/config.ts b/test/fixtures/03-metadata/config.ts new file mode 100644 index 000000000..28bcb0c99 --- /dev/null +++ b/test/fixtures/03-metadata/config.ts @@ -0,0 +1,24 @@ +import type { ConfigLoader } from '../../../src/server-glue/module'; + +export default { + models: [ + { + apiRoot: 'example', + modelFile: __dirname + '/example.sbvr', + modelName: 'example', + }, + ], + users: [ + { + username: 'guest', + password: ' ', + permissions: [ + 'example.device.all', + 'example.application.create', + 'example.application.read', + 'example.application.update', + 'example.gateway.read', + ], + }, + ], +} as ConfigLoader.Config; diff --git a/test/fixtures/03-metadata/example.sbvr b/test/fixtures/03-metadata/example.sbvr new file mode 100644 index 000000000..480988b97 --- /dev/null +++ b/test/fixtures/03-metadata/example.sbvr @@ -0,0 +1,41 @@ +Vocabulary: example + +Term: name + Concept Type: Short Text (Type) + +Term: note + Concept Type: Text (Type) + +Term: type + Concept Type: Short Text (Type) + + +Term: gateway + +Fact Type: gateway has name + Necessity: each gateway has exactly one name. + +Term: application + +Fact Type: application has name + Necessity: each application has at most one name. + +Fact Type: application has note + Necessity: each application has at most one note. + +Fact Type: application has type + Necessity: each application has exactly one type. + +Term: device + +Fact Type: device has name + Necessity: each device has at most one name. + +Fact Type: device has note + Necessity: each device has at most one note. + +Fact Type: device has type + Necessity: each device has exactly one type. + +Fact Type: device belongs to application + Necessity: each device belongs to exactly one application diff --git a/test/fixtures/03-metadata/openapi.json b/test/fixtures/03-metadata/openapi.json new file mode 100644 index 000000000..47f402d64 --- /dev/null +++ b/test/fixtures/03-metadata/openapi.json @@ -0,0 +1,23 @@ +{ + "req": { + "method": "GET", + "url": "http://localhost:1337/example/$metadata", + "data": { + "openapi": true + }, + "headers": { + "content-type": "application/json" + } + }, + "header": { + "x-powered-by": "Express", + "cache-control": "no-cache", + "content-type": "application/json; charset=utf-8", + "content-length": "2139", + "etag": "W/\"85b-bxp1fjUPz+3tZ4a5Ox6dkCS1b3c\"", + "date": "Mon, 25 Jul 2022 10:13:47 GMT", + "connection": "close" + }, + "status": 200, + "text": "{\"openapi\":\"3.0.2\",\"info\":{\"title\":\"Service for namespace example\",\"description\":\"This service is located at [https://localhost/example/](https://localhost/example/)\",\"version\":\"\"},\"servers\":[{\"url\":\"https://localhost/example\"}],\"tags\":[],\"paths\":{},\"components\":{\"schemas\":{\"count\":{\"anyOf\":[{\"type\":\"integer\",\"minimum\":0},{\"type\":\"string\"}],\"description\":\"The number of entities in the collection. Available when using the [$count](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-protocol.html#sec_SystemQueryOptioncount) query option.\"},\"error\":{\"type\":\"object\",\"required\":[\"error\"],\"properties\":{\"error\":{\"type\":\"object\",\"required\":[\"code\",\"message\"],\"properties\":{\"code\":{\"type\":\"string\"},\"message\":{\"type\":\"string\"},\"target\":{\"type\":\"string\"},\"details\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"code\",\"message\"],\"properties\":{\"code\":{\"type\":\"string\"},\"message\":{\"type\":\"string\"},\"target\":{\"type\":\"string\"}}}},\"innererror\":{\"type\":\"object\",\"description\":\"The structure of this object is service-specific\"}}}}}},\"parameters\":{\"top\":{\"name\":\"top\",\"in\":\"query\",\"description\":\"Show only the first n items, see [Paging - Top](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-protocol.html#sec_SystemQueryOptiontop)\",\"schema\":{\"type\":\"integer\",\"minimum\":0},\"example\":50},\"skip\":{\"name\":\"skip\",\"in\":\"query\",\"description\":\"Skip the first n items, see [Paging - Skip](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-protocol.html#sec_SystemQueryOptionskip)\",\"schema\":{\"type\":\"integer\",\"minimum\":0}},\"count\":{\"name\":\"count\",\"in\":\"query\",\"description\":\"Include count of items, see [Count](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-protocol.html#sec_SystemQueryOptioncount)\",\"schema\":{\"type\":\"boolean\"}},\"search\":{\"name\":\"search\",\"in\":\"query\",\"description\":\"Search items by search phrases, see [Searching](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-protocol.html#sec_SystemQueryOptionsearch)\",\"schema\":{\"type\":\"string\"}}},\"responses\":{\"error\":{\"description\":\"Error\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/error\"}}}}}}}" +} \ No newline at end of file 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; +}