- name
- document-management-sdm
- description
- Invoke this skill to migrate from Neo ECM Service to SAP Document Management Service. Detects com.sap.ecm.api.EcmService resource-ref in web.xml or EcmService JNDI lookups. Uses OpenCMIS client library for CMIS protocol.
- disable-model-invocation
- false
- allowed-tools
- Read, Edit, Write, Bash, Grep, Glob
# Document Management Service
Migrate from Neo's ECM Service to SAP Document Management Service (SDM).
## Purpose
Replace Neo's `com.sap.ecm.api.EcmService` JNDI resource with SAP Document Management Service using the CMIS protocol via OpenCMIS client library.
## Detection
This skill applies if any of these patterns are found:
### In web.xml
```xml
<resource-ref>
<res-ref-name>EcmService</res-ref-name>
<res-type>com.sap.ecm.api.EcmService</res-type>
</resource-ref>
```
### In Java source files
```java
import com.sap.ecm.api.EcmService;
import com.sap.ecm.api.RepositoryOptions;
@Resource(name = "EcmService")
private EcmService ecmService;
// OR JNDI lookup
EcmService ecmService = (EcmService) ctx.lookup("java:comp/env/EcmService");
```
## Prerequisites
> **Working directory:** This skill must run inside the `-cf-migration` copy of your app, created by `jakarta-java25-migration` or `neo-to-cf-migration-orchestrator`. If your current directory does not end in `-cf-migration`, switch to it before proceeding.
Before invoking this skill, ensure you have invoked:
1. **sdk-replacement** - `Use the sdk-replacement skill`
- Sets up SAP Cloud SDK
- REQUIRED before this skill
Also required:
- SDM service entitlement in subaccount
## Transformation Steps
### Step 1: Remove Resource Reference from web.xml
**Remove this from web.xml:**
```xml
<resource-ref>
<res-ref-name>EcmService</res-ref-name>
<res-type>com.sap.ecm.api.EcmService</res-type>
</resource-ref>
```
### Step 2: Add OpenCMIS Dependencies
> **Prerequisite — `sdk-modules-bom` and `scp-cf` must already be present from `sdk-replacement`.** The `ServiceBindingAccessor` asset (Step 3) calls `DefaultServiceBindingAccessor.getInstance()` from `com.sap.cloud.environment.servicebinding.api.*`, which is supplied by `scp-cf` and version-managed by `sdk-modules-bom` at `provided` scope (the buildpack ships its own — newer — implementation at runtime). Both are added by the `sdk-replacement` prerequisite skill; this skill does **not** add them. If they are missing, rerun `sdk-replacement` rather than patching them in by hand.
>
> **Do NOT** add `com.sap.cloud.environment.servicebinding:*` as a direct `compile`-scope dependency. Bundling a different version of those classes inside the WAR causes a runtime `ServiceConfigurationError: SapVcapServicesServiceBindingAccessor not a subtype` because the buildpack-provided interface and the WAR-bundled implementation are loaded by different classloaders.
Add to `pom.xml`:
```xml
<dependencies>
<!-- OpenCMIS Client -->
<dependency>
<groupId>org.apache.chemistry.opencmis</groupId>
<artifactId>chemistry-opencmis-client-impl</artifactId>
<version>1.1.0</version>
</dependency>
<dependency>
<groupId>org.apache.chemistry.opencmis</groupId>
<artifactId>chemistry-opencmis-client-api</artifactId>
<version>1.1.0</version>
</dependency>
<!-- HTTP Client for REST calls -->
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
</dependency>
<!-- JSON processing -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
</dependencies>
```
> **What about `scp-cf`?** The `ServiceBindingAccessor` asset depends on
> `com.sap.cloud.sdk.cloudplatform:scp-cf` (which transitively brings the
> `com.sap.cloud.environment.servicebinding.api.*` classes used by
> `DefaultServiceBindingAccessor.getInstance()`). That dependency — along with
> `sdk-modules-bom` — is the declared responsibility of the **`sdk-replacement`**
> skill, which is a prerequisite of this one. Do **not** add `scp-cf` again
> here. If your `pom.xml` is missing it, that means the `sdk-replacement` step
> didn't complete; rerun it instead of patching the dependency in by hand.
> **Important:** OpenCMIS libraries have NOT been migrated to Jakarta EE. They still use `javax.*` namespaces. This is acceptable as they are self-contained.
### Step 3: Create Service Binding Accessor
Copy [assets/ServiceBindingAccessor.java](assets/ServiceBindingAccessor.java) to your project:
Key features:
- Reads SDM service binding from VCAP_SERVICES
- Retrieves OAuth2 token using client credentials
- Caches token with automatic refresh
### Step 4: Create Document Service Client
Copy these two files to your project (both declare `package com.example.document;`):
- [assets/DocumentServiceClient.java](assets/DocumentServiceClient.java) — main client
- [assets/RepositoryAlreadyExistsException.java](assets/RepositoryAlreadyExistsException.java) — checked exception thrown when `createRepository` is called for an existing repo
Key features:
- Creates CMIS sessions for repositories
- Repository management — create, delete, list, check existence
- OAuth2 authentication integration
- Pre-checks existence before POSTing a create, because SDM is idempotent at the HTTP layer (see Common Issues below) — the alternative is silently double-creating, which the Neo `EcmService` never did.
> **Identifier rule — the two endpoints take DIFFERENT identifiers, do not collapse them:**
>
> - **CMIS browser binding** (`SessionParameter.REPOSITORY_ID`) uses the repository **name**.
> Verify by hitting `GET {ecmServiceUrl}/browser` — the response is a map keyed by names.
> The REST GET response also has a `cmisRepositoryId` field, but that is the *root folder
> ID*, not the CMIS repository id; using it produces `Repository 'xxx' not found!` from
> OpenCMIS.
>
> - **REST DELETE** (`DELETE /rest/v2/repositories/{X}`) uses the SDM internal **UUID** —
> the `id` field returned by `GET /rest/v2/repositories/`, e.g. `f3023e03-81da-4be4-...`.
> Passing the name produces HTTP 500 with body
> `{"message":"Repository with id:<name> is invalid. Please enter a valid repository ID."}`.
>
> The asset has **two** lookup methods to keep this distinction explicit:
> `getRepositoryId(name)` → returns the name (for CMIS) and `getRepositoryUuid(name)` →
> returns the UUID (for DELETE). Do not "simplify" them into one.
If the Neo source called `ecmService.deleteRepository(name, ...)` or
`ecmService.forceDeleteRepository(name, ...)`, route it to
`documentClient.deleteRepository(name)`. The Neo `EcmService` distinguishes empty vs.
non-empty deletion; SDM's REST API does not — use one method on the migrated side and let
SDM return the appropriate error if the repo is non-empty.
### Step 5: Update Application Code
**Before (Neo):**
```java
import com.sap.ecm.api.EcmService;
import com.sap.ecm.api.RepositoryOptions;
import org.apache.chemistry.opencmis.client.api.Session;
@Resource(name = "EcmService")
private EcmService ecmService;
public void handleDocument() {
// Create repository
RepositoryOptions options = new RepositoryOptions();
options.setUniqueName("my-repo");
options.setRepositoryKey("secret-key");
String repoId = ecmService.createRepository(options);
// Get CMIS session
Session session = ecmService.connect(repoId, "secret-key");
// Work with documents...
}
```
**After (Cloud Foundry):**
> **Note on the package**: the assets declare `package com.example.document;` as a placeholder.
> Rename to your project's package (e.g. `com.acme.docs`) when you copy them in, and update the
> imports below to match.
>
> **Note on servlet imports**: this is the Jakarta migration target — use `jakarta.servlet.*`,
> not `javax.servlet.*`. The Neo originals will be `javax.*`; the `jakarta-java25-migration`
> skill should have already rewritten them.
```java
import com.example.document.DocumentServiceClient;
import com.example.document.RepositoryAlreadyExistsException;
import org.apache.chemistry.opencmis.client.api.Session;
import org.apache.chemistry.opencmis.client.api.Folder;
import org.apache.chemistry.opencmis.client.api.Document;
import org.apache.chemistry.opencmis.client.api.CmisObject;
import org.apache.chemistry.opencmis.commons.data.ContentStream;
import org.apache.chemistry.opencmis.commons.enums.VersioningState;
import org.apache.chemistry.opencmis.commons.exceptions.CmisObjectNotFoundException;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
public class DocumentServlet extends HttpServlet {
private final DocumentServiceClient documentClient = new DocumentServiceClient();
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
String repoName = "my-repo";
// Create repository if not exists. createRepository will also throw
// RepositoryAlreadyExistsException on its own pre-check; the explicit
// repositoryExists() guard here just avoids the throw on the happy path.
if (!documentClient.repositoryExists(repoName)) {
try {
documentClient.createRepository(repoName);
} catch (RepositoryAlreadyExistsException e) {
// Race: someone else created it between our check and call.
// For the typical "ensure exists" flow this is fine — proceed.
}
}
// Get CMIS session
Session session = documentClient.getSession(repoName);
// Get root folder
Folder rootFolder = session.getRootFolder();
// List documents
response.setContentType("application/json");
response.getWriter().println("{\"documents\": [");
boolean first = true;
for (CmisObject obj : rootFolder.getChildren()) {
if (!first) response.getWriter().println(",");
response.getWriter().printf("{\"name\": \"%s\", \"type\": \"%s\"}",
obj.getName(), obj.getType().getId());
first = false;
}
response.getWriter().println("]}");
}
// Upload document example
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
Session session = documentClient.getSession("my-repo");
Folder rootFolder = session.getRootFolder();
// Create document
Map<String, Object> properties = new HashMap<>();
properties.put("cmis:objectTypeId", "cmis:document");
properties.put("cmis:name", "example.txt");
byte[] content = "Hello, World!".getBytes(StandardCharsets.UTF_8);
ContentStream contentStream = session.getObjectFactory()
.createContentStream("example.txt", content.length, "text/plain",
new ByteArrayInputStream(content));
Document doc = rootFolder.createDocument(properties, contentStream,
VersioningState.MAJOR);
response.getWriter().println("Created document: " + doc.getId());
}
// Delete repository example — replaces ecmService.deleteRepository(name, ...)
// and ecmService.forceDeleteRepository(name, ...) from the Neo API.
// The asset's deleteRepository() resolves the SDM internal UUID for you;
// you pass the NAME at the API boundary.
protected void doDelete(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
String repoName = request.getParameter("uniqueName");
try {
documentClient.deleteRepository(repoName);
response.setStatus(HttpServletResponse.SC_OK);
} catch (CmisObjectNotFoundException e) {
response.sendError(HttpServletResponse.SC_NOT_FOUND, e.getMessage());
}
}
// Create-repository handler — mirrors what Neo's ecmService.createRepository did,
// including the "already exists" branch. The Neo EcmService threw on duplicate;
// SDM does NOT (POST /rest/v2/repositories returns 201 even on duplicate). The
// asset closes that gap by pre-checking and throwing RepositoryAlreadyExistsException,
// which we map here to HTTP 412 — matching the contract of the original Neo app and
// any integration test that expected PRECONDITION_FAILED on duplicate creation.
protected void doPostCreateRepository(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
String repoName = request.getParameter("uniqueName");
try {
documentClient.createRepository(repoName);
response.setStatus(HttpServletResponse.SC_CREATED);
} catch (RepositoryAlreadyExistsException e) {
response.sendError(HttpServletResponse.SC_PRECONDITION_FAILED, e.getMessage());
}
}
}
```
### Step 6: Update MTA Descriptor
Bind the SDM service to your application in `mtad.yaml`. **Only add what this skill owns** —
the application module's `parameters:`/`properties:` block is set up by the
`tomee-runtime` / `jakarta-java25-migration` skills; do not overwrite it here.
Add the `requires:` entry to your existing app module:
```yaml
modules:
- name: ${app-name}
# ... existing parameters/properties ...
requires:
- name: ${app-name}-sdm
```
And add the resource:
```yaml
resources:
- name: ${app-name}-sdm
type: org.cloudfoundry.managed-service
parameters:
service: sdm
service-plan: standard
```
## Configuration Files
No additional configuration files required. Service binding is read at runtime.
## CF Services
| Service | Plan | Purpose |
|---------|------|---------|
| `sdm` | standard | Document Management Service |
## Verification
### 1. Compile Check
```bash
mvn clean compile
```
### 2. Deploy and Test
```bash
mvn clean package
cf deploy . -f
# Test document operations
curl "https://${app-url}/documents"
```
### 3. Check Service Binding
```bash
cf env ${app-name} | grep -A 30 "sdm"
```
## Common Issues
### Issue: `ServiceConfigurationError: SapVcapServicesServiceBindingAccessor not a subtype` at runtime
**Cause:** The WAR ships its own copy of `com.sap.cloud.environment.servicebinding.*` (typically because a direct compile-scope dependency on `java-sap-service-operator`, `java-sap-vcap-services`, or the `java-modules-bom` was added). The buildpack already provides those classes, so two versions end up in different classloaders and `ServiceLoader` rejects the in-WAR provider as "not a subtype" of the buildpack-provided interface. The error first surfaces as HTTP 500 on every SDM call, with the stacktrace bottoming out in `DefaultServiceBindingAccessor.<clinit>`.
Voir sur GitHub