@balena/pinejs 21.0.5-build-renovate-serve-static-2-x-d57cb45f323dd0921480deb4c400b718bb24a1d2-1 → 21.1.0-build-odata-metadata-json-395a55cb54e7b9ce0960ab93aad5f69d6c0e0462-2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,6 +6,199 @@ import type {
6
6
  import type { SbvrType } from '@balena/sbvr-types';
7
7
  import { sbvrTypes } from '../sbvr-api/sbvr-utils.js';
8
8
  import { version } from '../config-loader/env.js';
9
+ import type { PermissionLookup } from '../sbvr-api/permissions.js';
10
+
11
+ // OData JSON v4 CSDL Vocabulary constants
12
+ // http://docs.oasis-open.org/odata/odata-vocabularies/v4.0/odata-vocabularies-v4.0.html
13
+ const odataVocabularyReferences: ODataCsdlV4References = {
14
+ 'https://oasis-tcs.github.io/odata-vocabularies/vocabularies/Org.OData.Core.V1.json':
15
+ {
16
+ $Include: [
17
+ {
18
+ $Namespace: 'Org.OData.Core.V1',
19
+ $Alias: 'Core',
20
+ '@Core.DefaultNamespace': true,
21
+ },
22
+ ],
23
+ },
24
+ 'https://oasis-tcs.github.io/odata-vocabularies/vocabularies/Org.OData.Measures.V1.json':
25
+ {
26
+ $Include: [
27
+ {
28
+ $Namespace: 'Org.OData.Measures.V1',
29
+ $Alias: 'Measures',
30
+ },
31
+ ],
32
+ },
33
+ 'https://oasis-tcs.github.io/odata-vocabularies/vocabularies/Org.OData.Aggregation.V1.json':
34
+ {
35
+ $Include: [
36
+ {
37
+ $Namespace: 'Org.OData.Aggregation.V1',
38
+ $Alias: 'Aggregation',
39
+ },
40
+ ],
41
+ },
42
+ 'https://oasis-tcs.github.io/odata-vocabularies/vocabularies/Org.OData.Capabilities.V1.json':
43
+ {
44
+ $Include: [
45
+ {
46
+ $Namespace: 'Org.OData.Capabilities.V1',
47
+ $Alias: 'Capabilities',
48
+ },
49
+ ],
50
+ },
51
+ };
52
+
53
+ /**
54
+ * Odata Common Schema Definition Language JSON format
55
+ * http://docs.oasis-open.org/odata/odata-json-format/v4.0/odata-json-format-v4.0.html
56
+ */
57
+
58
+ type ODataCsdlV4References = {
59
+ [URI: string]: {
60
+ $Include: Array<{
61
+ $Namespace: string;
62
+ $Alias: string;
63
+ [annotation: string]: string | boolean;
64
+ }>;
65
+ };
66
+ };
67
+
68
+ type ODataCsdlV4BaseProperty = {
69
+ [annotation: string]: string | boolean | undefined;
70
+ $Type?: string;
71
+ $Nullable?: boolean;
72
+ };
73
+
74
+ type ODataCsdlV4StructuralProperty = ODataCsdlV4BaseProperty & {
75
+ $Kind?: 'Property'; // This member SHOULD be omitted to reduce document size.
76
+ };
77
+
78
+ type ODataCsdlV4NavigationProperty = ODataCsdlV4BaseProperty & {
79
+ $Kind: 'NavigationProperty';
80
+ $Partner?: string;
81
+ };
82
+
83
+ type ODataCsdlV4Property =
84
+ | ODataCsdlV4BaseProperty
85
+ | ODataCsdlV4StructuralProperty
86
+ | ODataCsdlV4NavigationProperty;
87
+
88
+ type ODataCsdlV4EntityType = {
89
+ $Kind: 'EntityType';
90
+ $Key: string[];
91
+ [property: string]: true | string[] | string | ODataCsdlV4Property;
92
+ };
93
+
94
+ type ODataCsdlV4EntityContainerEntries = {
95
+ // $Collection: true;
96
+ $Type: string;
97
+ [property: string]: true | string | ODataCapabilitiesUDIRRestrictionsMethod;
98
+ };
99
+
100
+ type ODataCsdlV4Entities = {
101
+ [resource: string]: ODataCsdlV4EntityType;
102
+ };
103
+
104
+ type ODataCsdlV4EntityContainer = {
105
+ $Kind: 'EntityContainer';
106
+ '@Capabilities.BatchSupported'?: boolean;
107
+ [resourceOrAnnotation: string]:
108
+ | boolean
109
+ | string
110
+ | ODataCsdlV4EntityContainerEntries
111
+ | undefined;
112
+ };
113
+
114
+ type ODataCsdlV4Schema = {
115
+ $Alias: string;
116
+ '@Core.DefaultNamespace': true;
117
+ [resource: string]:
118
+ | string
119
+ | boolean
120
+ | ODataCsdlV4EntityContainer
121
+ | ODataCsdlV4EntityType;
122
+ };
123
+
124
+ type OdataCsdlV4 = {
125
+ $Version: string;
126
+ $Reference: ODataCsdlV4References;
127
+ $EntityContainer: string;
128
+ [schema: string]: string | ODataCsdlV4References | ODataCsdlV4Schema;
129
+ };
130
+
131
+ type PreparedPermissionsLookup = {
132
+ [vocabulary: string]: {
133
+ [resource: string]: {
134
+ read: boolean;
135
+ create: boolean;
136
+ update: boolean;
137
+ delete: boolean;
138
+ };
139
+ };
140
+ };
141
+
142
+ type PreparedAbstractModel = {
143
+ vocabulary: string;
144
+ abstractSqlModel: AbstractSqlModel;
145
+ preparedPermissionLookup: PreparedPermissionsLookup;
146
+ };
147
+
148
+ type ODataCapabilitiesUDIRRestrictionsMethod =
149
+ | { Updatable: boolean }
150
+ | { Deletable: boolean }
151
+ | { Insertable: boolean }
152
+ | { Readable: boolean }
153
+ | { Filterable: boolean };
154
+
155
+ const restrictionsLookup = (
156
+ method: keyof PreparedPermissionsLookup[string][string] | 'all',
157
+ value: boolean,
158
+ ) => {
159
+ const lookup = {
160
+ update: {
161
+ '@Capabilities.UpdateRestrictions': {
162
+ Updatable: value,
163
+ },
164
+ '@Capabilities.FilterRestrictions': {
165
+ Filterable: true,
166
+ },
167
+ },
168
+ delete: {
169
+ '@Capabilities.DeleteRestrictions': {
170
+ Deletable: value,
171
+ },
172
+ '@Capabilities.FilterRestrictions': {
173
+ Filterable: true,
174
+ },
175
+ },
176
+ create: {
177
+ '@Capabilities.InsertRestrictions': {
178
+ Insertable: value,
179
+ },
180
+ },
181
+ read: {
182
+ '@Capabilities.ReadRestrictions': {
183
+ Readable: value,
184
+ },
185
+ '@Capabilities.FilterRestrictions': {
186
+ Filterable: true,
187
+ },
188
+ },
189
+ };
190
+
191
+ if (method === 'all') {
192
+ return {
193
+ ...lookup['update'],
194
+ ...lookup['delete'],
195
+ ...lookup['create'],
196
+ ...lookup['read'],
197
+ };
198
+ } else {
199
+ return lookup[method] ?? {};
200
+ }
201
+ };
9
202
 
10
203
  const getResourceName = (resourceName: string): string =>
11
204
  resourceName
@@ -14,17 +207,25 @@ const getResourceName = (resourceName: string): string =>
14
207
  .join('__');
15
208
 
16
209
  const forEachUniqueTable = <T>(
17
- model: AbstractSqlModel['tables'],
18
- callback: (tableName: string, table: AbstractSqlTable) => T,
210
+ model: PreparedAbstractModel,
211
+ callback: (
212
+ tableName: string,
213
+ table: AbstractSqlTable & { referenceScheme: string },
214
+ ) => T,
19
215
  ): T[] => {
20
216
  const usedTableNames: { [tableName: string]: true } = {};
21
217
 
22
218
  const result = [];
23
- for (const [key, table] of Object.entries(model)) {
219
+
220
+ for (const key of Object.keys(model.abstractSqlModel.tables).sort()) {
221
+ const table = model.abstractSqlModel.tables[key] as AbstractSqlTable & {
222
+ referenceScheme: string;
223
+ };
24
224
  if (
25
225
  typeof table !== 'string' &&
26
226
  !table.primitive &&
27
- !usedTableNames[table.name]
227
+ !usedTableNames[table.name] &&
228
+ model.preparedPermissionLookup
28
229
  ) {
29
230
  usedTableNames[table.name] = true;
30
231
  result.push(callback(key, table));
@@ -33,9 +234,49 @@ const forEachUniqueTable = <T>(
33
234
  return result;
34
235
  };
35
236
 
237
+ /**
238
+ * parsing dictionary of vocabulary.resource.operation permissions string
239
+ * into dictionary of resource to operation for later lookup
240
+ */
241
+
242
+ const preparePermissionsLookup = (
243
+ permissionLookup: PermissionLookup,
244
+ ): PreparedPermissionsLookup => {
245
+ const resourcesAndOps: PreparedPermissionsLookup = {};
246
+
247
+ for (const resourceOpsAuths of Object.keys(permissionLookup)) {
248
+ const [vocabulary, resource, rule] = resourceOpsAuths.split('.');
249
+ resourcesAndOps[vocabulary] ??= {};
250
+ resourcesAndOps[vocabulary][resource] ??= {
251
+ ['read']: false,
252
+ ['create']: false,
253
+ ['update']: false,
254
+ ['delete']: false,
255
+ };
256
+
257
+ if (rule === 'all' || (resource === 'all' && rule === undefined)) {
258
+ resourcesAndOps[vocabulary][resource] = {
259
+ ['read']: true,
260
+ ['create']: true,
261
+ ['update']: true,
262
+ ['delete']: true,
263
+ };
264
+ } else if (
265
+ rule === 'read' ||
266
+ rule === 'create' ||
267
+ rule === 'update' ||
268
+ rule === 'delete'
269
+ ) {
270
+ resourcesAndOps[vocabulary][resource][rule] = true;
271
+ }
272
+ }
273
+ return resourcesAndOps;
274
+ };
275
+
36
276
  export const generateODataMetadata = (
37
277
  vocabulary: string,
38
278
  abstractSqlModel: AbstractSqlModel,
279
+ permissionsLookup?: PermissionLookup,
39
280
  ) => {
40
281
  const complexTypes: { [fieldType: string]: string } = {};
41
282
  const resolveDataType = (fieldType: keyof typeof sbvrTypes): string => {
@@ -50,132 +291,109 @@ export const generateODataMetadata = (
50
291
  return sbvrTypes[fieldType].types.odata.name;
51
292
  };
52
293
 
53
- const model = abstractSqlModel.tables;
54
- const associations: Array<{
55
- name: string;
56
- ends: Array<{
57
- resourceName: string;
58
- cardinality: '1' | '0..1' | '*';
59
- }>;
60
- }> = [];
61
- forEachUniqueTable(model, (_key, { name: resourceName, fields }) => {
294
+ const prepPermissionsLookup = permissionsLookup
295
+ ? preparePermissionsLookup(permissionsLookup)
296
+ : {};
297
+
298
+ const model: PreparedAbstractModel = {
299
+ vocabulary,
300
+ abstractSqlModel,
301
+ preparedPermissionLookup: prepPermissionsLookup,
302
+ };
303
+
304
+ const metaBalenaEntries: ODataCsdlV4Entities = {};
305
+ const entityContainer: ODataCsdlV4EntityContainer = {
306
+ $Kind: 'EntityContainer',
307
+ '@Capabilities.KeyAsSegmentSupported': false,
308
+ };
309
+
310
+ forEachUniqueTable(model, (_key, { idField, name: resourceName, fields }) => {
62
311
  resourceName = getResourceName(resourceName);
63
- for (const { dataType, required, references } of fields) {
64
- if (dataType === 'ForeignKey' && references != null) {
65
- const { resourceName: referencedResource } = references;
66
- associations.push({
67
- name: resourceName + referencedResource,
68
- ends: [
69
- { resourceName, cardinality: required ? '1' : '0..1' },
70
- { resourceName: referencedResource, cardinality: '*' },
71
- ],
72
- });
73
- }
312
+ // no path nor entity when permissions not contain resource
313
+ const permissions: PreparedPermissionsLookup[string][string] =
314
+ model?.preparedPermissionLookup?.['resource']?.['all'] ??
315
+ model?.preparedPermissionLookup?.[model.vocabulary]?.['all'] ??
316
+ model?.preparedPermissionLookup?.[model.vocabulary]?.[resourceName];
317
+
318
+ if (!permissions) {
319
+ return;
320
+ }
321
+
322
+ const uniqueTable: ODataCsdlV4EntityType = {
323
+ $Kind: 'EntityType',
324
+ $Key: [idField],
325
+ };
326
+
327
+ fields
328
+ .filter(({ dataType }) => dataType !== 'ForeignKey')
329
+ .map(({ dataType, fieldName, required }) => {
330
+ dataType = resolveDataType(dataType as keyof typeof sbvrTypes);
331
+ fieldName = getResourceName(fieldName);
332
+
333
+ uniqueTable[fieldName] = {
334
+ $Type: dataType,
335
+ $Nullable: !required,
336
+ '@Core.Computed':
337
+ fieldName === 'created_at' || fieldName === 'modified_at'
338
+ ? true
339
+ : false,
340
+ };
341
+ });
342
+
343
+ fields
344
+ .filter(
345
+ ({ dataType, references }) =>
346
+ dataType === 'ForeignKey' && references != null,
347
+ )
348
+ .map(({ fieldName, references, required }) => {
349
+ const { resourceName: referencedResource } = references!;
350
+ const referencedResourceName =
351
+ model.abstractSqlModel.tables[referencedResource]?.name;
352
+ const typeReference = referencedResourceName || referencedResource;
353
+
354
+ fieldName = getResourceName(fieldName);
355
+ uniqueTable[fieldName] = {
356
+ $Kind: 'NavigationProperty',
357
+ $Partner: resourceName,
358
+ $Nullable: !required,
359
+ $Type: vocabulary + '.' + getResourceName(typeReference),
360
+ };
361
+ });
362
+
363
+ metaBalenaEntries[resourceName] = uniqueTable;
364
+
365
+ let entityCon: ODataCsdlV4EntityContainerEntries = {
366
+ $Collection: true,
367
+ $Type: vocabulary + '.' + resourceName,
368
+ };
369
+ for (const [resKey, resValue] of Object.entries(permissions) as Array<
370
+ [keyof PreparedPermissionsLookup[string][string], boolean]
371
+ >) {
372
+ entityCon = { ...entityCon, ...restrictionsLookup(resKey, resValue) };
74
373
  }
374
+
375
+ entityContainer[resourceName] = entityCon;
75
376
  });
76
377
 
77
- return (
78
- `
79
- <?xml version="1.0" encoding="iso-8859-1" standalone="yes"?>
80
- <edmx:Edmx Version="1.0" xmlns:edmx="http://schemas.microsoft.com/ado/2007/06/edmx">
81
- <edmx:DataServices xmlns:m="http://schemas.microsoft.com/ado/2007/08/dataservices/metadata" m:DataServiceVersion="2.0">
82
- <Schema Namespace="${vocabulary}"
83
- xmlns:d="http://schemas.microsoft.com/ado/2007/08/dataservices"
84
- xmlns:m="http://schemas.microsoft.com/ado/2007/08/dataservices/metadata"
85
- xmlns="http://schemas.microsoft.com/ado/2008/09/edm">
86
-
87
- ` +
88
- forEachUniqueTable(
89
- model,
90
- (_key, { idField, name: resourceName, fields }) => {
91
- resourceName = getResourceName(resourceName);
92
- return (
93
- `
94
- <EntityType Name="${resourceName}">
95
- <Key>
96
- <PropertyRef Name="${idField}" />
97
- </Key>
98
-
99
- ` +
100
- fields
101
- .filter(({ dataType }) => dataType !== 'ForeignKey')
102
- .map(({ dataType, fieldName, required }) => {
103
- dataType = resolveDataType(dataType as keyof typeof sbvrTypes);
104
- fieldName = getResourceName(fieldName);
105
- return `<Property Name="${fieldName}" Type="${dataType}" Nullable="${!required}" />`;
106
- })
107
- .join('\n') +
108
- '\n' +
109
- fields
110
- .filter(
111
- ({ dataType, references }) =>
112
- dataType === 'ForeignKey' && references != null,
113
- )
114
- .map(({ fieldName, references }) => {
115
- const { resourceName: referencedResource } = references!;
116
- fieldName = getResourceName(fieldName);
117
- return `<NavigationProperty Name="${fieldName}" Relationship="${vocabulary}.${
118
- resourceName + referencedResource
119
- }" FromRole="${resourceName}" ToRole="${referencedResource}" />`;
120
- })
121
- .join('\n') +
122
- '\n' +
123
- `
124
- </EntityType>`
125
- );
126
- },
127
- ).join('\n\n') +
128
- associations
129
- .map(({ name, ends }) => {
130
- name = getResourceName(name);
131
- return (
132
- `<Association Name="${name}">` +
133
- '\n\t' +
134
- ends
135
- .map(
136
- ({ resourceName, cardinality }) =>
137
- `<End Role="${resourceName}" Type="${vocabulary}.${resourceName}" Multiplicity="${cardinality}" />`,
138
- )
139
- .join('\n\t') +
140
- '\n' +
141
- `</Association>`
142
- );
143
- })
144
- .join('\n') +
145
- `
146
- <EntityContainer Name="${vocabulary}Service" m:IsDefaultEntityContainer="true">
147
-
148
- ` +
149
- forEachUniqueTable(model, (_key, { name: resourceName }) => {
150
- resourceName = getResourceName(resourceName);
151
- return `<EntitySet Name="${resourceName}" EntityType="${vocabulary}.${resourceName}" />`;
152
- }).join('\n') +
153
- '\n' +
154
- associations
155
- .map(({ name, ends }) => {
156
- name = getResourceName(name);
157
- return (
158
- `<AssociationSet Name="${name}" Association="${vocabulary}.${name}">` +
159
- '\n\t' +
160
- ends
161
- .map(
162
- ({ resourceName }) =>
163
- `<End Role="${resourceName}" EntitySet="${vocabulary}.${resourceName}" />`,
164
- )
165
- .join('\n\t') +
166
- `
167
- </AssociationSet>`
168
- );
169
- })
170
- .join('\n') +
171
- `
172
- </EntityContainer>` +
173
- Object.values(complexTypes).join('\n') +
174
- `
175
- </Schema>
176
- </edmx:DataServices>
177
- </edmx:Edmx>`
178
- );
378
+ const odataCsdl: OdataCsdlV4 = {
379
+ // 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
380
+ $Version: '3.0',
381
+ $EntityContainer: vocabulary + '.ODataApi',
382
+ $Reference: odataVocabularyReferences,
383
+ [vocabulary]: {
384
+ // schema
385
+ $Alias: vocabulary,
386
+ '@Core.DefaultNamespace': true,
387
+ '@Core.Description': `OpenAPI specification for PineJS served SBVR datamodel: ${vocabulary}`,
388
+ '@Core.LongDescription':
389
+ 'Auto-Genrated OpenAPI specification by utilizing OData CSDL to OpenAPI spec transformer.',
390
+ '@Core.SchemaVersion': version,
391
+ ...metaBalenaEntries,
392
+ ['ODataApi']: entityContainer,
393
+ },
394
+ };
395
+
396
+ return odataCsdl;
179
397
  };
180
398
 
181
399
  generateODataMetadata.version = version;
@@ -0,0 +1,98 @@
1
+ import * as odataMetadata from 'odata-openapi';
2
+ import type { generateODataMetadata } from './odata-metadata-generator.js';
3
+ import _ from 'lodash';
4
+
5
+ export const generateODataMetadataAsOpenApi = (
6
+ odataCsdl: ReturnType<typeof generateODataMetadata>,
7
+ versionBasePathUrl = '',
8
+ hostname = '',
9
+ ) => {
10
+ // console.log(`odataCsdl:${JSON.stringify(odataCsdl, null, 2)}`);
11
+ const openAPIJson: any = odataMetadata.csdl2openapi(odataCsdl, {
12
+ scheme: 'https',
13
+ host: hostname,
14
+ basePath: versionBasePathUrl,
15
+ diagram: false,
16
+ maxLevels: 5,
17
+ });
18
+
19
+ /**
20
+ * Manual rewriting OpenAPI specification to delete OData default functionality
21
+ * that is not implemented in Pinejs yet or is based on PineJs implements OData V3.
22
+ *
23
+ * Rewrite odata body response schema properties from `value: ` to `d: `
24
+ * Currently pinejs is returning `d: `
25
+ * https://www.odata.org/documentation/odata-version-2-0/json-format/ (6. Representing Collections of Entries)
26
+ * https://www.odata.org/documentation/odata-version-3-0/json-verbose-format/ (6.1 Response body)
27
+ *
28
+ * New v4 odata specifies the body response with `value: `
29
+ * http://docs.oasis-open.org/odata/odata-json-format/v4.01/odata-json-format-v4.01.html#sec_IndividualPropertyorOperationRespons
30
+ *
31
+ *
32
+ * Currently pinejs does not implement a $count=true query parameter as this would return the count of all rows returned as an additional parameter.
33
+ * 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.
34
+ * Until this is in parity with OData V4 pinejs needs to cleanup the `odata.count` key from the response schema put in by `csdl2openapi`
35
+ *
36
+ *
37
+ * Used oasis translator generates openapi according to v4 spec (`value: `)
38
+ *
39
+ * Unfortunantely odata-openapi does not export the genericFilter object.
40
+ * Using hardcoded generic filter description as used in odata-openapi code.
41
+ * Putting the genericFilter into the #/components/parameters/filter to reference it from paths
42
+ *
43
+ * */
44
+ const parameters = openAPIJson?.components?.parameters;
45
+ parameters['filter'] = {
46
+ name: '$filter',
47
+ description:
48
+ 'Filter items by property values, see [Filtering](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-protocol.html#sec_SystemQueryOptionfilter)',
49
+ in: 'query',
50
+ schema: {
51
+ type: 'string',
52
+ },
53
+ };
54
+
55
+ for (const idx of Object.keys(openAPIJson.paths)) {
56
+ // rewrite `value: ` to `d: `
57
+ const properties =
58
+ openAPIJson?.paths[idx]?.get?.responses?.['200']?.content?.[
59
+ 'application/json'
60
+ ]?.schema?.properties;
61
+ if (properties?.value) {
62
+ properties['d'] = properties.value;
63
+ delete properties.value;
64
+ }
65
+
66
+ // cleanup the `odata.count` key from the response schema
67
+ if (properties?.['@odata.count']) {
68
+ delete properties['@odata.count'];
69
+ }
70
+
71
+ // copy over 'delete' and 'patch' action from single entiy path
72
+ // odata-openAPI converter does not support collection delete and collection update.
73
+ // pinejs support collection delete and update with $filter parameter
74
+ const entityCollectionPath = openAPIJson?.paths[idx];
75
+ const singleEntityPath = openAPIJson?.paths[idx + '({id})'];
76
+ if (entityCollectionPath != null && singleEntityPath != null) {
77
+ const genericFilterParameterRef = {
78
+ $ref: '#/components/parameters/filter',
79
+ };
80
+ for (const action of ['delete', 'patch']) {
81
+ entityCollectionPath[action] = _.clone(singleEntityPath?.[action]);
82
+ if (entityCollectionPath[action]) {
83
+ entityCollectionPath[action]['parameters'] = [
84
+ genericFilterParameterRef,
85
+ ];
86
+ }
87
+ }
88
+ }
89
+ }
90
+
91
+ // cleanup $batch path as pinejs does not implement it.
92
+ // http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-protocol.html#sec_BatchRequests
93
+ if (openAPIJson?.paths['/$batch']) {
94
+ delete openAPIJson.paths['/$batch'];
95
+ }
96
+
97
+ return openAPIJson;
98
+ };
@@ -328,7 +328,7 @@ const namespaceRelationships = (
328
328
  }
329
329
  };
330
330
 
331
- type PermissionLookup = Dictionary<true | string[]>;
331
+ export type PermissionLookup = Dictionary<true | string[]>;
332
332
 
333
333
  const getPermissionsLookup = env.createCache(
334
334
  'permissionsLookup',
@@ -1707,7 +1707,7 @@ const getGuestPermissions = memoize(
1707
1707
  { promise: true },
1708
1708
  );
1709
1709
 
1710
- const getReqPermissions = async (
1710
+ export const getReqPermissions = async (
1711
1711
  req: PermissionReq,
1712
1712
  odataBinds: ODataBinds = [] as any as ODataBinds,
1713
1713
  ) => {
@@ -48,6 +48,7 @@ import * as asyncMigrator from '../migrator/async.js';
48
48
  import * as syncMigrator from '../migrator/sync.js';
49
49
  import { generateODataMetadata } from '../odata-metadata/odata-metadata-generator.js';
50
50
  import { importSBVR } from '../server-glue/sbvr-loader.js';
51
+ import { generateODataMetadataAsOpenApi } from '../odata-metadata/open-api-specification-generator.js';
51
52
 
52
53
  import type DevModel from './dev.js';
53
54
  const devModel = await importSBVR('./dev.sbvr', import.meta);
@@ -111,6 +112,7 @@ import {
111
112
  type MigrationExecutionResult,
112
113
  setExecutedMigrations,
113
114
  } from '../migrator/utils.js';
115
+ import { metadataEndpoints } from './uri-parser.js';
114
116
 
115
117
  const LF2AbstractSQLTranslator = LF2AbstractSQL.createTranslator(sbvrTypes);
116
118
  const LF2AbstractSQLTranslatorVersion = `${LF2AbstractSQLVersion}+${sbvrTypesVersion}`;
@@ -1335,7 +1337,10 @@ const runODataRequest = (req: Express.Request, vocabulary: string) => {
1335
1337
  })
1336
1338
  : resolveSynonym($request);
1337
1339
 
1338
- if (abstractSqlModel.tables[resolvedResourceName] == null) {
1340
+ if (
1341
+ abstractSqlModel.tables[resolvedResourceName] == null &&
1342
+ !metadataEndpoints.includes(resolvedResourceName)
1343
+ ) {
1339
1344
  throw new UnauthorizedError();
1340
1345
  }
1341
1346
 
@@ -1838,10 +1843,35 @@ const respondGet = async (
1838
1843
  return response;
1839
1844
  } else {
1840
1845
  if (request.resourceName === '$metadata') {
1846
+ const permLookup = await permissions.getReqPermissions(req);
1847
+ const spec = generateODataMetadata(
1848
+ vocab,
1849
+ models[vocab].abstractSql,
1850
+ permLookup,
1851
+ );
1852
+ return {
1853
+ statusCode: 200,
1854
+ body: spec,
1855
+ headers: { 'content-type': 'application/json' },
1856
+ };
1857
+ } else if (request.resourceName === 'openapi.json') {
1858
+ // https://docs.oasis-open.org/odata/odata-openapi/v1.0/cn01/odata-openapi-v1.0-cn01.html#sec_ProvidingOASDocumentsforanODataServi
1859
+ // Following the OASIS OData to openapi translation guide the openapi.json is an independent resource
1860
+ const permLookup = await permissions.getReqPermissions(req);
1861
+ const spec = generateODataMetadata(
1862
+ vocab,
1863
+ models[vocab].abstractSql,
1864
+ permLookup,
1865
+ );
1866
+ const openApispec = generateODataMetadataAsOpenApi(
1867
+ spec,
1868
+ req.originalUrl.replace('openapi.json', ''),
1869
+ req.hostname,
1870
+ );
1841
1871
  return {
1842
1872
  statusCode: 200,
1843
- body: models[vocab].odataMetadata,
1844
- headers: { 'content-type': 'xml' },
1873
+ body: openApispec,
1874
+ headers: { 'content-type': 'application/json' },
1845
1875
  };
1846
1876
  } else {
1847
1877
  // TODO: request.resourceName can be '$serviceroot' or a resource and we should return an odata xml document based on that
@@ -267,7 +267,7 @@ const memoizedOdata2AbstractSQL = (() => {
267
267
  };
268
268
  })();
269
269
 
270
- export const metadataEndpoints = ['$metadata', '$serviceroot'];
270
+ export const metadataEndpoints = ['$metadata', '$serviceroot', 'openapi.json'];
271
271
 
272
272
  export function parseOData(
273
273
  b: UnparsedRequest & { _isChangeSet?: false },