64 · SAP Object Store Service

Bind Object Store, upload binary files, generate pre-signed download URLs, and stream large file uploads.

Episode 64 — SAP Object Store Service

What you'll learn

Why this matters

CAP'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.

Service plans and binding

# 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"
}

Installing the client library

npm install @sap/objectstore-client

The library abstracts over S3, Azure Blob, and GCS using the same API.

CDS model for attachments

// 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;
}

Uploading files

// 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();
  }
};

Generating pre-signed download URLs

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.

Handling multipart / streaming uploads

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.

Common mistakes

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

Checkpoint ✓

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 →