diff --git a/docs/roadmap/epics/e12-rap-srvb.md b/docs/roadmap/epics/e12-rap-srvb.md index e2c867231..7564ac83e 100644 --- a/docs/roadmap/epics/e12-rap-srvb.md +++ b/docs/roadmap/epics/e12-rap-srvb.md @@ -82,7 +82,7 @@ Commit and push verified changes to the scoped feature branch without a separate `/sap/bc/adt/businessservices/bindings` (GET/POST/PUT/DELETE + lock/unlock + `publish` / `unpublish` on `/publishedstates`). Content-Type - `application/vnd.sap.adt.businessservices.servicebinding.v1+xml`. + `application/vnd.sap.adt.businessservices.servicebinding.v2+xml`. Built on the `crud()` helper with the publish/unpublish endpoints spliced on top. SRVB is metadata-only — no source text endpoints. - Schema: `sap/servicebinding.xsd` added to the adt-schemas target diff --git a/packages/adt-cli/src/lib/commands/import/object.ts b/packages/adt-cli/src/lib/commands/import/object.ts index 70150f663..136d62c07 100644 --- a/packages/adt-cli/src/lib/commands/import/object.ts +++ b/packages/adt-cli/src/lib/commands/import/object.ts @@ -28,6 +28,10 @@ export const importObjectCommand = new Command('object') (value: string, previous: string[]) => [...previous, value], [], ) + .option( + '-t, --object-type ', + 'Exact ABAP object type for same-named objects, e.g. DDLX', + ) .option('--debug', 'Enable debug output', false) .action(async (objectName, targetFolder, options) => { try { @@ -57,6 +61,7 @@ export const importObjectCommand = new Command('object') const result = await importService.importObject({ objectName, + objectType: options.objectType, outputPath, format: options.format, formatOptions, diff --git a/packages/adt-cli/src/lib/services/import/object-selection.ts b/packages/adt-cli/src/lib/services/import/object-selection.ts new file mode 100644 index 000000000..920396211 --- /dev/null +++ b/packages/adt-cli/src/lib/services/import/object-selection.ts @@ -0,0 +1,131 @@ +export type SearchObject = { + name?: string; + type?: string; + uri?: string; + description?: string; + packageName?: string; +}; + +export type ObjectSearchOptions = { + objectName: string; + objectType?: string; +}; + +type SearchObjectResolutionContext = { + objects: SearchObject[]; + options: ObjectSearchOptions; + exactMatches: SearchObject[]; + availableExactTypes: string[]; + requestedObjectType: string | undefined; +}; + +function normalizeObjectType(type: string): string { + return type.toUpperCase().split('/')[0] ?? ''; +} + +function getExactSearchMatches( + objects: SearchObject[], + objectName: string, +): SearchObject[] { + const normalizedName = objectName.toUpperCase(); + return objects.filter( + (obj) => String(obj.name || '').toUpperCase() === normalizedName, + ); +} + +function getSearchObjectTypes(objects: SearchObject[]): string[] { + return [ + ...new Set( + objects.map((obj) => normalizeObjectType(String(obj.type || ''))), + ), + ].filter(Boolean); +} + +function findExactSearchMatch( + exactMatches: SearchObject[], + requestedObjectType: string | undefined, + availableExactTypes: string[], +): SearchObject | undefined { + if (requestedObjectType) { + return exactMatches.find( + (obj) => + normalizeObjectType(String(obj.type || '')) === requestedObjectType, + ); + } + + return availableExactTypes.length <= 1 ? exactMatches[0] : undefined; +} + +function createObjectNotFoundError( + objects: SearchObject[], + objectName: string, +): Error { + const similar = objects + .filter((obj) => + String(obj.name || '') + .toUpperCase() + .includes(objectName.toUpperCase()), + ) + .slice(0, 5); + const similarList = similar + .map((obj) => ` • ${obj.name} (${obj.type}) – ${obj.packageName}`) + .join('\n'); + const hint = + similar.length > 0 ? `\nšŸ’” Similar objects:\n${similarList}` : ''; + + return new Error(`Object '${objectName}' not found in the system.${hint}`); +} + +function createSearchObjectResolutionError( + context: SearchObjectResolutionContext, +): Error { + const { + objects, + options, + exactMatches, + availableExactTypes, + requestedObjectType, + } = context; + + if (requestedObjectType && exactMatches.length > 0) { + return new Error( + `Object '${options.objectName}' with type '${requestedObjectType}' was not found. Available types: ${availableExactTypes.join(', ') || 'none'}.`, + ); + } + + if (!requestedObjectType && availableExactTypes.length > 1) { + return new Error( + `Object '${options.objectName}' is ambiguous. Use --object-type to select one of: ${availableExactTypes.join(', ')}.`, + ); + } + + return createObjectNotFoundError(objects, options.objectName); +} + +export function selectSearchObject( + objects: SearchObject[], + options: ObjectSearchOptions, +): SearchObject { + const exactMatches = getExactSearchMatches(objects, options.objectName); + const availableExactTypes = getSearchObjectTypes(exactMatches); + const requestedObjectType = options.objectType + ? normalizeObjectType(options.objectType.trim()) + : undefined; + const exactMatch = findExactSearchMatch( + exactMatches, + requestedObjectType, + availableExactTypes, + ); + + if (exactMatch) { + return exactMatch; + } + + throw createSearchObjectResolutionError({ + objects, + options, + exactMatches, + availableExactTypes, + requestedObjectType, + }); +} diff --git a/packages/adt-cli/src/lib/services/import/service.ts b/packages/adt-cli/src/lib/services/import/service.ts index 2f15569a0..356dde4ac 100644 --- a/packages/adt-cli/src/lib/services/import/service.ts +++ b/packages/adt-cli/src/lib/services/import/service.ts @@ -14,6 +14,7 @@ import type { AdkTransportObjectRef } from '@abapify/adk'; import { Readable } from 'node:stream'; import { mkdir, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; +import { selectSearchObject, type SearchObject } from './object-selection'; /** Default number of concurrent SAP requests during import */ const IMPORT_CONCURRENCY = 5; @@ -49,6 +50,8 @@ async function resolvePackagePath(packageName: string): Promise { export interface ObjectImportOptions { /** Object name to search for (e.g., 'ZAGE_DOMA_CASE_SENSITIVE') */ objectName: string; + /** Exact ABAP object type used to disambiguate same-named objects */ + objectType?: string; /** Output directory for serialized files */ outputPath: string; /** Format plugin name or package (e.g., 'abapgit', '@abapify/adt-plugin-abapgit') */ @@ -692,14 +695,6 @@ export class ImportService { maxResults: 10, }); - type SearchObject = { - name?: string; - type?: string; - uri?: string; - description?: string; - packageName?: string; - }; - // Handle different response shapes from quickSearch const resultsAny = searchResult as Record; if (options.debug) { @@ -730,35 +725,9 @@ export class ImportService { : [rawObjects] : []; - // Step 2: Find exact match (case-insensitive) - const exactMatch = objects.find( - (obj: SearchObject) => - String(obj.name || '').toUpperCase() === - options.objectName.toUpperCase(), - ); - - if (!exactMatch) { - // Show similar objects as a hint - const similar = objects - .filter((obj: SearchObject) => - String(obj.name || '') - .toUpperCase() - .includes(options.objectName.toUpperCase()), - ) - .slice(0, 5); - - const similarList = similar - .map( - (o: SearchObject) => ` • ${o.name} (${o.type}) – ${o.packageName}`, - ) - .join('\n'); - const hint = - similar.length > 0 ? `\nšŸ’” Similar objects:\n${similarList}` : ''; - - throw new Error( - `Object '${options.objectName}' not found in the system.${hint}`, - ); - } + // Step 2: Resolve the exact match. Object names are not globally unique + // across ABAP object types, so a caller can select the intended type. + const exactMatch = selectSearchObject(objects, options); // Extract base type (e.g. "DOMA/DD" → "DOMA") const fullType = String(exactMatch.type || ''); diff --git a/packages/adt-cli/tests/services/import/object.test.ts b/packages/adt-cli/tests/services/import/object.test.ts new file mode 100644 index 000000000..8530015eb --- /dev/null +++ b/packages/adt-cli/tests/services/import/object.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const factoryGet = vi.fn(); +const formatImport = vi.fn(); + +vi.mock('@abapify/adk', () => ({ + AdkPackage: { + get: vi.fn(async () => ({ superPackage: undefined })), + }, + AdkTransport: { get: vi.fn() }, + MergedTransportView: class {}, + matchesSelector: vi.fn(), + createAdkFactory: vi.fn(() => ({ get: factoryGet })), + getGlobalContext: vi.fn(() => ({ + client: { + adt: { + repository: { + informationsystem: { + search: { + quickSearch: vi.fn(async () => ({ + objectReference: [ + { + name: 'Z_SHARED_NAME', + type: 'BDEF/BDO', + packageName: 'ZPKG', + }, + { + name: 'Z_SHARED_NAME', + type: 'DDLX', + packageName: 'ZPKG', + }, + ], + })), + }, + }, + }, + }, + }, + })), +})); + +vi.mock('../../../src/lib/utils/format-loader', () => ({ + loadFormatPlugin: vi.fn(async () => ({ + name: 'abapGit', + description: 'abapGit format plugin', + instance: { + registry: { isSupported: vi.fn(() => true) }, + format: { import: formatImport }, + hooks: {}, + }, + })), + parseFormatSpec: vi.fn(() => ({ package: '@abapify/adt-plugin-abapgit' })), +})); + +vi.mock('../../../src/lib/utils/destinations', () => ({ + getConfig: vi.fn(async () => ({ raw: {} })), +})); + +describe('ImportService.importObject()', () => { + beforeEach(() => { + factoryGet.mockReset(); + formatImport.mockReset(); + factoryGet.mockImplementation((_name, type) => ({ + type, + load: vi.fn(async () => undefined), + })); + formatImport.mockResolvedValue({ success: true, filesCreated: [] }); + }); + + it('selects the requested type when the same name belongs to multiple object types', async () => { + const { ImportService } = + await import('../../../src/lib/services/import/service'); + + const result = await new ImportService().importObject({ + objectName: 'Z_SHARED_NAME', + objectType: 'DDLX', + outputPath: '/tmp/import-object', + format: 'abapgit', + }); + + expect(factoryGet).toHaveBeenCalledWith('Z_SHARED_NAME', 'DDLX'); + expect(result.objectType).toBe('DDLX'); + }); + + it('requires an explicit type when the same name belongs to multiple object types', async () => { + const { ImportService } = + await import('../../../src/lib/services/import/service'); + + await expect( + new ImportService().importObject({ + objectName: 'Z_SHARED_NAME', + outputPath: '/tmp/import-object', + format: 'abapgit', + }), + ).rejects.toThrow( + "Object 'Z_SHARED_NAME' is ambiguous. Use --object-type to select one of: BDEF, DDLX.", + ); + expect(factoryGet).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/adt-contracts/src/adt/businessservices/bindings.ts b/packages/adt-contracts/src/adt/businessservices/bindings.ts index 24a2759ce..b48032732 100644 --- a/packages/adt-contracts/src/adt/businessservices/bindings.ts +++ b/packages/adt-contracts/src/adt/businessservices/bindings.ts @@ -2,7 +2,7 @@ * ADT RAP Service Binding (SRVB) Contract * * Endpoint: /sap/bc/adt/businessservices/bindings - * Content-Type: application/vnd.sap.adt.businessservices.servicebinding.v1+xml + * Content-Type: application/vnd.sap.adt.businessservices.servicebinding.v2+xml * Object type: SRVB/SVB * * RAP Service Binding — binds a Service Definition (SRVD) to a runtime @@ -30,13 +30,14 @@ export type ServiceBindingResponse = InferTypedSchema< >; const basePath = '/sap/bc/adt/businessservices/bindings'; -const nameTransform = (n: string) => n.toLowerCase(); +const nameTransform = (n: string) => encodeURIComponent(n.toLowerCase()); const baseContract = crud({ basePath, schema: servicebindingSchema, - contentType: 'application/vnd.sap.adt.businessservices.servicebinding.v1+xml', - accept: 'application/vnd.sap.adt.businessservices.servicebinding.v1+xml', + contentType: 'application/vnd.sap.adt.businessservices.servicebinding.v2+xml', + accept: 'application/vnd.sap.adt.businessservices.servicebinding.v2+xml', + nameTransform, }); /** diff --git a/packages/adt-contracts/src/adt/ddic/ddlx/sources.ts b/packages/adt-contracts/src/adt/ddic/ddlx/sources.ts index b50c0da8e..f25372446 100644 --- a/packages/adt-contracts/src/adt/ddic/ddlx/sources.ts +++ b/packages/adt-contracts/src/adt/ddic/ddlx/sources.ts @@ -8,8 +8,8 @@ export type DdlxSourceResponse = InferTypedSchema; export const ddlxSourcesContract = crud({ basePath: '/sap/bc/adt/ddic/ddlx/sources', schema: ddlxSourceSchema, - contentType: 'application/vnd.sap.adt.ddlxSource+xml', - accept: 'application/vnd.sap.adt.ddlxSource+xml', + contentType: 'application/vnd.sap.adt.ddic.ddlx.v1+xml', + accept: 'application/vnd.sap.adt.ddic.ddlx.v1+xml', nameTransform: (name) => encodeURIComponent(name.toLowerCase()), sources: ['main'] as const, }); diff --git a/packages/adt-contracts/src/adt/ddic/dteb/sources.ts b/packages/adt-contracts/src/adt/ddic/dteb/sources.ts index b75e7c17b..008176c30 100644 --- a/packages/adt-contracts/src/adt/ddic/dteb/sources.ts +++ b/packages/adt-contracts/src/adt/ddic/dteb/sources.ts @@ -9,8 +9,8 @@ export type DtebSourceResponse = InferTypedSchema; export const dtebSourcesContract = crud({ basePath: '/sap/bc/adt/ddic/dteb/sources', schema: dtebSourceSchema, - contentType: 'application/vnd.sap.adt.dtebSource+xml', - accept: 'application/vnd.sap.adt.dtebSource+xml', + contentType: 'application/vnd.sap.adt.ddic.dteb.v1+xml', + accept: 'application/vnd.sap.adt.ddic.dteb.v1+xml', nameTransform: (name) => encodeURIComponent(name.toLowerCase()), sources: ['main'] as const, }); diff --git a/packages/adt-contracts/tests/contracts/cds-rap-sources.test.ts b/packages/adt-contracts/tests/contracts/cds-rap-sources.test.ts index 5b3ae6dca..2d1b460ca 100644 --- a/packages/adt-contracts/tests/contracts/cds-rap-sources.test.ts +++ b/packages/adt-contracts/tests/contracts/cds-rap-sources.test.ts @@ -16,7 +16,7 @@ class CdsRapSourcesScenario extends ContractScenario { contract: () => ddlxSourcesContract.get('Z_AFF_DDLX'), method: 'GET', path: '/sap/bc/adt/ddic/ddlx/sources/z_aff_ddlx', - headers: { Accept: 'application/vnd.sap.adt.ddlxSource+xml' }, + headers: { Accept: 'application/vnd.sap.adt.ddic.ddlx.v1+xml' }, response: { status: 200, schema: ddlxSource, @@ -52,7 +52,7 @@ class CdsRapSourcesScenario extends ContractScenario { contract: () => dtebSourcesContract.get('Z_AFF_DTEB'), method: 'GET', path: '/sap/bc/adt/ddic/dteb/sources/z_aff_dteb', - headers: { Accept: 'application/vnd.sap.adt.dtebSource+xml' }, + headers: { Accept: 'application/vnd.sap.adt.ddic.dteb.v1+xml' }, response: { status: 200, schema: dtebSource, diff --git a/packages/adt-contracts/tests/contracts/srvb.test.ts b/packages/adt-contracts/tests/contracts/srvb.test.ts index cdd6d54d4..f75188e91 100644 --- a/packages/adt-contracts/tests/contracts/srvb.test.ts +++ b/packages/adt-contracts/tests/contracts/srvb.test.ts @@ -2,7 +2,7 @@ * Service Binding (SRVB) contract scenarios * * Endpoint: /sap/bc/adt/businessservices/bindings - * Content-Type: application/vnd.sap.adt.businessservices.servicebinding.v1+xml + * Content-Type: application/vnd.sap.adt.businessservices.servicebinding.v2+xml * * SRVB is metadata-only — unlike BDEF/SRVD there is no source text. * The contract adds publish/unpublish to the base CRUD surface. @@ -24,7 +24,22 @@ class SrvbScenario extends ContractScenario { path: '/sap/bc/adt/businessservices/bindings/zui_mock_srvb', headers: { Accept: - 'application/vnd.sap.adt.businessservices.servicebinding.v1+xml', + 'application/vnd.sap.adt.businessservices.servicebinding.v2+xml', + }, + response: { + status: 200, + schema: servicebinding, + fixture: fixtures.businessservices.bindings.single, + }, + }, + { + name: 'get namespaced SRVB metadata', + contract: () => bindingsContract.get('/ACME/SRV_BINDING'), + method: 'GET', + path: '/sap/bc/adt/businessservices/bindings/%2Facme%2Fsrv_binding', + headers: { + Accept: + 'application/vnd.sap.adt.businessservices.servicebinding.v2+xml', }, response: { status: 200, @@ -39,7 +54,7 @@ class SrvbScenario extends ContractScenario { path: '/sap/bc/adt/businessservices/bindings', headers: { 'Content-Type': - 'application/vnd.sap.adt.businessservices.servicebinding.v1+xml', + 'application/vnd.sap.adt.businessservices.servicebinding.v2+xml', }, body: { schema: servicebinding }, response: { status: 200, schema: servicebinding }, diff --git a/packages/adt-fixtures/src/mock-server/routes.ts b/packages/adt-fixtures/src/mock-server/routes.ts index 4dc2199bf..ced388618 100644 --- a/packages/adt-fixtures/src/mock-server/routes.ts +++ b/packages/adt-fixtures/src/mock-server/routes.ts @@ -1153,7 +1153,7 @@ export function matchRoute( status: 200, body: f.srvbSingle, contentType: - 'application/vnd.sap.adt.businessservices.servicebinding.v1+xml', + 'application/vnd.sap.adt.businessservices.servicebinding.v2+xml', }; } if ( diff --git a/packages/adt-plugin-abapgit/tests/format-materialization.test.ts b/packages/adt-plugin-abapgit/tests/format-materialization.test.ts index 321e65930..96eb4d10e 100644 --- a/packages/adt-plugin-abapgit/tests/format-materialization.test.ts +++ b/packages/adt-plugin-abapgit/tests/format-materialization.test.ts @@ -4,6 +4,94 @@ import assert from 'node:assert/strict'; import { abapgitFormatPlugin } from '../src/lib/format-plugin.ts'; describe('abapGit format materialization', () => { + it('materializes every supported CDS and RAP source type in its abapGit layout', async () => { + const sourceTypes = [ + ['BDEF', 'abdl'], + ['DCLS', 'acds'], + ['DDLS', 'acds'], + ['DDLX', 'acds'], + ['DRAS', 'acds'], + ['DRTY', 'acds'], + ['DSFD', 'acds'], + ['DTDC', 'acds'], + ['DTEB', 'acds'], + ['DTIX', 'acds'], + ['DTSC', 'acds'], + ['SRVD', 'acds'], + ] as const; + + for (const [objectType, sourceExtension] of sourceTypes) { + const objectName = `Z_FIXTURE_${objectType}`; + const source = `define ${objectType.toLowerCase()} ${objectName}.`; + const result = await abapgitFormatPlugin.materialize!({ + object: { + name: objectName, + description: `${objectType} fixture`, + originalLanguage: 'EN', + }, + objectType, + packagePath: ['ZROOT'], + sources: { main: source }, + }); + + const suffix = objectType.toLowerCase(); + assert.deepStrictEqual( + result.files.map((file) => file.path), + [ + `src/z_fixture_${suffix}.${suffix}.${sourceExtension}`, + `src/z_fixture_${suffix}.${suffix}.json`, + ], + ); + assert.strictEqual(result.files[0]?.content, source); + } + }); + + it('materializes every supported CDS and RAP metadata-only type in its abapGit layout', async () => { + const desd = await abapgitFormatPlugin.materialize!({ + object: { + name: 'Z_FIXTURE_DESD', + description: 'DESD fixture', + originalLanguage: 'EN', + }, + objectType: 'DESD', + packagePath: ['ZROOT'], + }); + assert.deepStrictEqual( + desd.files.map((file) => file.path), + ['src/z_fixture_desd.desd.json'], + ); + + const dsfiDefinition = { + formatVersion: '1' as const, + header: { description: 'DSFI fixture', originalLanguage: 'en' }, + scalarFunctionName: 'Z_FIXTURE_DSFI', + engine: 'analyticalEngine' as const, + }; + const dsfi = await abapgitFormatPlugin.materialize!({ + object: { + name: 'Z_FIXTURE_DSFI', + getSource: async () => dsfiDefinition, + }, + objectType: 'DSFI', + packagePath: ['ZROOT'], + }); + assert.deepStrictEqual( + dsfi.files.map((file) => file.path), + ['src/z_fixture_dsfi.dsfi.json'], + ); + assert.deepStrictEqual(JSON.parse(dsfi.files[0]!.content), dsfiDefinition); + + const srvb = await abapgitFormatPlugin.materialize!({ + object: { name: 'Z_FIXTURE_SRVB' }, + objectType: 'SRVB', + packagePath: ['ZROOT'], + }); + assert.deepStrictEqual( + srvb.files.map((file) => file.path), + ['src/z_fixture_srvb.srvb.xml'], + ); + }); + it('materializes an explicit historical interface source without reading ADT', async () => { const object = { name: 'ZIF_FLOW_EXAMPLE', diff --git a/website/docs/cli/import.md b/website/docs/cli/import.md index 1e642b1b1..66309f220 100644 --- a/website/docs/cli/import.md +++ b/website/docs/cli/import.md @@ -27,6 +27,7 @@ support. | `` | ABAP object name to import (e.g. `ZAGE_DOMA_CASE_SENSITIVE`). | | `[targetFolder]` | Target folder for output. | | `-o, --output ` | Output directory (overrides `targetFolder`). | +| `-t, --object-type ` | Exact ABAP object type for same-named objects, e.g. `DDLX`. | | `--format ` | Output format: `abapgit` \| `@abapify/adt-plugin-abapgit`. Default: `abapgit`. | | `--format-option ` | Format-specific option (repeatable), e.g. `--format-option folderLogic=full`. | | `--debug` | Enable debug output. | @@ -63,6 +64,9 @@ support. # Single class, abapGit adt import object ZCL_DEMO ./src +# Disambiguate same-named objects across ABAP object types +adt import object Z_SHARED ./src --object-type DDLX + # Package with type filter and subpackages excluded adt import package $ZDEMO ./repo \ --object-types CLAS,INTF,DDLS --no-sub-packages