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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [4.0.1] 2026-05-27

### Added
- **Attachment API** support with new service accessor `attachmentAPI` on `CoreAPIClient`.
- `downloadContent(attachmentId)` - Download binary attachment content
- `checkExists(attachmentId)` - Check if attachment content exists (HTTP HEAD)
- `deleteContent(attachmentId)` - Delete attachment content

### Changed

- Adjusted permission for GITHUB_TOKEN
Expand Down
14 changes: 13 additions & 1 deletion src/core-api.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { TranslationApiService } from './core/translations/translations-api.serv
import { ServiceManagementAPIService } from './core/service-management/service-management.service';
import { DataServiceAPIService } from './core/data-service/data-service.service';
import { RulesAPIService } from './core/rules/rules-api.service';
import { AttachmentsAPIService } from './core/attachments/attachment-api.service';


export class CoreAPIClient {
Expand All @@ -18,6 +19,7 @@ export class CoreAPIClient {
private _accountApi: AccountAPIService;
private _translationApi: TranslationApiService;
private _rulesApi: RulesAPIService;
private _attachmentApi: AttachmentsAPIService;

private _dataServiceAPI: DataServiceAPIService

Expand Down Expand Up @@ -90,6 +92,7 @@ export class CoreAPIClient {
this._dataServiceAPI = new DataServiceAPIService(this._config, _http, this._auth);
this._translationApi = new TranslationApiService(this._config, _http, this._auth);
this._rulesApi = new RulesAPIService(this._config, _http, this._auth);
this._attachmentApi = new AttachmentsAPIService(this._config, _http, this._auth);
this._serviceManagementApi = new ServiceManagementAPIService(this._config, _http, this._auth);
}

Expand Down Expand Up @@ -142,13 +145,22 @@ export class CoreAPIClient {

/**
* Provides access to the Rules API for managing business rules.
*
*
* @returns {RulesAPIService} Rules API instance for business rule operations.
*/
public get rulesAPI(): RulesAPIService {
return this._rulesApi;
}

/**
* Provides access to the Attachment Download API for downloading, checking, and deleting attachment content.
*
* @returns {AttachmentsAPIService} Attachment download API instance.
*/
public get attachmentAPI(): AttachmentsAPIService {
return this._attachmentApi;
}

/**
* Executes a login using the current client configuration and retrieves an OAuth token.
*
Expand Down
44 changes: 44 additions & 0 deletions src/core/attachments/attachment-api.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { ClientConfig } from '../client-config.model';
import { HttpService } from '../http/http-service';
import { OAuthService } from '../oauth/oauth.service';
import { RequestOptionsFactory } from '../request-options.factory';

export class AttachmentsAPIService {

constructor(
private _config: Readonly<ClientConfig>,
private _http: Readonly<HttpService>,
private _auth: Readonly<OAuthService>
) { }

// https://api.sap.com/api/cloud_attachment_service/resource/Attachment_Download_Service
private getApiUrl(attachmentId: string): string {
return `${this._config.baseUrl}/cloud-attachment-service/api/v1/Attachment/${attachmentId}/content`;
}

public async downloadContent(attachmentId: string): Promise<string | null> {
const token = await this._auth.ensureToken(this._config);
const { Accept: _, ...headers } = RequestOptionsFactory.getRequestHeaders(token, this._config) as { [key: string]: string; }
return this._http.request<string>(this.getApiUrl(attachmentId), {
method: 'GET',
headers: headers
});
}

public async checkExists(attachmentId: string): Promise<string | null> {
const token = await this._auth.ensureToken(this._config);
return this._http.request<string>(this.getApiUrl(attachmentId), {
method: 'HEAD',
headers: RequestOptionsFactory.getRequestHeaders(token, this._config)
});
}

public async deleteContent(attachmentId: string): Promise<string | null> {
const token = await this._auth.ensureToken(this._config);
return this._http.request<''>(this.getApiUrl(attachmentId), {
method: 'DELETE',
headers: RequestOptionsFactory.getRequestHeaders(token, this._config)
});
}

}
2 changes: 1 addition & 1 deletion src/core/http/error-response.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,6 @@ export type HttpRequestOptions = {
| NodeJS.ReadableStream
| string
| any;
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD',
headers: { [key: string]: string }
}
18 changes: 16 additions & 2 deletions src/core/http/http-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ export class HttpService {

return fetch(uri, options)
.then(async (response: HttpResponse) => {

const contentType = response.headers.get('content-type');
const isJson = contentType && contentType.includes('application/json');

Expand All @@ -32,8 +31,15 @@ export class HttpService {
: Promise.resolve(null)
);

return { response, content };
})
.then(async ({ response, content }) => {



if (!response.ok && [304, 302].indexOf(response.status || -1) === -1) {
throw <ErrorResponse<any, HttpRequestOptions>>{

const error = <ErrorResponse<any, HttpRequestOptions>>{
uri: uri,
statusCode: response.status,
message: response.statusText,
Expand All @@ -47,13 +53,21 @@ export class HttpService {
}
}
};

throw error;
}

if (this._config.debug) {
this._logger.log(`[httpRequest] incoming going options[${JSON.stringify(options, null, 2)}] response[${JSON.stringify(content, null, 2)}]`);
}

return content as T;
})
.catch(error => {
if (this._config.debug && error) {
this._logger.log(`[httpRequest] ERROR on ${uri} [${error instanceof Error ? error : JSON.stringify(error, null, 2)}]`);
}
throw error;
});
}
}
82 changes: 82 additions & 0 deletions src/test/e2e/attachments/attachment-api.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import assert from 'assert';
import { Buffer } from 'buffer';
import { ClientConfigBuilder } from '../../integration-test.config';
import { ClientConfig } from '../../../core/client-config.model';
import { CoreAPIClient } from '../../../core-api.client';
import ServiceCallTreeFixture from '../service-management/service-call-tree.fixture.json';

describe('AttachmentsAPI', () => {

const config = { ...ClientConfigBuilder.getConfig('password'), tokenCacheFilePath: undefined, debug: false } as ClientConfig;
const client = new CoreAPIClient(config);

const setup = async (): Promise<{ attachmentId: string, cleanup: () => Promise<void> }> => {
const [{ businessPartner }] = await client
.dataServiceAPI
.query(`select businessPartner FROM BusinessPartner businessPartner LIMIT 1`, ['BusinessPartner'])
.then(r => r.data);

const serviceCall = await client.serviceManagementAPI.composite.tree.postServiceCall({
...ServiceCallTreeFixture,
id: CoreAPIClient.createUUID(),
subject: `test-attachment-setup-${Date.now()}`,
businessPartner: { id: businessPartner?.id || '' },
}, { autoCreateActivity: true });

const activityId = serviceCall.activities![0].id!;

const id = CoreAPIClient.createUUID({ legacyFormat: true });
const [{ attachment }] = await client.dataServiceAPI.post('Attachment', {
id,
fileName: `test-attachment-${Date.now()}.txt`,
title: `Test Attachment ${Date.now()}`,
fileContent: Buffer.from(`This is a test attachment created at ${new Date().toISOString()}`).toString('base64'),
object: {
objectType: 'ACTIVITY',
objectId: activityId,

}
}).then(r => r.data);

const cleanup = async () => {
try {
await client.dataServiceAPI.deleteById('Attachment', attachment as any);
} catch (e) {
// ignore
}
};

return { attachmentId: attachment.id!, cleanup };
};

it('HEAD - checkExists', async () => {
const { attachmentId, cleanup } = await setup();
try {
const result = await client.attachmentAPI.checkExists(attachmentId);
assert(result !== undefined, 'should return a result');
} finally {
await cleanup();
}
}).timeout(ClientConfigBuilder.getTestTimeout());

it('GET - downloadContent', async () => {
const { attachmentId, cleanup } = await setup();
try {
const result = await client.attachmentAPI.downloadContent(attachmentId);
assert(result !== undefined, 'should return content');
} finally {
await cleanup();
}
}).timeout(ClientConfigBuilder.getTestTimeout());

xit('DELETE - deleteContent', async () => {
const { attachmentId, cleanup } = await setup();
try {
const result = await client.attachmentAPI.deleteContent(attachmentId);
assert(result === '' || result === null, 'should return empty on delete');
} finally {
await cleanup();
}
}).timeout(ClientConfigBuilder.getTestTimeout());

});
Loading