Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/roadmap/epics/e12-rap-srvb.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions packages/adt-cli/src/lib/commands/import/object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ export const importObjectCommand = new Command('object')
(value: string, previous: string[]) => [...previous, value],
[],
)
.option(
'-t, --object-type <type>',
'Exact ABAP object type for same-named objects, e.g. DDLX',
)
.option('--debug', 'Enable debug output', false)
.action(async (objectName, targetFolder, options) => {
try {
Expand Down Expand Up @@ -57,6 +61,7 @@ export const importObjectCommand = new Command('object')

const result = await importService.importObject({
objectName,
objectType: options.objectType,
outputPath,
format: options.format,
formatOptions,
Expand Down
131 changes: 131 additions & 0 deletions packages/adt-cli/src/lib/services/import/object-selection.ts
Original file line number Diff line number Diff line change
@@ -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,
});
}
43 changes: 6 additions & 37 deletions packages/adt-cli/src/lib/services/import/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
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;
Expand Down Expand Up @@ -49,6 +50,8 @@
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') */
Expand Down Expand Up @@ -691,74 +694,40 @@
query: options.objectName,
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<string, unknown>;
if (options.debug) {
console.log(
`🔎 Raw search result keys: ${Object.keys(resultsAny).join(', ')}`,
);
}
let rawObjects: SearchObject | SearchObject[] | undefined;
if ('objectReference' in resultsAny && resultsAny.objectReference) {
rawObjects = resultsAny.objectReference as SearchObject | SearchObject[];
} else if (
'objectReferences' in resultsAny &&
resultsAny.objectReferences
) {
const refs = resultsAny.objectReferences as {
objectReference?: SearchObject | SearchObject[];
};
rawObjects = refs.objectReference;
} else if ('mainObject' in resultsAny && resultsAny.mainObject) {
const main = resultsAny.mainObject as {
objectReference?: SearchObject | SearchObject[];
};
rawObjects = main.objectReference;
}
const objects: SearchObject[] = rawObjects
? Array.isArray(rawObjects)
? rawObjects
: [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);

Check notice on line 730 in packages/adt-cli/src/lib/services/import/service.ts

View check run for this annotation

CodeScene Access / CodeScene Code Health Review (main)

✅ Getting better: Complex Method

ImportService.importObject decreases in cyclomatic complexity from 28 to 23, threshold = 9 This function has many conditional statements (e.g. if, for, while), leading to lower code health. Avoid adding more conditionals and code to it without refactoring.

// Extract base type (e.g. "DOMA/DD" → "DOMA")
const fullType = String(exactMatch.type || '');
Expand Down
100 changes: 100 additions & 0 deletions packages/adt-cli/tests/services/import/object.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
9 changes: 5 additions & 4 deletions packages/adt-contracts/src/adt/businessservices/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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',
Comment thread
ThePlenkov marked this conversation as resolved.
accept: 'application/vnd.sap.adt.businessservices.servicebinding.v2+xml',
nameTransform,
});

/**
Expand Down
4 changes: 2 additions & 2 deletions packages/adt-contracts/src/adt/ddic/ddlx/sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ export type DdlxSourceResponse = InferTypedSchema<typeof ddlxSourceSchema>;
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,
});
4 changes: 2 additions & 2 deletions packages/adt-contracts/src/adt/ddic/dteb/sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ export type DtebSourceResponse = InferTypedSchema<typeof dtebSourceSchema>;
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,
});
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading