Skip to content

Commit e4d0c5c

Browse files
committed
Return $metadata resource as odata + openapi spec
Returning odata and openapi specs in json format. Specs are scoped to the request permissions. Different users (roles) will receive different metadata endpoints and resources. Change-type: minor Signed-off-by: Harald Fischer <[email protected]>
1 parent 14dd554 commit e4d0c5c

File tree

10 files changed

+669
-171
lines changed

10 files changed

+669
-171
lines changed

src/odata-metadata/odata-metadata-generator.ts

Lines changed: 352 additions & 127 deletions
Large diffs are not rendered by default.
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import * as odataMetadata from 'odata-openapi';
2+
import type { generateODataMetadata } from './odata-metadata-generator';
3+
import _ = require('lodash');
4+
// tslint:disable-next-line:no-var-requires
5+
6+
export const generateODataMetadataAsOpenApi = (
7+
odataCsdl: ReturnType<typeof generateODataMetadata>,
8+
versionBasePathUrl: string = '',
9+
hostname: string = '',
10+
) => {
11+
// console.log(`odataCsdl:${JSON.stringify(odataCsdl, null, 2)}`);
12+
const openAPIJson: any = odataMetadata.csdl2openapi(odataCsdl, {
13+
scheme: 'https',
14+
host: hostname,
15+
basePath: versionBasePathUrl,
16+
diagram: false,
17+
maxLevels: 5,
18+
});
19+
20+
/**
21+
* Manual rewriting OpenAPI specification to delete OData default functionality
22+
* that is not implemented in Pinejs yet or is based on PineJs implements OData V3.
23+
*
24+
* Rewrite odata body response schema properties from `value: ` to `d: `
25+
* Currently pinejs is returning `d: `
26+
* https://www.odata.org/documentation/odata-version-2-0/json-format/ (6. Representing Collections of Entries)
27+
* https://www.odata.org/documentation/odata-version-3-0/json-verbose-format/ (6.1 Response body)
28+
*
29+
* New v4 odata specifies the body response with `value: `
30+
* http://docs.oasis-open.org/odata/odata-json-format/v4.01/odata-json-format-v4.01.html#sec_IndividualPropertyorOperationRespons
31+
*
32+
*
33+
* Currently pinejs does not implement a $count=true query parameter as this would return the count of all rows returned as an additional parameter.
34+
* 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.
35+
* Until this is in parity with OData V4 pinejs needs to cleanup the `odata.count` key from the response schema put in by `csdl2openapi`
36+
*
37+
*
38+
* Used oasis translator generates openapi according to v4 spec (`value: `)
39+
*
40+
* Unfortunantely odata-openapi does not export the genericFilter object.
41+
* Using hardcoded generic filter description as used in odata-openapi code.
42+
* Putting the genericFilter into the #/components/parameters/filter to reference it from paths
43+
*
44+
* */
45+
const parameters = openAPIJson?.components?.parameters;
46+
parameters['filter'] = {
47+
name: '$filter',
48+
description:
49+
'Filter items by property values, see [Filtering](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-protocol.html#sec_SystemQueryOptionfilter)',
50+
in: 'query',
51+
schema: {
52+
type: 'string',
53+
},
54+
};
55+
56+
for (const idx of Object.keys(openAPIJson.paths)) {
57+
// rewrite `value: ` to `d: `
58+
const properties =
59+
openAPIJson?.paths[idx]?.get?.responses?.['200']?.content?.[
60+
'application/json'
61+
]?.schema?.properties;
62+
if (properties?.value) {
63+
properties['d'] = properties.value;
64+
delete properties.value;
65+
}
66+
67+
// cleanup the `odata.count` key from the response schema
68+
if (properties?.['@odata.count']) {
69+
delete properties['@odata.count'];
70+
}
71+
72+
// copy over 'delete' and 'patch' action from single entiy path
73+
// odata-openAPI converter does not support collection delete and collection update.
74+
// pinejs support collection delete and update with $filter parameter
75+
const entityCollectionPath = openAPIJson?.paths[idx];
76+
const singleEntityPath = openAPIJson?.paths[idx + '({id})'];
77+
if (entityCollectionPath != null && singleEntityPath != null) {
78+
const genericFilterParameterRef = {
79+
$ref: '#/components/parameters/filter',
80+
};
81+
for (const action of ['delete', 'patch']) {
82+
entityCollectionPath[action] = _.clone(singleEntityPath?.[action]);
83+
if (entityCollectionPath[action]) {
84+
entityCollectionPath[action]['parameters'] = [
85+
genericFilterParameterRef,
86+
];
87+
}
88+
}
89+
}
90+
}
91+
92+
// cleanup $batch path as pinejs does not implement it.
93+
// http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-protocol.html#sec_BatchRequests
94+
if (openAPIJson?.paths['/$batch']) {
95+
delete openAPIJson.paths['/$batch'];
96+
}
97+
98+
return openAPIJson;
99+
};

src/sbvr-api/permissions.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -324,7 +324,7 @@ const namespaceRelationships = (
324324
});
325325
};
326326

327-
type PermissionLookup = Dictionary<true | string[]>;
327+
export type PermissionLookup = Dictionary<true | string[]>;
328328

329329
const getPermissionsLookup = env.createCache(
330330
'permissionsLookup',
@@ -1703,7 +1703,7 @@ const getGuestPermissions = memoize(
17031703
{ promise: true },
17041704
);
17051705

1706-
const getReqPermissions = async (
1706+
export const getReqPermissions = async (
17071707
req: PermissionReq,
17081708
odataBinds: ODataBinds = [] as any as ODataBinds,
17091709
) => {

src/sbvr-api/sbvr-utils.ts

Lines changed: 68 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ import { ExtendedSBVRParser } from '../extended-sbvr-parser/extended-sbvr-parser
4343

4444
import * as asyncMigrator from '../migrator/async';
4545
import * as syncMigrator from '../migrator/sync';
46-
import { generateODataMetadata } from '../odata-metadata/odata-metadata-generator';
46+
import { generateODataMetadataAsOpenApi } from '../odata-metadata/open-api-sepcification-generator';
4747

4848
import type DevModel from './dev';
4949
// eslint-disable-next-line @typescript-eslint/no-var-requires
@@ -105,6 +105,8 @@ import {
105105
type MigrationExecutionResult,
106106
setExecutedMigrations,
107107
} from '../migrator/utils';
108+
import { generateODataMetadata } from '../odata-metadata/odata-metadata-generator';
109+
import { metadataEndpoints } from './uri-parser';
108110

109111
const LF2AbstractSQLTranslator = LF2AbstractSQL.createTranslator(sbvrTypes);
110112
const LF2AbstractSQLTranslatorVersion = `${LF2AbstractSQLVersion}+${sbvrTypesVersion}`;
@@ -147,18 +149,18 @@ export interface ApiKey extends Actor {
147149
export interface Response {
148150
statusCode: number;
149151
headers?:
150-
| {
151-
[headerName: string]: any;
152-
}
153-
| undefined;
152+
| {
153+
[headerName: string]: any;
154+
}
155+
| undefined;
154156
body?: AnyObject | string;
155157
}
156158

157159
export type ModelExecutionResult =
158160
| undefined
159161
| {
160-
migrationExecutionResult?: MigrationExecutionResult;
161-
};
162+
migrationExecutionResult?: MigrationExecutionResult;
163+
};
162164

163165
const memoizedResolvedSynonym = memoizeWeak(
164166
(
@@ -250,9 +252,9 @@ const prettifyConstraintError = (
250252
let keyMatches: RegExpExecArray | null = null;
251253
let violatedConstraintInfo:
252254
| {
253-
table: AbstractSQLCompiler.AbstractSqlTable;
254-
name: string;
255-
}
255+
table: AbstractSQLCompiler.AbstractSqlTable;
256+
name: string;
257+
}
256258
| undefined;
257259
if (err instanceof db.UniqueConstraintError) {
258260
switch (db.engine) {
@@ -290,8 +292,8 @@ const prettifyConstraintError = (
290292
const columns = keyMatches[1].split('_');
291293
throw new db.UniqueConstraintError(
292294
'"' +
293-
columns.map(sqlNameToODataName).join('" and "') +
294-
'" must be unique.',
295+
columns.map(sqlNameToODataName).join('" and "') +
296+
'" must be unique.',
295297
);
296298
}
297299
if (violatedConstraintInfo != null) {
@@ -326,16 +328,16 @@ const prettifyConstraintError = (
326328
const tableName = abstractSqlModel.tables[resourceName].name;
327329
keyMatches = new RegExp(
328330
'"' +
329-
tableName +
330-
'" violates foreign key constraint ".*?" on table "(.*?)"',
331+
tableName +
332+
'" violates foreign key constraint ".*?" on table "(.*?)"',
331333
).exec(err.message);
332334
if (keyMatches == null) {
333335
keyMatches = new RegExp(
334336
'"' +
335-
tableName +
336-
'" violates foreign key constraint "' +
337-
tableName +
338-
'_(.*?)_fkey"',
337+
tableName +
338+
'" violates foreign key constraint "' +
339+
tableName +
340+
'_(.*?)_fkey"',
339341
).exec(err.message);
340342
}
341343
break;
@@ -362,8 +364,8 @@ const prettifyConstraintError = (
362364
case 'postgres':
363365
keyMatches = new RegExp(
364366
'new row for relation "' +
365-
table.name +
366-
'" violates check constraint "(.*?)"',
367+
table.name +
368+
'" violates check constraint "(.*?)"',
367369
).exec(err.message);
368370
break;
369371
}
@@ -980,11 +982,11 @@ export const runRule = (() => {
980982
const odataResult = (await runURI(
981983
'GET',
982984
'/' +
983-
vocab +
984-
'/' +
985-
sqlNameToODataName(table.resourceName) +
986-
'?$filter=' +
987-
filter,
985+
vocab +
986+
'/' +
987+
sqlNameToODataName(table.resourceName) +
988+
'?$filter=' +
989+
filter,
988990
undefined,
989991
undefined,
990992
permissions.rootRead,
@@ -1375,7 +1377,7 @@ const runODataRequest = (req: Express.Request, vocabulary: string) => {
13751377
-'#canAccess'.length,
13761378
);
13771379
}
1378-
if (abstractSqlModel.tables[resolvedResourceName] == null) {
1380+
if (abstractSqlModel.tables[resolvedResourceName] == null && !metadataEndpoints.includes(resolvedResourceName)) {
13791381
throw new UnauthorizedError();
13801382
}
13811383

@@ -1695,19 +1697,19 @@ const runRequest = async (
16951697

16961698
const runChangeSet =
16971699
(req: Express.Request, tx: Db.Tx) =>
1698-
async (
1699-
changeSetResults: Map<number, Response>,
1700-
request: uriParser.ODataRequest,
1701-
): Promise<void> => {
1702-
request = updateBinds(changeSetResults, request);
1703-
const result = await runRequest(req, tx, request);
1704-
if (request.id == null) {
1705-
throw new Error('No request id');
1706-
}
1707-
result.headers ??= {};
1708-
result.headers['content-id'] = request.id;
1709-
changeSetResults.set(request.id, result);
1710-
};
1700+
async (
1701+
changeSetResults: Map<number, Response>,
1702+
request: uriParser.ODataRequest,
1703+
): Promise<void> => {
1704+
request = updateBinds(changeSetResults, request);
1705+
const result = await runRequest(req, tx, request);
1706+
if (request.id == null) {
1707+
throw new Error('No request id');
1708+
}
1709+
result.headers ??= {};
1710+
result.headers['content-id'] = request.id;
1711+
changeSetResults.set(request.id, result);
1712+
};
17111713

17121714
// Requests inside a changeset may refer to resources created inside the
17131715
// changeset, the generation of the sql query for those requests must be
@@ -1878,10 +1880,35 @@ const respondGet = async (
18781880
return response;
18791881
} else {
18801882
if (request.resourceName === '$metadata') {
1883+
const permLookup = await permissions.getReqPermissions(req);
1884+
const spec = generateODataMetadata(
1885+
vocab,
1886+
models[vocab].abstractSql,
1887+
permLookup,
1888+
);
1889+
return {
1890+
statusCode: 200,
1891+
body: spec,
1892+
headers: { 'content-type': 'application/json' },
1893+
};
1894+
} else if (request.resourceName === 'openapi.json') {
1895+
// https://docs.oasis-open.org/odata/odata-openapi/v1.0/cn01/odata-openapi-v1.0-cn01.html#sec_ProvidingOASDocumentsforanODataServi
1896+
// Following the OASIS OData to openapi translation guide the openapi.json is an independent resource
1897+
const permLookup = await permissions.getReqPermissions(req);
1898+
const spec = generateODataMetadata(
1899+
vocab,
1900+
models[vocab].abstractSql,
1901+
permLookup,
1902+
);
1903+
const openApispec = generateODataMetadataAsOpenApi(
1904+
spec,
1905+
req.originalUrl.replace('openapi.json', ''),
1906+
req.hostname,
1907+
);
18811908
return {
18821909
statusCode: 200,
1883-
body: models[vocab].odataMetadata,
1884-
headers: { 'content-type': 'xml' },
1910+
body: openApispec,
1911+
headers: { 'content-type': 'application/json' },
18851912
};
18861913
} else {
18871914
// TODO: request.resourceName can be '$serviceroot' or a resource and we should return an odata xml document based on that

src/sbvr-api/uri-parser.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,7 @@ const memoizedOdata2AbstractSQL = (() => {
260260
};
261261
})();
262262

263-
export const metadataEndpoints = ['$metadata', '$serviceroot'];
263+
export const metadataEndpoints = ['$metadata', '$serviceroot', 'openapi.json'];
264264

265265
export function parseOData(
266266
b: UnparsedRequest & { _isChangeSet?: false },

test/08-metadata.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { writeFileSync } from 'fs';
2+
import { expect } from 'chai';
3+
import supertest from 'supertest';
4+
import { testInit, testDeInit, testLocalServer } from './lib/test-init';
5+
6+
import OpenAPIParser from '@readme/openapi-parser';
7+
8+
describe('08 metadata / openAPI spec', function () {
9+
describe('Full model access specification', async function () {
10+
const fixturePath =
11+
__dirname + '/fixtures/08-metadata/config-full-access.js';
12+
let pineServer: Awaited<ReturnType<typeof testInit>>;
13+
before(async () => {
14+
pineServer = await testInit({
15+
configPath: fixturePath,
16+
deleteDb: true,
17+
});
18+
});
19+
20+
after(async () => {
21+
await testDeInit(pineServer);
22+
});
23+
24+
it('should send OData CSDL JSON on /$metadata', async () => {
25+
const res = await supertest(testLocalServer)
26+
.get('/example/$metadata')
27+
.expect(200);
28+
expect(res.body).to.be.an('object');
29+
});
30+
31+
it('should send valid OpenAPI spec JSON on /$metadata', async () => {
32+
const { body } = await supertest(testLocalServer)
33+
.get('/example/openapi.json')
34+
.expect(200);
35+
expect(body).to.be.an('object');
36+
37+
const bodySpec = JSON.stringify(body, null, 2);
38+
await writeFileSync('openApiSpe-full.json', bodySpec);
39+
40+
// validate the openAPI spec and expect no validator errors.
41+
try {
42+
const apiSpec = await OpenAPIParser.validate(JSON.parse(bodySpec));
43+
expect(apiSpec).to.be.an('object');
44+
} catch (err) {
45+
expect(err).to.be.undefined;
46+
}
47+
});
48+
49+
it('OpenAPI spec should contain all paths and actions on resources', async () => {
50+
// full CRUD access for device resource
51+
const res = await supertest(testLocalServer)
52+
.get('/example/openapi.json')
53+
.expect(200);
54+
expect(res.body).to.be.an('object');
55+
56+
// all collections should have get, patch, delete and post
57+
const singleIdPathRegEx = /\({id}\)/;
58+
for (const [path, value] of Object.entries(res.body.paths)) {
59+
if (!singleIdPathRegEx.exec(path)) {
60+
expect(value).to.have.keys(['get', 'patch', 'delete', 'post']);
61+
}
62+
}
63+
});
64+
});
65+
});

0 commit comments

Comments
 (0)