diff --git a/package.json b/package.json index 627563270..82d4b57fe 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "json-schema-to-ts": "3.1.1", "lodash": "^4.17.21", "memoizee": "^0.4.17", + "odata-openapi": "^0.28.2", "pinejs-client-core": "^8.1.1", "randomstring": "^1.3.0", "typed-error": "^3.2.2" diff --git a/src/odata-metadata/odata-metadata-generator.ts b/src/odata-metadata/odata-metadata-generator.ts index 5a809a985..f83a957a8 100644 --- a/src/odata-metadata/odata-metadata-generator.ts +++ b/src/odata-metadata/odata-metadata-generator.ts @@ -3,11 +3,211 @@ import type { AbstractSqlTable, } from '@balena/abstract-sql-compiler'; -import sbvrTypes, { type SbvrType } from '@balena/sbvr-types'; +import type { SbvrType } from '@balena/sbvr-types'; +import sbvrTypes from '@balena/sbvr-types'; +import type { PermissionLookup } from '../sbvr-api/permissions'; // eslint-disable-next-line @typescript-eslint/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 } + | { Filterable: boolean }; + +const restrictionsLookup = ( + method: keyof PreparedPermissionsLookup[string][string] | 'all', + value: boolean, +) => { + const lookup = { + update: { + '@Capabilities.UpdateRestrictions': { + Updatable: value, + }, + '@Capabilities.FilterRestrictions': { + Filterable: true, + }, + }, + delete: { + '@Capabilities.DeleteRestrictions': { + Deletable: value, + }, + '@Capabilities.FilterRestrictions': { + Filterable: true, + }, + }, + create: { + '@Capabilities.InsertRestrictions': { + Insertable: value, + }, + }, + read: { + '@Capabilities.ReadRestrictions': { + Readable: value, + }, + '@Capabilities.FilterRestrictions': { + Filterable: true, + }, + }, + }; + + 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 +215,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 +242,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 +299,109 @@ 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 }) => { + 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 }) => { 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: '*' }, - ], - }); - } + // 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], + }; + + 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) }; } + + entityContainer[resourceName] = entityCon; }); - 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') + - ` - - - ` - ); + 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..ddd6ef8ef --- /dev/null +++ b/src/odata-metadata/open-api-sepcification-generator.ts @@ -0,0 +1,99 @@ +import * as odataMetadata from 'odata-openapi'; +import type { generateODataMetadata } from './odata-metadata-generator'; +import _ = require('lodash'); +// tslint:disable-next-line:no-var-requires + +export const generateODataMetadataAsOpenApi = ( + odataCsdl: ReturnType, + versionBasePathUrl = '', + hostname = '', +) => { + // 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: `) + * + * Unfortunantely odata-openapi does not export the genericFilter object. + * Using hardcoded generic filter description as used in odata-openapi code. + * Putting the genericFilter into the #/components/parameters/filter to reference it from paths + * + * */ + const parameters = openAPIJson?.components?.parameters; + parameters['filter'] = { + name: '$filter', + description: + 'Filter items by property values, see [Filtering](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-protocol.html#sec_SystemQueryOptionfilter)', + in: 'query', + schema: { + type: 'string', + }, + }; + + for (const idx of Object.keys(openAPIJson.paths)) { + // rewrite `value: ` to `d: ` + const properties = + openAPIJson?.paths[idx]?.get?.responses?.['200']?.content?.[ + 'application/json' + ]?.schema?.properties; + if (properties?.value) { + properties['d'] = properties.value; + delete properties.value; + } + + // cleanup the `odata.count` key from the response schema + if (properties?.['@odata.count']) { + delete properties['@odata.count']; + } + + // copy over 'delete' and 'patch' action from single entiy path + // odata-openAPI converter does not support collection delete and collection update. + // pinejs support collection delete and update with $filter parameter + const entityCollectionPath = openAPIJson?.paths[idx]; + const singleEntityPath = openAPIJson?.paths[idx + '({id})']; + if (entityCollectionPath != null && singleEntityPath != null) { + const genericFilterParameterRef = { + $ref: '#/components/parameters/filter', + }; + for (const action of ['delete', 'patch']) { + entityCollectionPath[action] = _.clone(singleEntityPath?.[action]); + if (entityCollectionPath[action]) { + entityCollectionPath[action]['parameters'] = [ + genericFilterParameterRef, + ]; + } + } + } + } + + // 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 82c92537c..51ecfedac 100644 --- a/src/sbvr-api/permissions.ts +++ b/src/sbvr-api/permissions.ts @@ -326,7 +326,7 @@ const namespaceRelationships = ( }); }; -type PermissionLookup = Dictionary; +export type PermissionLookup = Dictionary; const getPermissionsLookup = env.createCache( 'permissionsLookup', @@ -1705,7 +1705,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 d882eeab6..9a57c1f5f 100644 --- a/src/sbvr-api/sbvr-utils.ts +++ b/src/sbvr-api/sbvr-utils.ts @@ -43,7 +43,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'; import type DevModel from './dev'; // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -105,6 +105,8 @@ import { type MigrationExecutionResult, setExecutedMigrations, } from '../migrator/utils'; +import { generateODataMetadata } from '../odata-metadata/odata-metadata-generator'; +import { metadataEndpoints } from './uri-parser'; const LF2AbstractSQLTranslator = LF2AbstractSQL.createTranslator(sbvrTypes); const LF2AbstractSQLTranslatorVersion = `${LF2AbstractSQLVersion}+${sbvrTypesVersion}`; @@ -1375,7 +1377,10 @@ const runODataRequest = (req: Express.Request, vocabulary: string) => { -'#canAccess'.length, ); } - if (abstractSqlModel.tables[resolvedResourceName] == null) { + if ( + abstractSqlModel.tables[resolvedResourceName] == null && + !metadataEndpoints.includes(resolvedResourceName) + ) { throw new UnauthorizedError(); } @@ -1878,10 +1883,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: 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: models[vocab].odataMetadata, - headers: { 'content-type': 'xml' }, + 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 diff --git a/src/sbvr-api/uri-parser.ts b/src/sbvr-api/uri-parser.ts index fae088e51..c0c0e44ac 100644 --- a/src/sbvr-api/uri-parser.ts +++ b/src/sbvr-api/uri-parser.ts @@ -260,7 +260,7 @@ const memoizedOdata2AbstractSQL = (() => { }; })(); -export const metadataEndpoints = ['$metadata', '$serviceroot']; +export const metadataEndpoints = ['$metadata', '$serviceroot', 'openapi.json']; export function parseOData( b: UnparsedRequest & { _isChangeSet?: false }, diff --git a/test/09-metadata.test.ts b/test/09-metadata.test.ts new file mode 100644 index 000000000..2a1344688 --- /dev/null +++ b/test/09-metadata.test.ts @@ -0,0 +1,65 @@ +import { writeFileSync } from 'fs'; +import { expect } from 'chai'; +import supertest from 'supertest'; +import { testInit, testDeInit, testLocalServer } from './lib/test-init'; + +import OpenAPIParser from '@readme/openapi-parser'; + +describe('08 metadata / openAPI spec', function () { + describe('Full model access specification', async function () { + const fixturePath = + __dirname + '/fixtures/09-metadata/config-full-access.js'; + 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 valid OpenAPI spec JSON on /$metadata', async () => { + const { body } = await supertest(testLocalServer) + .get('/example/openapi.json') + .expect(200); + expect(body).to.be.an('object'); + + const bodySpec = JSON.stringify(body, null, 2); + await writeFileSync('openApiSpe-full.json', bodySpec); + + // validate the openAPI spec and expect no validator errors. + try { + const apiSpec = await OpenAPIParser.validate(JSON.parse(bodySpec)); + expect(apiSpec).to.be.an('object'); + } catch (err) { + expect(err).to.be.undefined; + } + }); + + 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'); + + // all collections should have get, patch, delete and post + const singleIdPathRegEx = /\({id}\)/; + for (const [path, value] of Object.entries(res.body.paths)) { + if (!singleIdPathRegEx.exec(path)) { + expect(value).to.have.keys(['get', 'patch', 'delete', 'post']); + } + } + }); + }); +}); diff --git a/test/fixtures/09-metadata/config-full-access.ts b/test/fixtures/09-metadata/config-full-access.ts new file mode 100644 index 000000000..a68c04631 --- /dev/null +++ b/test/fixtures/09-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/09-metadata/config-restricted-access.ts b/test/fixtures/09-metadata/config-restricted-access.ts new file mode 100644 index 000000000..0fcb74110 --- /dev/null +++ b/test/fixtures/09-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/09-metadata/example.sbvr b/test/fixtures/09-metadata/example.sbvr new file mode 100644 index 000000000..581f36613 --- /dev/null +++ b/test/fixtures/09-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; +}