From d9800459089c59bee5b023d3e6aad7dac1d0e703 Mon Sep 17 00:00:00 2001 From: Simon Gausmann Date: Thu, 28 May 2026 17:24:23 +0200 Subject: [PATCH 1/2] Add Attachments API service and integrate with CoreAPIClient --- src/core-api.client.ts | 14 +++- .../attachments/attachment-api.service.ts | 44 ++++++++++ src/core/http/error-response.model.ts | 2 +- src/core/http/http-service.ts | 18 +++- .../e2e/attachments/attachment-api.spec.ts | 82 +++++++++++++++++++ 5 files changed, 156 insertions(+), 4 deletions(-) create mode 100644 src/core/attachments/attachment-api.service.ts create mode 100644 src/test/e2e/attachments/attachment-api.spec.ts diff --git a/src/core-api.client.ts b/src/core-api.client.ts index 675b286..c1aca33 100644 --- a/src/core-api.client.ts +++ b/src/core-api.client.ts @@ -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 { @@ -18,6 +19,7 @@ export class CoreAPIClient { private _accountApi: AccountAPIService; private _translationApi: TranslationApiService; private _rulesApi: RulesAPIService; + private _attachmentApi: AttachmentsAPIService; private _dataServiceAPI: DataServiceAPIService @@ -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); } @@ -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. * diff --git a/src/core/attachments/attachment-api.service.ts b/src/core/attachments/attachment-api.service.ts new file mode 100644 index 0000000..cfffbdc --- /dev/null +++ b/src/core/attachments/attachment-api.service.ts @@ -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, + private _http: Readonly, + private _auth: Readonly + ) { } + + // 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 { + const token = await this._auth.ensureToken(this._config); + const { Accept: _, ...headers } = RequestOptionsFactory.getRequestHeaders(token, this._config) as { [key: string]: string; } + return this._http.request(this.getApiUrl(attachmentId), { + method: 'GET', + headers: headers + }); + } + + public async checkExists(attachmentId: string): Promise { + const token = await this._auth.ensureToken(this._config); + return this._http.request(this.getApiUrl(attachmentId), { + method: 'HEAD', + headers: RequestOptionsFactory.getRequestHeaders(token, this._config) + }); + } + + public async deleteContent(attachmentId: string): Promise { + const token = await this._auth.ensureToken(this._config); + return this._http.request<''>(this.getApiUrl(attachmentId), { + method: 'DELETE', + headers: RequestOptionsFactory.getRequestHeaders(token, this._config) + }); + } + +} diff --git a/src/core/http/error-response.model.ts b/src/core/http/error-response.model.ts index c9bdae6..fe81a46 100644 --- a/src/core/http/error-response.model.ts +++ b/src/core/http/error-response.model.ts @@ -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 } } \ No newline at end of file diff --git a/src/core/http/http-service.ts b/src/core/http/http-service.ts index 788b86e..f634a10 100644 --- a/src/core/http/http-service.ts +++ b/src/core/http/http-service.ts @@ -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'); @@ -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 >{ + + const error = >{ uri: uri, statusCode: response.status, message: response.statusText, @@ -47,6 +53,8 @@ export class HttpService { } } }; + + throw error; } if (this._config.debug) { @@ -54,6 +62,12 @@ export class HttpService { } 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; }); } } \ No newline at end of file diff --git a/src/test/e2e/attachments/attachment-api.spec.ts b/src/test/e2e/attachments/attachment-api.spec.ts new file mode 100644 index 0000000..9353d2a --- /dev/null +++ b/src/test/e2e/attachments/attachment-api.spec.ts @@ -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 }> => { + 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()); + +}); From 4c8fe6e8c177ed34b485a27b9fc12695cd3a9633 Mon Sep 17 00:00:00 2001 From: Simon Gausmann Date: Thu, 28 May 2026 17:26:14 +0200 Subject: [PATCH 2/2] Update CHANGELOG.md to include new Attachment API features --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab9be4d..cea9bb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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