SYNC: Merge pull request #54 from dbgate/feature/odata-api

This commit is contained in:
Jan Prochazka
2026-02-18 16:31:48 +01:00
committed by Diflow
parent 53f940cd23
commit 2474f915d4
13 changed files with 1172 additions and 32 deletions
+3 -1
View File
@@ -4,6 +4,8 @@
"dbgate"
],
"chat.tools.terminal.autoApprove": {
"yarn workspace": true
"yarn workspace": true,
"yarn --cwd packages/rest": true,
"yarn --cwd packages/web": true
}
}
+5
View File
@@ -0,0 +1,5 @@
# AGENTS
## Rules
- In newly added code, always use `DBGM-00000` for message/error codes; do not introduce new numbered DBGM codes such as `DBGM-00316`.
+2 -2
View File
@@ -4,7 +4,7 @@ const { pluginsdir, packagedPluginsDir, getPluginBackendPath } = require('../uti
const platformInfo = require('../utility/platformInfo');
const authProxy = require('../utility/authProxy');
const { getLogger } = require('dbgate-tools');
const { openApiDriver, graphQlDriver } = require('dbgate-rest');
const { openApiDriver, graphQlDriver, oDataDriver } = require('dbgate-rest');
//
const logger = getLogger('requirePlugin');
@@ -26,7 +26,7 @@ function requirePlugin(packageName, requiredPlugin = null) {
if (requiredPlugin == null) {
if (packageName.endsWith('@rest') || packageName === 'rest') {
return {
drivers: [openApiDriver, graphQlDriver],
drivers: [openApiDriver, graphQlDriver, oDataDriver],
};
}
let module;
+1 -1
View File
@@ -1,6 +1,6 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
moduleFileExtensions: ['js'],
moduleFileExtensions: ['ts', 'js'],
reporters: ['default', 'github-actions'],
};
+3
View File
@@ -1,6 +1,9 @@
export * from './openApiDriver';
export * from './oDataDriver';
export * from './graphQlDriver';
export * from './openApiAdapter';
export * from './oDataAdapter';
export * from './oDataMetadataParser';
export * from './restApiExecutor';
export * from './arrayify';
export * from './graphqlIntrospection';
+70
View File
@@ -0,0 +1,70 @@
const { analyseODataDefinition } = require('./oDataAdapter');
function findEndpoint(apiInfo, path, method = 'GET') {
return apiInfo.categories
.flatMap(category => category.endpoints)
.find(endpoint => endpoint.path === path && endpoint.method === method);
}
test('deduces mandatory company parameter for customers and items from ContainsTarget metadata', () => {
const serviceDocument = {
'@odata.context': 'https://example/odata/$metadata',
value: [
{ name: 'companies', kind: 'EntitySet', url: 'companies' },
{ name: 'customers', kind: 'EntitySet', url: 'customers' },
{ name: 'items', kind: 'EntitySet', url: 'items' },
],
};
const metadataXml = `<?xml version="1.0" encoding="utf-8"?>
<edmx:Edmx Version="4.0" xmlns:edmx="http://docs.oasis-open.org/odata/ns/edmx">
<edmx:DataServices>
<Schema Namespace="Microsoft.NAV" Alias="NAV" xmlns="http://docs.oasis-open.org/odata/ns/edm">
<EntityType Name="company">
<Key><PropertyRef Name="id"/></Key>
<Property Name="id" Type="Edm.Guid"/>
<Property Name="displayName" Type="Edm.String"/>
<NavigationProperty Name="customers" Type="Collection(NAV.customer)" ContainsTarget="true" />
<NavigationProperty Name="items" Type="Collection(NAV.item)" ContainsTarget="true" />
</EntityType>
<EntityType Name="customer">
<Property Name="id" Type="Edm.Guid"/>
</EntityType>
<EntityType Name="item">
<Property Name="id" Type="Edm.Guid"/>
</EntityType>
<EntityContainer Name="default">
<EntitySet Name="companies" EntityType="NAV.company">
<NavigationPropertyBinding Path="customers" Target="customers"/>
<NavigationPropertyBinding Path="items" Target="items"/>
</EntitySet>
<EntitySet Name="customers" EntityType="NAV.customer"/>
<EntitySet Name="items" EntityType="NAV.item"/>
</EntityContainer>
</Schema>
</edmx:DataServices>
</edmx:Edmx>`;
const apiInfo = analyseODataDefinition(serviceDocument, 'https://example/odata', metadataXml);
const customersGet = findEndpoint(apiInfo, '/customers', 'GET');
const itemsGet = findEndpoint(apiInfo, '/items', 'GET');
expect(customersGet).toBeDefined();
expect(itemsGet).toBeDefined();
const customersCompany = customersGet.parameters.find(param => param.name === 'company');
const itemsCompany = itemsGet.parameters.find(param => param.name === 'company');
expect(customersCompany).toBeDefined();
expect(customersCompany.required).toBe(true);
expect(customersCompany.in).toBe('query');
expect(customersCompany.odataLookupEntitySet).toBe('companies');
expect(customersCompany.odataLookupPath).toBe('/companies');
expect(itemsCompany).toBeDefined();
expect(itemsCompany.required).toBe(true);
expect(itemsCompany.in).toBe('query');
expect(itemsCompany.odataLookupEntitySet).toBe('companies');
expect(itemsCompany.odataLookupPath).toBe('/companies');
});
+458
View File
@@ -0,0 +1,458 @@
import { RestApiDefinition, RestApiEndpoint, RestApiParameter, RestApiServer } from './restApiDef';
import { parseODataMetadataDocument } from './oDataMetadataParser';
export type ODataServiceResource = {
name?: string;
kind?: string;
url?: string;
};
export type ODataServiceDocument = {
'@odata.context'?: string;
value?: ODataServiceResource[];
};
export interface ODataMetadataNavigationProperty {
name: string;
type?: string;
containsTarget: boolean;
nullable: boolean;
}
export interface ODataMetadataEntityType {
typeName: string;
fullTypeName: string;
keyProperties: string[];
stringProperties: string[];
navigationProperties: ODataMetadataNavigationProperty[];
}
export interface ODataMetadataEntitySet {
name: string;
entityType: string;
navigationBindings: Record<string, string>;
}
export interface ODataMetadataDocument {
entityTypes: Record<string, ODataMetadataEntityType>;
entitySets: Record<string, ODataMetadataEntitySet>;
}
function normalizeServiceRoot(contextUrl: string | undefined, fallbackUrl: string): string {
const safeFallback = String(fallbackUrl ?? '').trim();
if (typeof contextUrl === 'string' && contextUrl.trim()) {
try {
const resolved = new URL(contextUrl.trim(), safeFallback || undefined);
resolved.hash = '';
resolved.search = '';
resolved.pathname = resolved.pathname.replace(/\/$metadata$/i, '');
const url = resolved.toString();
return url.endsWith('/') ? url : `${url}/`;
} catch {
// ignore, fallback below
}
}
return safeFallback.endsWith('/') ? safeFallback : `${safeFallback}/`;
}
function normalizeEndpointPath(valueUrl: string | undefined): string | null {
const input = String(valueUrl ?? '').trim();
if (!input) return null;
try {
const parsed = new URL(input, 'http://odata.local');
const pathWithQuery = `${parsed.pathname}${parsed.search}`;
return pathWithQuery.startsWith('/') ? pathWithQuery : `/${pathWithQuery}`;
} catch {
return input.startsWith('/') ? input : `/${input}`;
}
}
function inferMethods(kind: string | undefined): RestApiEndpoint['method'][] {
const normalizedKind = String(kind ?? '').toLowerCase();
if (normalizedKind === 'actionimport') return ['POST'];
if (normalizedKind === 'entityset') return ['GET', 'POST'];
return ['GET'];
}
function toLowerCamelCase(value: string | undefined): string {
const text = String(value ?? '').trim();
if (!text) return '';
return text.charAt(0).toLowerCase() + text.slice(1);
}
function normalizeSingularName(value: string | undefined): string {
const text = String(value ?? '').trim();
if (!text) return '';
if (/ies$/i.test(text)) return `${text.slice(0, -3)}y`;
if (/sses$/i.test(text)) return text;
if (/s$/i.test(text) && text.length > 1) return text.slice(0, -1);
return text;
}
function normalizePluralName(value: string | undefined): string {
const text = String(value ?? '').trim();
if (!text) return '';
if (/y$/i.test(text)) return `${text.slice(0, -1)}ies`;
if (/s$/i.test(text)) return text;
return `${text}s`;
}
function normalizeEntityTypeName(typeName: string | undefined): string {
const text = String(typeName ?? '').trim();
if (!text) return '';
const collectionMatch = text.match(/^Collection\((.+)\)$/i);
const unwrapped = collectionMatch ? collectionMatch[1] : text;
const slashStripped = unwrapped.includes('/') ? unwrapped.split('/').pop() || unwrapped : unwrapped;
return slashStripped.trim();
}
function buildTypeReferenceKeys(typeReference: string | undefined): string[] {
const normalizedReference = normalizeEntityTypeName(typeReference);
if (!normalizedReference) return [];
const keys = new Set<string>();
const lower = normalizedReference.toLowerCase();
keys.add(lower);
const withoutNamespace = normalizedReference.includes('.')
? normalizedReference.split('.').pop() || normalizedReference
: normalizedReference;
keys.add(withoutNamespace.toLowerCase());
return Array.from(keys);
}
function buildEntityTypeLookup(entityTypes: Record<string, ODataMetadataEntityType>): Map<string, ODataMetadataEntityType> {
const lookup = new Map<string, ODataMetadataEntityType>();
for (const [entityTypeKey, entityType] of Object.entries(entityTypes || {})) {
const keys = new Set<string>([
...buildTypeReferenceKeys(entityTypeKey),
...buildTypeReferenceKeys(entityType.fullTypeName),
...buildTypeReferenceKeys(entityType.typeName),
]);
for (const key of keys) {
if (!lookup.has(key)) {
lookup.set(key, entityType);
}
}
}
return lookup;
}
function resolveEntityType(
entityTypeLookup: Map<string, ODataMetadataEntityType>,
typeReference: string | undefined
): ODataMetadataEntityType | null {
const keys = buildTypeReferenceKeys(typeReference);
for (const key of keys) {
const found = entityTypeLookup.get(key);
if (found) return found;
}
return null;
}
function resolveLookupPath(entitySetName: string, serviceResourceMap: Map<string, ODataServiceResource>): string {
const serviceResource = serviceResourceMap.get(entitySetName);
const resourceUrl = String(serviceResource?.url ?? '').trim();
if (!resourceUrl) return `/${entitySetName}`;
return resourceUrl.startsWith('/') ? resourceUrl : `/${resourceUrl}`;
}
function buildServiceResourceNameLookup(resources: ODataServiceResource[]): Map<string, string> {
const lookup = new Map<string, string>();
for (const resource of resources || []) {
const resourceName = String(resource?.name ?? '').trim();
if (!resourceName) continue;
const lower = resourceName.toLowerCase();
if (!lookup.has(lower)) {
lookup.set(lower, resourceName);
}
}
return lookup;
}
function resolveServiceResourceNameForEntityType(
entityType: ODataMetadataEntityType,
serviceResourceNameLookup: Map<string, string>
): string | null {
const baseNames = [
String(entityType?.typeName ?? '').trim(),
normalizeSingularName(entityType?.typeName),
normalizeEntityTypeName(entityType?.fullTypeName),
normalizeSingularName(normalizeEntityTypeName(entityType?.fullTypeName)),
].filter(Boolean);
const candidates = new Set<string>();
for (const baseName of baseNames) {
candidates.add(baseName);
candidates.add(normalizeSingularName(baseName));
candidates.add(normalizePluralName(baseName));
}
for (const candidate of candidates) {
const matched = serviceResourceNameLookup.get(String(candidate).toLowerCase());
if (matched) return matched;
}
return null;
}
type MandatoryNavigationTargetParameter = {
name: string;
lookupEntitySet: string;
lookupPath: string;
lookupValueField?: string;
lookupLabelField?: string;
};
type MandatoryNavigationByTarget = Record<string, MandatoryNavigationTargetParameter[]>;
type ParentNavigationContext = {
parentEntitySetName: string;
parentType: ODataMetadataEntityType;
navigationBindings: Record<string, string>;
};
function deduceMandatoryNavigationByTarget(
metadataDocument: ODataMetadataDocument | null,
resources: ODataServiceResource[]
): MandatoryNavigationByTarget {
if (!metadataDocument) return {};
const entityTypeLookup = buildEntityTypeLookup(metadataDocument.entityTypes || {});
const serviceResourceMap = new Map<string, ODataServiceResource>();
for (const resource of resources) {
const resourceName = String(resource?.name ?? '').trim();
if (resourceName) {
serviceResourceMap.set(resourceName, resource);
}
}
const serviceResourceNameLookup = buildServiceResourceNameLookup(resources);
const entitySetsByEntityType = new Map<string, string[]>();
for (const [entitySetName, entitySet] of Object.entries(metadataDocument.entitySets || {})) {
const typeKeys = buildTypeReferenceKeys(entitySet?.entityType);
if (typeKeys.length === 0) continue;
for (const typeKey of typeKeys) {
const list = entitySetsByEntityType.get(typeKey) || [];
if (!list.includes(entitySetName)) {
list.push(entitySetName);
entitySetsByEntityType.set(typeKey, list);
}
}
}
const mandatoryByTarget: MandatoryNavigationByTarget = {};
const parentContexts: ParentNavigationContext[] = [];
const parentTypeKeysCovered = new Set<string>();
for (const [parentEntitySetName, parentEntitySet] of Object.entries(metadataDocument.entitySets || {})) {
const parentType = resolveEntityType(entityTypeLookup, parentEntitySet.entityType);
if (!parentType) continue;
parentContexts.push({
parentEntitySetName,
parentType,
navigationBindings: parentEntitySet.navigationBindings || {},
});
for (const typeKey of buildTypeReferenceKeys(parentEntitySet.entityType)) {
parentTypeKeysCovered.add(typeKey);
}
}
for (const entityType of Object.values(metadataDocument.entityTypes || {})) {
const typeKeys = [
...buildTypeReferenceKeys(entityType.fullTypeName),
...buildTypeReferenceKeys(entityType.typeName),
];
const alreadyCovered = typeKeys.some(typeKey => parentTypeKeysCovered.has(typeKey));
if (alreadyCovered) continue;
if (!Array.isArray(entityType.navigationProperties) || entityType.navigationProperties.length === 0) {
continue;
}
const parentEntitySetName = resolveServiceResourceNameForEntityType(entityType, serviceResourceNameLookup);
if (!parentEntitySetName) continue;
parentContexts.push({
parentEntitySetName,
parentType: entityType,
navigationBindings: {},
});
for (const typeKey of typeKeys) {
parentTypeKeysCovered.add(typeKey);
}
}
for (const { parentEntitySetName, parentType, navigationBindings } of parentContexts) {
const parentParamName =
toLowerCamelCase(parentType.typeName) ||
toLowerCamelCase(normalizeSingularName(parentEntitySetName)) ||
toLowerCamelCase(parentEntitySetName);
if (!parentParamName) continue;
for (const navProperty of parentType.navigationProperties || []) {
if (!navProperty.containsTarget) continue;
const targetNames = new Set<string>();
const directBoundTarget = navigationBindings?.[navProperty.name];
if (directBoundTarget) {
targetNames.add(directBoundTarget);
}
const navTypeKeys = buildTypeReferenceKeys(navProperty.type);
if (navTypeKeys.length > 0) {
const typeTargets = navTypeKeys.flatMap(typeKey => entitySetsByEntityType.get(typeKey) || []);
for (const targetName of typeTargets) {
targetNames.add(targetName);
}
}
for (const targetEntitySetName of targetNames) {
const targetList = mandatoryByTarget[targetEntitySetName] || [];
const exists = targetList.some(item => item.name.toLowerCase() === parentParamName.toLowerCase());
if (exists) continue;
targetList.push({
name: parentParamName,
lookupEntitySet: parentEntitySetName,
lookupPath: resolveLookupPath(parentEntitySetName, serviceResourceMap),
lookupValueField: parentType.keyProperties?.[0],
lookupLabelField: parentType.stringProperties?.find(prop => /name/i.test(prop)) || parentType.stringProperties?.[0],
});
mandatoryByTarget[targetEntitySetName] = targetList;
}
}
}
return mandatoryByTarget;
}
function buildMandatoryNavigationParameters(
resource: ODataServiceResource,
mandatoryByTarget: MandatoryNavigationByTarget
): RestApiParameter[] {
const resourceName = String(resource?.name ?? '').trim();
if (!resourceName) return [];
const mandatoryTargets = mandatoryByTarget[resourceName] || [];
const mandatoryParameters: RestApiParameter[] = [];
const seenNames = new Set<string>();
for (const mandatoryTarget of mandatoryTargets) {
const normalizedName = mandatoryTarget.name.toLowerCase();
if (seenNames.has(normalizedName)) continue;
const description = mandatoryTarget.lookupEntitySet
? `Required navigation parameter deduced from OData metadata (lookup: ${mandatoryTarget.lookupEntitySet})`
: 'Required navigation parameter deduced from OData metadata';
mandatoryParameters.push({
name: mandatoryTarget.name,
in: 'query',
dataType: 'string',
required: true,
description,
odataLookupPath: mandatoryTarget.lookupPath,
odataLookupEntitySet: mandatoryTarget.lookupEntitySet,
odataLookupValueField: mandatoryTarget.lookupValueField,
odataLookupLabelField: mandatoryTarget.lookupLabelField,
});
seenNames.add(normalizedName);
}
return mandatoryParameters;
}
function createODataResourceEndpoints(
resource: ODataServiceResource,
mandatoryByTarget: MandatoryNavigationByTarget
): RestApiEndpoint[] {
const path = normalizeEndpointPath(resource.url);
if (!path) return [];
const summary = resource.name || resource.url || path;
const descriptionKind = String(resource.kind ?? '').trim();
const methods = inferMethods(resource.kind);
const mandatoryNavigationParameters = buildMandatoryNavigationParameters(resource, mandatoryByTarget);
return methods.map(method => {
const parameters: RestApiParameter[] = [...mandatoryNavigationParameters];
if (method === 'POST') {
parameters.push({
name: 'body',
in: 'body',
dataType: 'object',
contentType: 'application/json',
});
}
return {
method,
path,
summary,
description: descriptionKind ? `OData ${descriptionKind}` : 'OData resource',
parameters,
};
});
}
export function analyseODataDefinition(
doc: ODataServiceDocument,
endpointUrl: string,
metadataDocumentXml?: string | null
): RestApiDefinition {
const resources = Array.isArray(doc?.value) ? doc.value : [];
const categoriesByName = new Map<string, RestApiEndpoint[]>();
const metadataDocument = metadataDocumentXml ? parseODataMetadataDocument(metadataDocumentXml) : null;
const mandatoryByTarget = deduceMandatoryNavigationByTarget(metadataDocument, resources);
for (const resource of resources) {
const endpoints = createODataResourceEndpoints(resource, mandatoryByTarget);
if (endpoints.length === 0) continue;
const categoryName = String(resource.kind ?? 'Resources').trim() || 'Resources';
const existingEndpoints = categoriesByName.get(categoryName) || [];
existingEndpoints.push(...endpoints);
categoriesByName.set(categoryName, existingEndpoints);
}
const metadataEndpoint: RestApiEndpoint = {
method: 'GET',
path: '/$metadata',
summary: '$metadata',
description: 'OData service metadata',
parameters: [],
};
const metadataCategory = categoriesByName.get('Metadata') || [];
metadataCategory.push(metadataEndpoint);
categoriesByName.set('Metadata', metadataCategory);
const serviceRoot = normalizeServiceRoot(doc?.['@odata.context'], endpointUrl);
const servers: RestApiServer[] = serviceRoot ? [{ url: serviceRoot }] : [];
return {
categories: Array.from(categoriesByName.entries()).map(([name, endpoints]) => ({
name,
endpoints,
})),
servers,
};
}
+93
View File
@@ -0,0 +1,93 @@
import type { EngineDriver } from 'dbgate-types';
import { buildRestAuthHeaders } from './restAuthTools';
import { apiDriverBase } from './restDriverBase';
function resolveServiceRoot(contextUrl: string | undefined, fallbackUrl: string): string {
const safeFallback = String(fallbackUrl ?? '').trim();
if (typeof contextUrl === 'string' && contextUrl.trim()) {
try {
const resolved = new URL(contextUrl.trim(), safeFallback || undefined);
resolved.hash = '';
resolved.search = '';
resolved.pathname = resolved.pathname.replace(/\/$metadata$/i, '');
const url = resolved.toString();
return url.endsWith('/') ? url : `${url}/`;
} catch {
// ignore, fallback below
}
}
return safeFallback.endsWith('/') ? safeFallback : `${safeFallback}/`;
}
async function loadODataServiceDocument(dbhan: any) {
if (!dbhan?.connection?.apiServerUrl1) {
throw new Error('DBGM-00000 OData endpoint URL is not configured');
}
const response = await dbhan.axios.get(dbhan.connection.apiServerUrl1, {
headers: buildRestAuthHeaders(dbhan.connection.restAuth),
});
const document = response?.data;
if (!document || typeof document !== 'object') {
throw new Error('DBGM-00000 OData service document is empty or invalid');
}
if (!document['@odata.context']) {
throw new Error('DBGM-00000 OData service document does not contain @odata.context');
}
return document;
}
function getODataVersion(document: any): string {
const contextUrl = String(document?.['@odata.context'] ?? '').trim();
const versionMatch = contextUrl.match(/\/v(\d+(?:\.\d+)*)\/$metadata$/i);
if (versionMatch?.[1]) return versionMatch[1];
return '';
}
// @ts-ignore
export const oDataDriver: EngineDriver = {
...apiDriverBase,
engine: 'odata@rest',
title: 'OData - REST (experimental)',
databaseEngineTypes: ['rest', 'odata'],
icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><rect width="128" height="128" fill="#f9a000"/><rect x="12" y="12" width="47" height="12" fill="#ffffff"/><rect x="69" y="12" width="47" height="12" fill="#ffffff"/><rect x="12" y="37" width="47" height="12" fill="#ffffff"/><rect x="69" y="37" width="47" height="12" fill="#ffffff"/><rect x="12" y="62" width="47" height="12" fill="#ffffff"/><rect x="69" y="62" width="47" height="12" fill="#ffffff"/><rect x="69" y="87" width="47" height="12" fill="#ffffff"/><circle cx="35" cy="102" r="20" fill="#e6e6e6"/></svg>',
apiServerUrl1Label: 'OData Service URL',
showConnectionField: (field, values) => {
if (apiDriverBase.showAuthConnectionField(field, values)) return true;
if (field === 'apiServerUrl1') return true;
return false;
},
beforeConnectionSave: connection => ({
...connection,
singleDatabase: true,
defaultDatabase: '_api_database_',
}),
async connect(connection: any) {
return {
connection,
client: null,
database: '_api_database_',
axios: connection.axios,
};
},
async getVersion(dbhan: any) {
const document = await loadODataServiceDocument(dbhan);
const resourcesCount = Array.isArray(document?.value) ? document.value.length : 0;
const odataVersion = getODataVersion(document);
return {
version: odataVersion || 'OData',
versionText: `OData${odataVersion ? ` ${odataVersion}` : ''}, ${resourcesCount} resources`,
};
},
};
+161
View File
@@ -0,0 +1,161 @@
import type { ODataMetadataDocument, ODataMetadataEntitySet, ODataMetadataEntityType, ODataMetadataNavigationProperty } from './oDataAdapter';
function decodeXmlEntities(value: string): string {
return String(value ?? '')
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&');
}
function parseXmlAttributes(attributesText: string): Record<string, string> {
const attributes: Record<string, string> = {};
const regex = /([A-Za-z_][A-Za-z0-9_.:-]*)\s*=\s*("([^"]*)"|'([^']*)')/g;
let match = regex.exec(attributesText || '');
while (match) {
const rawName = match[1];
const localName = rawName.includes(':') ? rawName.split(':').pop() || rawName : rawName;
const rawValue = match[3] ?? match[4] ?? '';
const decoded = decodeXmlEntities(rawValue);
attributes[rawName] = decoded;
attributes[localName] = decoded;
match = regex.exec(attributesText || '');
}
return attributes;
}
function extractXmlElements(xml: string, elementName: string): Array<{ attributes: Record<string, string>; innerXml: string }> {
const elements: Array<{ attributes: Record<string, string>; innerXml: string }> = [];
const fullTagRegex = new RegExp(
`<(?:[A-Za-z_][A-Za-z0-9_.-]*:)?${elementName}\\b([^>]*)>([\\s\\S]*?)<\\/(?:[A-Za-z_][A-Za-z0-9_.-]*:)?${elementName}>`,
'gi'
);
const selfClosingRegex = new RegExp(
`<(?:[A-Za-z_][A-Za-z0-9_.-]*:)?${elementName}\\b([^>]*)\\/>`,
'gi'
);
let fullMatch = fullTagRegex.exec(xml || '');
while (fullMatch) {
elements.push({
attributes: parseXmlAttributes(fullMatch[1] || ''),
innerXml: fullMatch[2] || '',
});
fullMatch = fullTagRegex.exec(xml || '');
}
let selfClosingMatch = selfClosingRegex.exec(xml || '');
while (selfClosingMatch) {
elements.push({
attributes: parseXmlAttributes(selfClosingMatch[1] || ''),
innerXml: '',
});
selfClosingMatch = selfClosingRegex.exec(xml || '');
}
return elements;
}
function toBoolAttribute(value: string | undefined): boolean {
return String(value ?? '').trim().toLowerCase() === 'true';
}
function normalizeEntitySetName(value: string | undefined): string {
const input = String(value ?? '').trim();
if (!input) return '';
const noContainer = input.includes('/') ? input.split('/').pop() || '' : input;
return noContainer.includes('.') ? noContainer.split('.').pop() || noContainer : noContainer;
}
export function parseODataMetadataDocument(metadataXml: string): ODataMetadataDocument {
const schemas = extractXmlElements(metadataXml || '', 'Schema');
const entityTypes: Record<string, ODataMetadataEntityType> = {};
const entitySets: Record<string, ODataMetadataEntitySet> = {};
for (const schema of schemas) {
const namespace = String(schema.attributes.Namespace || '').trim();
for (const entityTypeNode of extractXmlElements(schema.innerXml, 'EntityType')) {
const typeName = String(entityTypeNode.attributes.Name || '').trim();
if (!typeName) continue;
const fullTypeName = namespace ? `${namespace}.${typeName}` : typeName;
const keyProperties: string[] = [];
const stringProperties: string[] = [];
const navigationProperties: ODataMetadataNavigationProperty[] = [];
for (const keyNode of extractXmlElements(entityTypeNode.innerXml, 'Key')) {
for (const propRef of extractXmlElements(keyNode.innerXml, 'PropertyRef')) {
const keyName = String(propRef.attributes.Name || '').trim();
if (keyName && !keyProperties.includes(keyName)) {
keyProperties.push(keyName);
}
}
}
for (const propertyNode of extractXmlElements(entityTypeNode.innerXml, 'Property')) {
const propName = String(propertyNode.attributes.Name || '').trim();
const propType = String(propertyNode.attributes.Type || '').trim();
if (propName && /^Edm\.String$/i.test(propType)) {
stringProperties.push(propName);
}
}
for (const navNode of extractXmlElements(entityTypeNode.innerXml, 'NavigationProperty')) {
const navName = String(navNode.attributes.Name || '').trim();
if (!navName) continue;
navigationProperties.push({
name: navName,
type: String(navNode.attributes.Type || '').trim(),
containsTarget: toBoolAttribute(navNode.attributes.ContainsTarget),
nullable: navNode.attributes.Nullable === undefined ? true : toBoolAttribute(navNode.attributes.Nullable),
});
}
entityTypes[fullTypeName] = {
typeName,
fullTypeName,
keyProperties,
stringProperties,
navigationProperties,
};
}
for (const entitySetNode of extractXmlElements(schema.innerXml, 'EntitySet')) {
const setName = String(entitySetNode.attributes.Name || '').trim();
const entityType = String(entitySetNode.attributes.EntityType || '').trim();
if (!setName || !entityType) continue;
const navigationBindings: Record<string, string> = {};
for (const bindingNode of extractXmlElements(entitySetNode.innerXml, 'NavigationPropertyBinding')) {
const path = String(bindingNode.attributes.Path || '').trim();
const target = normalizeEntitySetName(bindingNode.attributes.Target);
if (!path || !target) continue;
navigationBindings[path] = target;
const pathLastSegment = path.split('/').pop();
if (pathLastSegment && !navigationBindings[pathLastSegment]) {
navigationBindings[pathLastSegment] = target;
}
}
entitySets[setName] = {
name: setName,
entityType,
navigationBindings,
};
}
}
return {
entityTypes,
entitySets,
};
}
+5
View File
@@ -7,6 +7,11 @@ export interface RestApiParameter {
description?: string;
required?: boolean;
defaultValue?: any;
options?: Array<{ label: string; value: string }>;
odataLookupPath?: string;
odataLookupEntitySet?: string;
odataLookupValueField?: string;
odataLookupLabelField?: string;
}
export interface RestApiEndpoint {
+134
View File
@@ -0,0 +1,134 @@
const { executeODataApiEndpoint } = require('./restApiExecutor');
function createDefinition() {
return {
categories: [
{
name: 'EntitySet',
endpoints: [
{
method: 'GET',
path: '/customers',
parameters: [
{
name: 'company',
in: 'query',
dataType: 'string',
required: true,
},
],
},
{
method: 'GET',
path: '/$metadata',
parameters: [],
},
],
},
],
};
}
test('adds OData system query options from parameterValues', async () => {
const calls = [];
const axios = async args => {
calls.push(args);
return { status: 200, data: {} };
};
await executeODataApiEndpoint(
createDefinition(),
'/customers',
'GET',
{
company: '123',
'$top': 50,
'$skip': '10',
'$count': true,
'$select': ['id', 'displayName'],
'$orderby': 'displayName asc',
'$filter': 'displayName ne null',
'$search': 'dino',
'$expand': 'addresses',
'$format': 'application/json',
},
'https://example.test/odata',
null,
axios
);
expect(calls).toHaveLength(1);
const requestUrl = String(calls[0].url);
const parsed = new URL(requestUrl);
expect(parsed.pathname).toBe('/odata/customers');
expect(parsed.searchParams.get('company')).toBe('123');
expect(parsed.searchParams.get('$top')).toBe('50');
expect(parsed.searchParams.get('$skip')).toBe('10');
expect(parsed.searchParams.get('$count')).toBe('true');
expect(parsed.searchParams.get('$select')).toBe('id,displayName');
expect(parsed.searchParams.get('$orderby')).toBe('displayName asc');
expect(parsed.searchParams.get('$filter')).toBe('displayName ne null');
expect(parsed.searchParams.get('$search')).toBe('dino');
expect(parsed.searchParams.get('$expand')).toBe('addresses');
expect(parsed.searchParams.get('$format')).toBe('application/json');
});
test('accepts non-dollar aliases and ignores invalid system option values', async () => {
const calls = [];
const axios = async args => {
calls.push(args);
return { status: 200, data: {} };
};
await executeODataApiEndpoint(
createDefinition(),
'/customers',
'GET',
{
company: '123',
top: 'abc',
skip: -1,
count: 'yes',
select: ['id'],
filter: 'id ne null',
},
'https://example.test/odata',
null,
axios
);
expect(calls).toHaveLength(1);
const parsed = new URL(String(calls[0].url));
expect(parsed.searchParams.get('$top')).toBeNull();
expect(parsed.searchParams.get('$skip')).toBeNull();
expect(parsed.searchParams.get('$count')).toBeNull();
expect(parsed.searchParams.get('$select')).toBe('id');
expect(parsed.searchParams.get('$filter')).toBe('id ne null');
});
test('does not add OData system query options to $metadata endpoint', async () => {
const calls = [];
const axios = async args => {
calls.push(args);
return { status: 200, data: {} };
};
await executeODataApiEndpoint(
createDefinition(),
'/$metadata',
'GET',
{
'$top': 10,
'$count': true,
},
'https://example.test/odata',
null,
axios
);
expect(calls).toHaveLength(1);
const parsed = new URL(String(calls[0].url));
expect(parsed.pathname).toBe('/odata/$metadata');
expect(parsed.search).toBe('');
});
+235 -26
View File
@@ -32,7 +32,156 @@ function normalizeValueForRequest(value: any, parameter: RestApiParameter): any
return value;
}
export async function executeRestApiEndpoint(
function splitPathAndQuery(path: string) {
const value = String(path || '');
const index = value.indexOf('?');
if (index < 0) {
return {
pathOnly: value,
queryString: '',
};
}
return {
pathOnly: value.slice(0, index),
queryString: value.slice(index + 1),
};
}
function addAuthHeaders(headers: Record<string, string>, auth: RestApiAuthorization | null) {
if (!auth) return;
if (auth.type === 'basic') {
const basicAuth = Buffer.from(`${auth.user}:${auth.password}`).toString('base64');
headers['Authorization'] = `Basic ${basicAuth}`;
} else if (auth.type === 'bearer') {
headers['Authorization'] = `Bearer ${auth.token}`;
} else if (auth.type === 'apikey') {
headers[auth.header] = auth.value;
}
}
function findEndpointDefinition(
definition: RestApiDefinition,
endpoint: string,
method: string
) {
return definition.categories
.flatMap(category => category.endpoints)
.find(ep => ep.path === endpoint && ep.method === method);
}
function buildRequestUrl(server: string, pathOnly: string) {
const normalizedServer = String(server || '').trim();
const normalizedPath = String(pathOnly || '').trim();
if (!normalizedServer) {
return normalizedPath;
}
try {
const baseUrl = normalizedServer.endsWith('/') ? normalizedServer : `${normalizedServer}/`;
const relativePath = normalizedPath.replace(/^\//, '');
return new URL(relativePath, baseUrl).toString();
} catch {
return normalizedServer + normalizedPath;
}
}
function appendQueryAndCookies(
url: string,
query: URLSearchParams,
cookies: string[],
headers: Record<string, string>
) {
const queryStringValue = query.toString();
if (queryStringValue) {
const separator = url.includes('?') ? '&' : '?';
url += separator + queryStringValue;
}
if (cookies.length > 0) {
headers['Cookie'] = cookies.join('; ');
}
return url;
}
const ODATA_SYSTEM_QUERY_OPTIONS = new Set([
'$filter',
'$select',
'$expand',
'$orderby',
'$top',
'$skip',
'$count',
'$search',
'$format',
]);
const ODATA_SYSTEM_QUERY_ALIASES: Record<string, string> = {
filter: '$filter',
select: '$select',
expand: '$expand',
orderby: '$orderby',
top: '$top',
skip: '$skip',
count: '$count',
search: '$search',
format: '$format',
};
function resolveODataQueryOptionKey(rawKey: string): string | null {
const key = String(rawKey || '').trim();
if (!key) return null;
const keyLower = key.toLowerCase();
if (ODATA_SYSTEM_QUERY_OPTIONS.has(keyLower)) {
return keyLower;
}
return ODATA_SYSTEM_QUERY_ALIASES[keyLower] || null;
}
function normalizeODataQueryOptionValue(optionKey: string, value: any): string | null {
if (!hasValue(value)) return null;
if (Array.isArray(value)) {
const items = value.filter(item => hasValue(item)).map(item => String(item).trim()).filter(Boolean);
if (items.length === 0) return null;
return items.join(',');
}
if (optionKey === '$count') {
if (typeof value === 'boolean') return value ? 'true' : 'false';
const lowered = String(value).trim().toLowerCase();
if (lowered === 'true' || lowered === 'false') return lowered;
return null;
}
if (optionKey === '$top' || optionKey === '$skip') {
const numeric = Number(value);
if (Number.isFinite(numeric) && numeric >= 0) {
return String(Math.trunc(numeric));
}
return null;
}
return String(value).trim();
}
function applyODataSystemQueryOptions(query: URLSearchParams, parameterValues: Record<string, any>) {
for (const [rawKey, rawValue] of Object.entries(parameterValues || {})) {
const optionKey = resolveODataQueryOptionKey(rawKey);
if (!optionKey) continue;
const normalizedValue = normalizeODataQueryOptionValue(optionKey, rawValue);
if (!hasValue(normalizedValue)) continue;
query.set(optionKey, String(normalizedValue));
}
}
export async function executeRestApiEndpointOpenApi(
definition: RestApiDefinition,
endpoint: string,
method: string,
@@ -41,16 +190,15 @@ export async function executeRestApiEndpoint(
auth: RestApiAuthorization | null,
axios: AxiosInstance
): Promise<any> {
const endpointDef = definition.categories
.flatMap(category => category.endpoints)
.find(ep => ep.path === endpoint && ep.method === method);
const endpointDef = findEndpointDefinition(definition, endpoint, method);
if (!endpointDef) {
throw new Error(`Endpoint ${method} ${endpoint} not found in definition.`);
}
let url = server + endpointDef.path;
const { pathOnly, queryString } = splitPathAndQuery(endpointDef.path);
let url = buildRequestUrl(server, pathOnly);
const headers: Record<string, string> = {};
const query = new URLSearchParams();
const query = new URLSearchParams(queryString);
const cookies: string[] = [];
let body: any = undefined;
@@ -88,26 +236,87 @@ export async function executeRestApiEndpoint(
}
}
const queryString = query.toString();
if (queryString) {
const separator = url.includes('?') ? '&' : '?';
url += separator + queryString;
}
if (cookies.length > 0) {
headers['Cookie'] = cookies.join('; ');
}
if (auth) {
if (auth.type === 'basic') {
const basicAuth = Buffer.from(`${auth.user}:${auth.password}`).toString('base64');
headers['Authorization'] = `Basic ${basicAuth}`;
} else if (auth.type === 'bearer') {
headers['Authorization'] = `Bearer ${auth.token}`;
} else if (auth.type === 'apikey') {
headers[auth.header] = auth.value;
}
}
url = appendQueryAndCookies(url, query, cookies, headers);
addAuthHeaders(headers, auth);
const resp = await axios({
method,
url,
headers,
data: body,
});
return resp;
}
export async function executeODataApiEndpoint(
definition: RestApiDefinition,
endpoint: string,
method: string,
parameterValues: Record<string, any>,
server: string,
auth: RestApiAuthorization | null,
axios: AxiosInstance
): Promise<any> {
const endpointDef = findEndpointDefinition(definition, endpoint, method);
if (!endpointDef) {
throw new Error(`Endpoint ${method} ${endpoint} not found in definition.`);
}
const { pathOnly, queryString } = splitPathAndQuery(endpointDef.path);
const metadataPath = pathOnly.replace(/\/+$/, '') === '/$metadata';
let url = buildRequestUrl(server, pathOnly);
const headers: Record<string, string> = {
Accept: 'application/json',
'OData-Version': '4.0',
};
const query = metadataPath ? new URLSearchParams() : new URLSearchParams(queryString);
const cookies: string[] = [];
let body: any = undefined;
for (const param of endpointDef.parameters) {
const value = normalizeValueForRequest(parameterValues[param.name], param);
if (!hasValue(value) && param.in !== 'path') {
continue;
}
if (param.in === 'path') {
url = url.replace(`{${param.name}}`, encodeURIComponent(value));
} else if (param.in === 'query') {
if (metadataPath) continue;
if (Array.isArray(value)) {
for (const item of value) {
query.append(param.name, String(item));
}
} else {
query.append(param.name, String(value));
}
} else if (param.in === 'header') {
headers[param.name] = Array.isArray(value) ? value.map(item => String(item)).join(',') : String(value);
} else if (param.in === 'cookie') {
if (Array.isArray(value)) {
for (const item of value) {
cookies.push(`${encodeURIComponent(param.name)}=${encodeURIComponent(String(item))}`);
}
} else {
cookies.push(`${encodeURIComponent(param.name)}=${encodeURIComponent(String(value))}`);
}
} else if (param.in === 'body') {
body = value;
if (param.contentType && !headers['Content-Type']) {
headers['Content-Type'] = param.contentType;
}
}
}
if (!metadataPath) {
applyODataSystemQueryOptions(query, parameterValues);
}
url = appendQueryAndCookies(url, query, cookies, headers);
addAuthHeaders(headers, auth);
const resp = await axios({
method,
@@ -36,7 +36,7 @@
}
function buildDrivers(plugins) {
const res = isProApp() ? [openApiDriver, graphQlDriver] : [];
const res = isProApp() ? [openApiDriver, oDataDriver, graphQlDriver] : [];
for (const { content } of plugins) {
if (content.drivers) res.push(...content.drivers);
}
@@ -68,7 +68,7 @@
import * as dataLib from 'dbgate-datalib';
import { apiCall } from '../utility/api';
import { isProApp } from '../utility/proTools';
import { openApiDriver, graphQlDriver } from 'dbgate-rest';
import { openApiDriver, oDataDriver, graphQlDriver } from 'dbgate-rest';
let pluginsDict = {};
const installedPlugins = useInstalledPlugins();