Bind Object Store, upload binary files, generate pre-signed download URLs, and stream large file uploads.
@sap/objectstore-clientCAP's built-in @Core.MediaType support writes binary data directly into the database — fine for small attachments but a serious problem at scale. SAP Object Store Service provides S3-compatible blob storage on BTP (backed by AWS S3, Azure Blob, or GCS depending on your landscape). Storing files there keeps your HANA footprint small, enables direct browser uploads without routing through your service, and gives you CDN-friendly URLs.
# Create an Object Store instance (s3-standard plan on AWS landscapes)
cf create-service objectstore s3-standard my-objectstore
# Bind to your app
cf bind-service my-cap-app my-objectstore
# Or add to MTA descriptor
resources:
- name: my-objectstore
type: org.cloudfoundry.managed-service
parameters:
service: objectstore
service-plan: s3-standard
Credentials structure after binding:
{
"bucket": "my-bucket-abc123",
"region": "eu-central-1",
"access_key_id": "AKI...",
"secret_access_key": "...",
"host": "s3.amazonaws.com",
"uri": "s3://my-bucket-abc123"
}
npm install @sap/objectstore-client
The library abstracts over S3, Azure Blob, and GCS using the same API.
// db/schema.cds
entity Documents {
key ID : UUID;
name : String(255);
mimeType : String(100);
storageKey : String(500); // object key in Object Store
sizeBytes : Integer64;
uploadedAt : Timestamp;
uploadedBy : String(100);
}
service DocumentService @(path:'/documents') {
entity Documents as projection on db.Documents
excluding { storageKey }; // never expose storage key to clients
action upload(name: String, mimeType: String, content: LargeBinary): UUID;
function download(documentId: UUID): LargeBinary;
action deleteDoc(documentId: UUID): Boolean;
}
// srv/document-service.js
const cds = require('@sap/cds');
const OSClient = require('@sap/objectstore-client');
const { v4: uuid } = require('uuid');
let osClient;
async function getOSClient() {
if (osClient) return osClient;
const vcapServices = JSON.parse(process.env.VCAP_SERVICES || '{}');
const creds = vcapServices.objectstore?.[0]?.credentials;
if (!creds) throw new Error('Object Store not bound');
osClient = new OSClient(creds);
return osClient;
}
module.exports = class DocumentService extends cds.ApplicationService {
async init() {
this.on('upload', async req => {
const { name, mimeType, content } = req.data;
const os = await getOSClient();
// Generate a unique storage key (folder/uuid/filename)
const storageKey = `documents/${uuid()}/${name}`;
// Upload the binary content
await os.upload({
objectName: storageKey,
data: Buffer.isBuffer(content) ? content : Buffer.from(content, 'base64'),
mimeType
});
// Persist metadata in DB
const db = await cds.connect.to('db');
const doc = await db.insert(this.entities.Documents).entries({
ID: uuid(),
name,
mimeType,
storageKey,
sizeBytes: content.length,
uploadedAt: new Date(),
uploadedBy: req.user.id
});
return doc.ID;
});
this.on('download', async req => {
const { documentId } = req.data;
const db = await cds.connect.to('db');
const doc = await db.read(this.entities.Documents).where({ ID: documentId });
if (!doc) return req.reject(404, 'Document not found');
const os = await getOSClient();
const data = await os.download({ objectName: doc.storageKey });
// Return as base64 for OData binary response
return data.toString('base64');
});
this.on('deleteDoc', async req => {
const { documentId } = req.data;
const db = await cds.connect.to('db');
const doc = await db.read(this.entities.Documents).where({ ID: documentId });
if (!doc) return req.reject(404, 'Document not found');
const os = await getOSClient();
await os.delete({ objectName: doc.storageKey });
await db.delete(this.entities.Documents).where({ ID: documentId });
return true;
});
await super.init();
}
};
For large files, avoid routing the binary through your CAP service. Instead, generate a pre-signed URL that the browser can download directly from Object Store:
this.on('getDownloadUrl', async req => {
const { documentId } = req.data;
const db = await cds.connect.to('db');
const doc = await db.read(this.entities.Documents).where({ ID: documentId });
const os = await getOSClient();
// Pre-signed URL valid for 15 minutes
const url = await os.getSignedUrl({
objectName: doc.storageKey,
expiresIn: 900 // seconds
});
return url;
});
The client calls GET /documents/getDownloadUrl(documentId='...') and receives a temporary URL — no large binary traffic through your service tier.
For files > a few MB, avoid loading the whole file into memory:
// Use Node.js stream API
const stream = require('stream');
this.on('uploadStream', async req => {
const os = await getOSClient();
const input = req._.req; // raw IncomingMessage (HTTP stream)
const storageKey = `documents/${uuid()}/upload`;
await os.uploadStream({ objectName: storageKey, stream: input });
// ...persist metadata
});
Wire this to a dedicated @Core.MediaType endpoint or a custom route registered in server.js.
| Mistake | Fix |
|---|---|
| Storing the storage key in the OData response | Exclude storageKey from the projection — return only the document ID |
| Loading entire file into memory for large uploads | Use streaming upload |
| Not deleting from Object Store on entity delete | Always implement a deleteDoc handler that calls os.delete() |
| Hardcoding bucket name | Read from VCAP_SERVICES credentials |
| Not scoping access | Add @requires on upload/download/delete actions |
You can bind SAP Object Store, upload binary attachments with metadata stored in HANA, generate pre-signed URLs for direct downloads, and delete objects in sync with entity deletes. You know why you exclude the storage key from OData projections and how streaming avoids memory pressure on large files.
Sign in for progress tracking & Q&A →