Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
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
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ All notable changes to this project will be documented in this file.
This project adheres to [Semantic Versioning](http://semver.org/).
The format is based on [Keep a Changelog](http://keepachangelog.com/).

## Version 4.0.0 - TBD
## Version 4.0.0 - 2026-08-03

**BREAKING CHANGE: The attachments plugin comes now without hyperscaler dependencies, please make sure to install them accordingly!**
**BREAKING CHANGE: Projects that explicitly set `attachments.outbox: true` in their own CDS configuration must rename the key to `outboxed`.**

### Changed

- The `outbox` configuration key under `cds.requires.attachments` has been renamed to `outboxed`. A deprecation warning is logged at startup when the old key is detected.
- Cloud storage SDKs (`@aws-sdk/client-s3`, `@aws-sdk/lib-storage`, `@azure/storage-blob`, `@google-cloud/storage`) are now optional peer dependencies. Install only the SDK(s) for the provider you use (e.g. `npm install @aws-sdk/client-s3 @aws-sdk/lib-storage` for AWS S3). A clear error message with the exact install command is shown if a required SDK is missing at runtime.

### Fixed
Expand Down
15 changes: 2 additions & 13 deletions lib/generic-handlers.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,7 @@
const cds = require("@sap/cds")
const LOG = cds.log("attachments")
const { extname } = require("path")
const {
MAX_FILE_SIZE,
sizeInBytes,
checkMimeTypeMatch,
inferTargetCAP8,
} = require("./helper")
const { MAX_FILE_SIZE, sizeInBytes, checkMimeTypeMatch } = require("./helper")
const { getMime } = require("./mime")

/**
Expand Down Expand Up @@ -40,13 +35,7 @@ async function finalizePrepareAttachment(data, req, prefix) {
// Only try to populate parent keys if there is a parent reference
if (parentRef && parentRef.length > 0) {
let target
if (cds.infer?.target) {
// CAP 9+: Use cds.infer.target
target = cds.infer.target({ SELECT: { from: { ref: parentRef } } })
} else {
// CAP 8 fallback: Use inferTargetCAP8 helper
target = inferTargetCAP8(req, parentRef)
}
target = cds.infer.target({ SELECT: { from: { ref: parentRef } } })

if (target?.keys) {
LOG.info(`Populating parent keys for attachment upload`, target)
Expand Down
35 changes: 0 additions & 35 deletions lib/helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -484,40 +484,6 @@ async function computeHash(input) {
return hash.digest("hex")
}

/**
* Resolves the target entity definition from a subject ref in CAP 8 (fallback for CAP 9+ cds.infer).
* Walks the ref path through the model and returns the draft entity if draft-enabled.
* @param {import('@sap/cds').Request} req - The current request (unused, kept for API symmetry with cds.infer)
* @param {Array<string|{id: string}>} ref - The subject ref array from req.subject
* @returns {import('@sap/cds').entity|null} The resolved entity definition, or null if not found
*/
function inferTargetCAP8(req, ref) {
const model = cds.context?.model || cds.model

// Extract entity/navigation names from ref array
// Handle both simple strings and objects with .id property
const names = ref
.map((part) => {
if (typeof part === "string") return part
if (typeof part === "object" && part.id) return part.id
return null
})
.filter(Boolean)

const name = names.join(".")

let target = model.definitions[name]
if (!target) return null

// draft fallback
if (target["@odata.draft.enabled"]) {
const draft = model.definitions[`${name}.drafts`]
if (draft) target = draft
}

return target
}

const multipliers = {}
multipliers.B = 1
multipliers.KB = multipliers.B * 1024
Expand Down Expand Up @@ -952,7 +918,6 @@ module.exports = {
traverseEntity,
buildBackAssocChain,
MAX_FILE_SIZE,
inferTargetCAP8,
getAttachmentKind,
handleDuplicates,
createSizeCheckHandler,
Expand Down
22 changes: 9 additions & 13 deletions lib/plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,11 @@ const {
validateAttachmentMimeType,
validateAndInsertAttachmentFromDBHandler,
} = require("./generic-handlers")
const {
inferTargetCAP8,
getAttachmentKind,
handleDuplicates,
} = require("./helper")
const { getAttachmentKind, handleDuplicates } = require("./helper")
require("./csn-runtime-extension")
const LOG = cds.log("attachments")

cds.on(
cds.version.split(".")[0] >= 8 ? "compile.to.edmx" : "loaded",
unfoldModel,
)
cds.on("compile.to.edmx", unfoldModel)

// Register the db handler ONCE (not per-service) to intercept attachment INSERT
// and handle it through the attachments service instead of native DB insert
Expand All @@ -27,6 +20,11 @@ cds.on(
// NOTE: Must use db.prepend() to ensure handlers run before existing ones
cds.once("served", () => {
if (!cds.env.requires.attachments) return
if (cds.env.requires.attachments?.outbox !== undefined) {
LOG.warn(
"`cds.requires.attachments.outbox` is deprecated; use `outboxed` instead.",
)
}
const { db } = cds.services

db.prepend(() => {
Expand Down Expand Up @@ -853,8 +851,7 @@ cds.ApplicationService.handle_attachments = cds.service.impl(async function () {
if (req.query?.INSERT?.into.ref.length > 1) {
const ref = req.query.INSERT.into.ref.slice(0, -1)
const parentQuery = { SELECT: { from: { ref: ref }, one: true } }
const parent =
cds.infer?.target?.(parentQuery) || inferTargetCAP8(req, ref)
const parent = cds.infer.target(parentQuery)
if (!parent) {
LOG.warn(
`Could not determine parent target. Ref: ${JSON.stringify(ref)}`,
Expand Down Expand Up @@ -935,8 +932,7 @@ cds.ApplicationService.handle_attachments = cds.service.impl(async function () {
if (req.query?.DELETE?.from?.ref.length > 1) {
const ref = req.query.DELETE.from.ref.slice(0, -1)
const parentQuery = { SELECT: { from: { ref: ref }, one: true } }
const parent =
cds.infer?.target?.(parentQuery) || inferTargetCAP8(req, ref)
const parent = cds.infer.target(parentQuery)
if (!parent) {
LOG.warn(
`Could not determine parent target. Ref: ${JSON.stringify(ref)}`,
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
"@aws-sdk/lib-storage": "^3",
"@azure/storage-blob": "^12",
"@google-cloud/storage": "^7",
"@sap/cds": ">=8"
"@sap/cds": ">=9"
Comment thread
eric-pSAP marked this conversation as resolved.
},
"peerDependenciesMeta": {
"@aws-sdk/client-s3": {
Expand Down Expand Up @@ -101,7 +101,7 @@
}
},
"attachments": {
"outbox": true,
"outboxed": true,
"scan": true,
"deduplicateFileNames": true,
"scanExpiryMs": 259200000,
Expand Down
13 changes: 2 additions & 11 deletions srv/attachments/gcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -414,17 +414,8 @@ module.exports = class GoogleAttachmentsService extends (
)

const file = bucket.file(blobName)
let response
try {
response = await file.delete()
} catch (error) {
if (error.statusCode === 404) {
response = error
} else {
throw error
}
}
if (response?.[0]?.statusCode !== 204) {
const [response] = await file.delete({ ignoreNotFound: true })
if (response?.statusCode !== 204) {
LOG.warn("File has not been deleted from Google Cloud Storage", {
blobName,
bucketName: bucket.name,
Expand Down
12 changes: 4 additions & 8 deletions tests/integration/attachments-non-draft.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
const cds = require("@sap/cds")
const { test } = cds.test()
const {
waitForScanStatus,
newIncident,
Expand All @@ -10,10 +9,7 @@ const {
const path = require("path")

const app = path.resolve(__dirname, "../incidents-app")
const { GET, POST, PATCH, DELETE, PUT } = withUser(
"alice",
require("@cap-js/cds-test")(app),
)
const { GET, POST, PATCH, DELETE, PUT } = withUser("alice", cds.test(app))
const { join } = cds.utils.path
const { createReadStream, readFileSync, statSync } = cds.utils.fs

Expand All @@ -32,7 +28,7 @@ describe("Tests for uploading/deleting and fetching attachments through API call
originalDeduplicateFileNames
})

let log = test.log()
let log = cds.test.log()
const { createAttachmentMetadata, uploadAttachmentContent } = createHelpers()

// Allow background operations (malware scan status updates) to complete before teardown
Expand Down Expand Up @@ -1059,7 +1055,7 @@ describe("Testing max and min amounts of attachments", () => {
})
})

it("custom error message can be specified targeting composition property", async () => {
it("Custom error message can be specified targeting composition property", async () => {
await POST(`odata/v4/validation-test-non-draft/Incidents`, {
customer_ID: "1004155",
title: "ABC",
Expand All @@ -1081,7 +1077,7 @@ describe("Testing max and min amounts of attachments", () => {
})
})

it("custom error message can be specified for entity", async () => {
it("Custom error message can be specified for entity", async () => {
await POST(`odata/v4/validation-test-non-draft/Incidents`, {
customer_ID: "1004155",
title: "ABC",
Expand Down