diff --git a/package.json b/package.json index e0d65608d..5c64e51e3 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "lodash": "^4.17.21", "memoizee": "^0.4.15", "pinejs-client-core": "^6.12.3", + "odata-openapi": "^0.19.1", "randomstring": "^1.2.3", "typed-error": "^3.2.2" }, diff --git a/src/odata-metadata/odata-metadata-generator.ts b/src/odata-metadata/odata-metadata-generator.ts index beb108625..8bf050a62 100644 --- a/src/odata-metadata/odata-metadata-generator.ts +++ b/src/odata-metadata/odata-metadata-generator.ts @@ -4,10 +4,199 @@ import type { } from '@balena/abstract-sql-compiler'; import sbvrTypes, { SbvrType } from '@balena/sbvr-types'; +import { PermissionLookup } from '../sbvr-api/permissions'; // 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('-') @@ -15,17 +204,25 @@ const getResourceName = (resourceName: string): string => .join('__'); const forEachUniqueTable = ( - model: AbstractSqlModel['tables'], - callback: (tableName: string, table: AbstractSqlTable) => T, + model: PreparedAbstractModel, + 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).sort()) { + const table = model.abstractSqlModel.tables[key] as AbstractSqlTable & { + referenceScheme: string; + }; if ( typeof table !== 'string' && !table.primitive && - !usedTableNames[table.name] + !usedTableNames[table.name] && + model.preparedPermissionLookup ) { usedTableNames[table.name] = true; result.push(callback(key, table)); @@ -34,9 +231,49 @@ 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, +): 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 generateODataMetadata = ( vocabulary: string, abstractSqlModel: AbstractSqlModel, + permissionsLookup?: PermissionLookup, ) => { const complexTypes: { [fieldType: string]: string } = {}; const resolveDataType = (fieldType: keyof typeof sbvrTypes): string => { @@ -51,132 +288,114 @@ 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) + : {}; + + 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 as keyof typeof sbvrTypes); + 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) }; } - } - }); - - return ( - ` - - - - - - ` + - forEachUniqueTable( - model, - (_key, { idField, name: resourceName, fields }) => { - resourceName = getResourceName(resourceName); - return ( - ` - - - - - - ` + - fields - .filter(({ dataType }) => dataType !== 'ForeignKey') - .map(({ dataType, fieldName, required }) => { - dataType = resolveDataType(dataType as keyof typeof sbvrTypes); - 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') + - ` - - - ` + - forEachUniqueTable(model, (_key, { name: resourceName }) => { - resourceName = getResourceName(resourceName); - return ``; - }).join('\n') + - '\n' + - associations - .map(({ name, ends }) => { - name = getResourceName(name); - return ( - `` + - '\n\t' + - ends - .map( - ({ resourceName }) => - ``, - ) - .join('\n\t') + - ` - ` - ); - }) - .join('\n') + - ` - ` + - Object.values(complexTypes).join('\n') + - ` - - - ` + + entityContainer[resourceName] = entityCon; + }, ); + + const odataCsdl: OdataCsdlV4 = { + // needs to be === '4.0' as > '4.0' in csdl2openapi will switch to drop the `$` query parameter prefix for eg $top, $skip as it became optional in OData V4.01 + $Version: '3.0', + $EntityContainer: vocabulary + '.ODataApi', + $Reference: odataVocabularyReferences, + [vocabulary]: { + // schema + $Alias: vocabulary, + '@Core.DefaultNamespace': true, + '@Core.Description': `OpenAPI specification for PineJS served SBVR datamodel: ${vocabulary}`, + '@Core.LongDescription': + 'Auto-Genrated OpenAPI specification by utilizing OData CSDL to OpenAPI spec transformer.', + '@Core.SchemaVersion': version, + ...metaBalenaEntries, + ['ODataApi']: entityContainer, + }, + }; + + return odataCsdl; }; generateODataMetadata.version = version; diff --git a/src/odata-metadata/open-api-sepcification-generator.ts b/src/odata-metadata/open-api-sepcification-generator.ts new file mode 100644 index 000000000..034a5e78b --- /dev/null +++ b/src/odata-metadata/open-api-sepcification-generator.ts @@ -0,0 +1,77 @@ +import * as odataMetadata from 'odata-openapi'; +import { generateODataMetadata } from './odata-metadata-generator'; +// tslint:disable-next-line:no-var-requires + +export const generateODataMetadataAsOpenApi = ( + odataCsdl: ReturnType, + versionBasePathUrl: string = '', + hostname: string = '', +) => { + // console.log(`odataCsdl:${JSON.stringify(odataCsdl, null, 2)}`); + const openAPIJson: any = odataMetadata.csdl2openapi(odataCsdl, { + scheme: 'https', + host: hostname, + basePath: versionBasePathUrl, + diagram: false, + maxLevels: 5, + }); + + /** + * Manual rewriting OpenAPI specification to delete OData default functionality + * that is not implemented in Pinejs yet or is based on PineJs implements OData V3. + * + * 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 + * + * + * Currently pinejs does not implement a $count=true query parameter as this would return the count of all rows returned as an additional parameter. + * This was not part of OData V3 and is new for OData V4. As the odata-openapi converte is opionionated on V4 the parameter is put into the schema. + * Until this is in parity with OData V4 pinejs needs to cleanup the `odata.count` key from the response schema put in by `csdl2openapi` + * + * + * Used oasis translator generates openapi according to v4 spec (`value: `) + */ + + Object.keys(openAPIJson.paths).forEach((i) => { + // rewrite `value: ` to `d: ` + 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; + } + + // cleanup the `odata.count` key from the response schema + if ( + openAPIJson?.paths[i]?.get?.responses?.['200']?.content?.[ + 'application/json' + ]?.schema?.properties?.['@odata.count'] + ) { + delete openAPIJson.paths[i].get.responses['200'].content[ + 'application/json' + ].schema.properties['@odata.count']; + } + }); + + // cleanup $batch path as pinejs does not implement it. + // http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-protocol.html#sec_BatchRequests + if (openAPIJson?.paths['/$batch']) { + delete openAPIJson.paths['/$batch']; + } + + return openAPIJson; +}; diff --git a/src/sbvr-api/permissions.ts b/src/sbvr-api/permissions.ts index e3698fc71..8cee0e0c0 100644 --- a/src/sbvr-api/permissions.ts +++ b/src/sbvr-api/permissions.ts @@ -312,7 +312,7 @@ const namespaceRelationships = ( }); }; -type PermissionLookup = Dictionary; +export type PermissionLookup = Dictionary; const getPermissionsLookup = env.createCache( 'permissionsLookup', @@ -1603,7 +1603,7 @@ const getGuestPermissions = memoize( { promise: true }, ); -const getReqPermissions = async ( +export const getReqPermissions = async ( req: PermissionReq, odataBinds: ODataBinds = [] as any as ODataBinds, ) => { diff --git a/src/sbvr-api/sbvr-utils.ts b/src/sbvr-api/sbvr-utils.ts index 8886f03ae..37fbb24c6 100644 --- a/src/sbvr-api/sbvr-utils.ts +++ b/src/sbvr-api/sbvr-utils.ts @@ -36,7 +36,7 @@ import { ExtendedSBVRParser } from '../extended-sbvr-parser/extended-sbvr-parser import * as asyncMigrator from '../migrator/async'; import * as syncMigrator from '../migrator/sync'; -import { generateODataMetadata } from '../odata-metadata/odata-metadata-generator'; +import { generateODataMetadataAsOpenApi } from '../odata-metadata/open-api-sepcification-generator'; // tslint:disable-next-line:no-var-requires const devModel = require('./dev.sbvr'); @@ -94,6 +94,7 @@ export { resolveOdataBind } from './abstract-sql'; import * as odataResponse from './odata-response'; import { env } from '../server-glue/module'; import { translateAbstractSqlModel } from './translations'; +import { generateODataMetadata } from '../odata-metadata/odata-metadata-generator'; const LF2AbstractSQLTranslator = LF2AbstractSQL.createTranslator(sbvrTypes); const LF2AbstractSQLTranslatorVersion = `${LF2AbstractSQLVersion}+${sbvrTypesVersion}`; @@ -1714,10 +1715,35 @@ const respondGet = async ( return response; } else { if (request.resourceName === '$metadata') { + const permLookup = await permissions.getReqPermissions(req); + const spec = generateODataMetadata( + vocab, + models[vocab].abstractSql, + permLookup, + ); return { statusCode: 200, - body: models[vocab].odataMetadata, - headers: { 'content-type': 'xml' }, + body: spec, + headers: { 'content-type': 'application/json' }, + }; + } else if (request.resourceName === 'openapi.json') { + // https://docs.oasis-open.org/odata/odata-openapi/v1.0/cn01/odata-openapi-v1.0-cn01.html#sec_ProvidingOASDocumentsforanODataServi + // Following the OASIS OData to openapi translation guide the openapi.json is an independent resource + const permLookup = await permissions.getReqPermissions(req); + const spec = generateODataMetadata( + vocab, + models[vocab].abstractSql, + permLookup, + ); + const openApispec = generateODataMetadataAsOpenApi( + spec, + req.originalUrl.replace('openapi.json', ''), + req.hostname, + ); + return { + statusCode: 200, + body: openApispec, + 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 @@ -1728,6 +1754,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/src/sbvr-api/uri-parser.ts b/src/sbvr-api/uri-parser.ts index 28ed74988..ff0772a4f 100644 --- a/src/sbvr-api/uri-parser.ts +++ b/src/sbvr-api/uri-parser.ts @@ -259,7 +259,7 @@ const memoizedOdata2AbstractSQL = (() => { }; })(); -export const metadataEndpoints = ['$metadata', '$serviceroot']; +export const metadataEndpoints = ['$metadata', '$serviceroot', 'openapi.json']; export async function parseOData( b: UnparsedRequest & { _isChangeSet?: false }, diff --git a/test/05-metadata.test.ts b/test/05-metadata.test.ts new file mode 100644 index 000000000..0134742e2 --- /dev/null +++ b/test/05-metadata.test.ts @@ -0,0 +1,50 @@ +import * as fs from 'fs'; +import { expect } from 'chai'; +import * as supertest from 'supertest'; +import { testInit, testDeInit, testLocalServer } from './lib/test-init'; + +describe('05 metadata', function () { + describe('Full model access specification', async function () { + const fixturePath = __dirname + '/fixtures/05-metadata/config-full-access'; + let pineServer: Awaited>; + before(async () => { + pineServer = await testInit({ + configPath: fixturePath, + deleteDb: true, + }); + }); + + after(async () => { + await testDeInit(pineServer); + }); + + it('should send OData CSDL JSON on /$metadata', async () => { + const res = await supertest(testLocalServer) + .get('/example/$metadata') + .expect(200); + expect(res.body).to.be.an('object'); + }); + + it('should send OpenAPI spec JSON on /$metadata', async () => { + const res = await supertest(testLocalServer) + .get('/example/openapi.json') + .expect(200); + expect(res.body).to.be.an('object'); + }); + + it('OpenAPI spec should contain all paths and actions on resources', async () => { + // full CRUD access for device resource + const res = await supertest(testLocalServer) + .get('/example/openapi.json') + .expect(200); + expect(res.body).to.be.an('object'); + + fs.writeFileSync('./openapiSpec.json', JSON.stringify(res.body, null, 2)); + + for (const value of Object.values(res.body.paths)) { + console.log(`value:${JSON.stringify(value, null, 2)}`); + expect(value).to.have.keys(['get', 'patch', 'delete', 'post']); + } + }); + }); +}); diff --git a/test/fixtures/04-metadata/config-full-access.ts b/test/fixtures/04-metadata/config-full-access.ts new file mode 100644 index 000000000..a68c04631 --- /dev/null +++ b/test/fixtures/04-metadata/config-full-access.ts @@ -0,0 +1,18 @@ +import type { ConfigLoader } from '../../../src/server-glue/module'; + +export default { + models: [ + { + apiRoot: 'example', + modelFile: __dirname + '/example.sbvr', + modelName: 'example', + }, + ], + users: [ + { + username: 'guest', + password: ' ', + permissions: ['resource.all'], + }, + ], +} as ConfigLoader.Config; diff --git a/test/fixtures/04-metadata/config-restricted-access.ts b/test/fixtures/04-metadata/config-restricted-access.ts new file mode 100644 index 000000000..0fcb74110 --- /dev/null +++ b/test/fixtures/04-metadata/config-restricted-access.ts @@ -0,0 +1,25 @@ +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', + 'example.gateway__connects__device.all', + ], + }, + ], +} as ConfigLoader.Config; diff --git a/test/fixtures/04-metadata/example.sbvr b/test/fixtures/04-metadata/example.sbvr new file mode 100644 index 000000000..581f36613 --- /dev/null +++ b/test/fixtures/04-metadata/example.sbvr @@ -0,0 +1,33 @@ +Vocabulary: example + +Term: name + Concept Type: Short Text (Type) +Term: note + Concept Type: Text (Type) +Term: type + Concept Type: Short Text (Type) + + +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. + + +Term: device + +Fact Type: device has name + Necessity: each device has at most one name. +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 + + +Term: gateway + +Fact Type: gateway has name + Necessity: each gateway has exactly one name. +Fact Type: gateway connects device 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; +}