SYNC: Merge pull request #54 from dbgate/feature/odata-api
This commit is contained in:
Vendored
+3
-1
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -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,6 +1,6 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
moduleFileExtensions: ['js'],
|
||||
moduleFileExtensions: ['ts', 'js'],
|
||||
reporters: ['default', 'github-actions'],
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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`,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,161 @@
|
||||
import type { ODataMetadataDocument, ODataMetadataEntitySet, ODataMetadataEntityType, ODataMetadataNavigationProperty } from './oDataAdapter';
|
||||
|
||||
function decodeXmlEntities(value: string): string {
|
||||
return String(value ?? '')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/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,
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user