diff --git a/CLAUDE.md b/CLAUDE.md index 41a18f1f4e00..41a64596af6f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,11 +1,15 @@ -# dotCMS Development Guide +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Project Structure ``` core/ ├── dotCMS/ # Main backend Java code -│ └── src/main/java/com/ # Java source files +│ └── src/main/java/com/ +│ ├── dotcms/ # Modern domain-driven packages (prefer these) +│ └── dotmarketing/ # Legacy packages (15+ yr old code, still active) ├── core-web/ # Frontend (Angular/Nx monorepo) → see core-web/CLAUDE.md ├── dotcms-integration/ # Integration tests ├── dotcms-postman/ # Postman API tests @@ -14,13 +18,21 @@ core/ └── .github/workflows/ # CI/CD pipelines ``` +## Environment Prerequisites + +```bash +sdk env install # Java 21 via SDKMAN (.sdkmanrc) — build fails with wrong version +nvm use # Node 22.15+ via nvm (.nvmrc) — frontend build fails with wrong version +``` + ## Build & Test Commands ```bash # Build (choose based on scope) -./mvnw install -pl :dotcms-core -DskipTests # Simple core changes (~2-3 min) -./mvnw install -pl :dotcms-core --am -DskipTests # Core + dependencies (~3-5 min) -./mvnw clean install -DskipTests # Full rebuild (~8-15 min) +./mvnw install -pl :dotcms-core --am -DskipTests # Core + in-project deps (~2-3 min) ✅ +./mvnw install -pl :dotcms-core -DskipTests # ⚠️ Can fail: missing in-project deps +./mvnw clean install -DskipTests # Full rebuild (~8-15 min) +./mvnw clean install -DskipTests -Ddocker.skip # Full rebuild, skip Docker image # Test (⚠️ NEVER run full integration suite — 60+ min) ./mvnw verify -pl :dotcms-integration -Dcoreit.test.skip=false -Dit.test=MyTestClass # Specific class @@ -32,8 +44,8 @@ just test-integration-ide # Start PostgreSQL + Elasticsearch + dotCMS just test-integration-stop # Stop services when done # Run -just dev-run # Start dotCMS in Docker with Glowroot -cd core-web && nx run dotcms-ui:serve # Frontend dev server only +just dev-run # Start dotCMS in Docker with Glowroot +cd core-web && yarn nx serve dotcms-ui # Frontend dev server only (use yarn nx, not nx) ``` > All test modules need explicit `skip=false` flags or tests are silently skipped. @@ -56,6 +68,14 @@ UserAPI userAPI = APILocator.getUserAPI(); // Service access pattern - **REST @Schema**: Must match actual return type — see [REST API Guide](dotCMS/src/main/java/com/dotcms/rest/CLAUDE.md) - **Frontend**: See [core-web/CLAUDE.md](core-web/CLAUDE.md) for Angular/TypeScript standards +### OpenAPI / Swagger + +`openapi.yaml` is **auto-generated** by `swagger-maven-plugin` at compile phase — it writes directly to `src/main/webapp/WEB-INF/openapi/openapi.yaml`. The CI verifies the committed file matches what the build produces. + +- All description changes must go in Java `@Operation` / `@Parameter` annotations, not in the yaml directly +- Regenerate after annotation changes: `./mvnw compile -pl :dotcms-core -DskipTests` (no Docker needed) +- Commit the regenerated yaml alongside the Java changes + ### Progressive Enhancement When editing ANY code, improve incrementally: diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/MultiPartUtils.java b/dotCMS/src/main/java/com/dotcms/rest/api/MultiPartUtils.java index 8dd5f3f7b27d..00a819c92939 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/MultiPartUtils.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/MultiPartUtils.java @@ -108,7 +108,12 @@ public List getBinariesFromMultipart(final FormDataMultiPart multipart) th final List binaries = new ArrayList<>(); - for (final FormDataBodyPart part : multipart.getFields(FILE)) { + final List fileParts = multipart.getFields(FILE); + if (fileParts == null) { + return binaries; + } + + for (final FormDataBodyPart part : fileParts) { final File tmpFolder = new File( this.fileAssetAPI.getRealAssetPathTmpBinary() + UUIDUtil.uuid()); diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/contenttype/ContentTypeResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/contenttype/ContentTypeResource.java index 042a2dd8c424..a6e3f0d926de 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/contenttype/ContentTypeResource.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/contenttype/ContentTypeResource.java @@ -256,11 +256,13 @@ public final Response copyType(@Context final HttpServletRequest req, "| Property | Type | Description |\n" + "|----------|--------|-------------|\n" + "| `name` | String | **Required.** Name of new content type |\n" + - "| `variable` | String | System variable of new content type |\n" + + "| `variable` | String | Velocity variable name of the new content type |\n" + "| `folder` | String | Folder in which new content type will live |\n" + "| `host` | String | Site or host to which the new content type will belong |\n" + "| `icon` | String | System icon to represent content type |\n\n" + - "Values not specified default to values of the original content type.", + "The copy preserves: `description`, `host`, `folder`, full `fields[]` (with new field IDs), " + + "layout (Row/Column markers), `metadata`, and workflow assignments. " + + "Unspecified values default to those of the original content type.", required = true, content = @Content( schema = @Schema(implementation = CopyContentTypeForm.class), @@ -484,16 +486,42 @@ private ImmutableMap copyContentTypeAndDependencies(final Conten public final Response createType(@Context final HttpServletRequest req, @Context final HttpServletResponse res, @RequestBody( - description = "Payload may consist of a single content type JSON object, or a list " + - "containing multiple content type objects.\n\n" + - "Objects require `clazz` and `name` properties at minimum.\n\n" + - "May optionally include the following special properties:\n\n" + - "| Property | Value | Description |\n" + - "|-|-|-|\n" + - "| `systemActionMappings` | JSON Object | Maps " + - "[Default Workflow Actions](https://www.dotcms.com/docs/latest/managing-workflows#DefaultActions) (as keys) " + - "to workflow action identifiers (as values) for this content type.|\n" + - "| `workflow` | List of Strings | A list of identifiers of workflow schemes to be associated with the content type.", + description = "Accepts either a single content-type object or an array. " + + "The body is the content-type object directly (not wrapped in a 'contentType' envelope).\n\n" + + "**Required properties:**\n" + + "- `clazz` *(string)* — fully-qualified class name. One of: " + + "`com.dotcms.contenttype.model.type.ImmutableSimpleContentType`, " + + "`com.dotcms.contenttype.model.type.ImmutableWidgetContentType`, " + + "`com.dotcms.contenttype.model.type.ImmutableFormContentType`, " + + "`com.dotcms.contenttype.model.type.ImmutableFileAssetContentType`, " + + "`com.dotcms.contenttype.model.type.ImmutablePageContentType`, " + + "`com.dotcms.contenttype.model.type.ImmutablePersonaContentType`, " + + "`com.dotcms.contenttype.model.type.ImmutableVanityUrlContentType`, " + + "`com.dotcms.contenttype.model.type.ImmutableKeyValueContentType`, " + + "`com.dotcms.contenttype.model.type.ImmutableDotAssetContentType`\n" + + "- `name` *(string)* — display name\n\n" + + "**Common optional properties:**\n" + + "- `variable` *(string)* — Velocity variable name (unique, alphanumeric, starts with a letter; auto-generated if omitted)\n" + + "- `host` *(string)* — site identifier UUID or the literal `SYSTEM_HOST` (defaults to the default site)\n" + + "- `folder` *(string)* — folder identifier UUID or the literal `SYSTEM_FOLDER` (defaults to `SYSTEM_FOLDER`)\n" + + "- `description` *(string)*\n" + + "- `workflow` *(array of workflow scheme UUIDs)* — e.g. `[\"d61a59e1-a49c-46f2-a929-db2b4bfa88b2\"]` for System Workflow. " + + "⚠️ **Note:** this is `workflow` (singular) in the request. GET responses return `workflows` (plural, array of objects) — " + + "clients round-tripping an object must rename this key.\n" + + "- `fields` *(array of field objects)* — see field schema below\n" + + "- `metadata` *(object)* — known keys: `CONTENT_EDITOR2_ENABLED` (boolean), `DOT_STYLE_EDITOR_SCHEMA` (JSON string)\n" + + "- `systemActionMappings` *(object)* — maps system actions (`NEW`, `EDIT`, `PUBLISH`, `UNPUBLISH`, `ARCHIVE`, `UNARCHIVE`, `DELETE`, `DESTROY`) to workflow action UUIDs\n\n" + + "**Field object schema** (each item in `fields[]`):\n" + + "- `clazz` *(string, required)* — e.g. `com.dotcms.contenttype.model.field.ImmutableTextField`, `ImmutableTextAreaField`, " + + "`ImmutableStoryBlockField`, `ImmutableBinaryField`, `ImmutableTagField`, `ImmutableRadioField`, `ImmutableSelectField`, " + + "`ImmutableDateField`, `ImmutableDateTimeField`, `ImmutableRowField` *(layout marker)*, `ImmutableColumnField` *(layout marker)*\n" + + "- `name`, `variable`, `dataType` (one of `TEXT`, `LONG_TEXT`, `SYSTEM`, `BOOL`, `INTEGER`, `FLOAT`, `DATE`), " + + "`required`, `indexed`, `listed`, `sortOrder` *(integer, position in the fields array)*\n" + + "- `values` *(string)* — for Radio/Select/Checkbox: newline-separated `Display|value` pairs. " + + "For a boolean field use `ImmutableRadioField` + `dataType: BOOL` + `values: 'True|true\\r\\nFalse|false'` — there is no dedicated Boolean field class.\n\n" + + "**Layout encoding:** Rows and columns are regular field entries placed in `fields[]`. " + + "`ImmutableRowField` begins a new row; `ImmutableColumnField` begins a new column inside that row; " + + "following content fields belong to the most-recent column until the next marker.", required = true, content = @Content( schema = @Schema(implementation = ContentTypeForm.class), @@ -627,10 +655,19 @@ public final Response createType(@Context final HttpServletRequest req, operationId = "putContentTypeUpdate", summary = "Updates a content type", description = "Updates the content type based on the given ID or Velocity variable name.\n\n" + - "Returns a copy of the updated content type object.\n\n" + - "> **Caution:** When updating a content type, any editable fields omitted from the request body " + - "will be removed from the content type. To update selected properties without deleting others," + - "submit the full JSON entity with the desired items edited.", + "⚠️ **Destructive semantics.** This endpoint treats the request body as the full desired state. " + + "Any editable property (including items in `fields[]` and `metadata` keys) absent from the body will be removed.\n\n" + + "**Recommended update pattern:**\n" + + "1. `GET /api/v1/contenttype/id/{idOrVar}` to fetch the current object.\n" + + "2. Mutate the returned object in place. Rename `workflows` (array of objects) → `workflow` (array of UUIDs) before sending.\n" + + "3. PUT the entire mutated object back.\n\n" + + "This is also the only supported way to add, remove, or modify individual fields — " + + "the `/api/v1/contenttype/{typeId}/fields/**` family is deprecated in favor of this full-CT PUT.\n\n" + + "⚠️ **`systemActionMappings` is validated on write.** Each entry's `workflowAction.id` must still exist; " + + "if a mapping points to a deleted workflow action, the entire PUT fails with " + + "`\"The workflow action with the id does not exists\"`. When round-tripping the object, prune " + + "`systemActionMappings` entries you do not intend to update — or omit the `systemActionMappings` property " + + "entirely if no mapping changes are needed.", tags = {"Content Type"}, responses = { @ApiResponse(responseCode = "200", description = "Content type updated successfully", @@ -989,7 +1026,9 @@ private void handleUpdateFieldVariables( operationId = "deleteContentType", summary = "Deletes a content type", description = "Deletes the content type based on the provided ID or Velocity variable name.\n\n" + - "Returns JSON string containing the identifier of the deleted content type.", + "⚠️ **Note:** The `entity` field in the response is a **JSON-encoded string**, not an object. " + + "Typed clients must call `JSON.parse(entity)` before use to get `{ \"deleted\": \"\" }`. " + + "This differs from other endpoints in the same family where `entity` is a direct object.", tags = {"Content Type"}, responses = { @ApiResponse(responseCode = "200", description = "Content type deleted successfully", @@ -1056,7 +1095,13 @@ public Response deleteType(@PathParam("idOrVar") @Parameter( @Operation( operationId = "getContentTypeIdVar", summary = "Retrieves a single content type", - description = "Returns one content type based on the provided ID or Velocity variable name.", + description = "Returns one content type based on the provided ID or Velocity variable name.\n\n" + + "The response includes a `fields[]` array describing every field on the type. " + + "Each field has a `clazz` property (e.g. `ImmutableBinaryField`, `ImmutableImageField`, `ImmutableTextField`) " + + "that determines what values the field accepts. This is particularly important for file-like fields: " + + "`ImmutableBinaryField` only accepts a `temp_` from `POST /api/v1/temp`; " + + "`ImmutableImageField` accepts a `temp_` or a dotAsset `identifier`. " + + "Always inspect `fields[].clazz` before attempting to set binary or image field values via the workflow fire endpoint.", tags = {"Content Type"}, responses = { @ApiResponse(responseCode = "200", description = "Content type retrieved successfully", @@ -1320,7 +1365,9 @@ private Response retrieveContentType(final HttpServletRequest httpRequest, @Operation( operationId = "postContentTypeFilter", summary = "Filters content types", - description = "Returns the list of content type objects that match the specified filter, with optional pagination criteria.", + description = "Returns the list of content type objects that match the specified filter, with optional pagination criteria.\n\n" + + "For simple substring filtering without pagination control, `GET /api/v1/contenttype?filter=` " + + "is equivalent and simpler. Use `_filter` when you need pagination, ordering, and direction together.", tags = {"Content Type"}, responses = { @ApiResponse(responseCode = "200", description = "Content types filtered successfully", diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/page/NavResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/page/NavResource.java index e5cb19f6360a..979fdeb8caf3 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/page/NavResource.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/page/NavResource.java @@ -89,9 +89,10 @@ protected NavResource(final WebResource webResource) { @Operation( operationId = "getNavigationTree", summary = "Get site navigation tree", - description = "Returns navigation metadata in JSON format for objects marked as 'show on menu'. " - + "Builds a tree structure starting from the given URI path, up to the specified depth. " - + "Example: /api/v1/nav/about-us?depth=2&languageId=1 returns the navigation tree under /about-us, 2 levels deep." + description = "Returns the dotCMS site navigation tree starting at the given **folder** URI, up to the specified depth. " + + "Only objects marked as 'show on menu' are included. " + + "The URI must resolve to a folder — page URIs (e.g., '/index', '/about-us/team') will return 404. " + + "Use '/' to fetch the navigation tree for the site root." ) @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Navigation tree retrieved successfully", @@ -107,11 +108,19 @@ protected NavResource(final WebResource webResource) { @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) @Path("/{uri: .*}") public final Response loadJson(@Context final HttpServletRequest request, @Context final HttpServletResponse response, - @Parameter(description = "Starting URI path for the navigation tree (e.g., 'about-us')", required = true) + @Parameter(description = "Starting folder URI for the navigation tree (e.g., '/about-us', '/blog', or '/' for the site root). " + + "Must resolve to a folder — page URIs return 404.", required = true) @PathParam("uri") final String uri, - @Parameter(description = "Number of levels deep to include in the tree (default: 1)") + @Parameter(description = "Total number of levels to include, counting the starting node as level 1. " + + "depth=1 returns only the starting node with no children; depth=2 returns the node plus its direct children. " + + "Values exceeding the actual tree depth return the full subtree. Values less than 1 are treated as 1. (default: 1)", + schema = @Schema(type = "integer", format = "int32")) @QueryParam("depth") final String depth, - @Parameter(description = "Language ID for the navigation content (defaults to the request language)") + @Parameter(description = "Tags each returned node with this language ID. " + + "Note: folder names are language-neutral in dotCMS and are not translated — " + + "this parameter only affects the 'languageId' attribute on each node, not the visible 'title'. " + + "Defaults to the language of the current request.", + schema = @Schema(type = "integer", format = "int64")) @QueryParam("languageId") final String languageId) { final InitDataObject auth = webResource.init(request, response, true); diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/temp/TempFileResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/temp/TempFileResource.java index 190201b6ade4..da3632950de7 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/temp/TempFileResource.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/temp/TempFileResource.java @@ -35,6 +35,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; @@ -98,12 +99,35 @@ public TempFileResource() { description = "Uploads one or more files as temporary resources via multipart form data. " + "Files are stored temporarily and can be referenced when creating content. " + "The response streams back a JSON object with the created temporary file references. " + - "Anonymous access can be allowed via the TEMP_RESOURCE_ALLOW_ANONYMOUS configuration property.", + "Anonymous access can be allowed via the TEMP_RESOURCE_ALLOW_ANONYMOUS configuration property.\n\n" + + "**Use this endpoint to supply files for binary and image fields** (`ImmutableBinaryField`, `ImmutableImageField`) " + + "when creating or updating contentlets. After uploading, pass `tempFiles[0].id` " + + "(e.g. `\"temp_5311313004\"`) as the field value in the workflow fire endpoint. " + + "See `PUT /api/v1/workflow/actions/default/fire/{systemAction}` for the full pattern.", responses = { @ApiResponse(responseCode = "200", description = "Temporary files created successfully", content = @Content(mediaType = "application/json", - schema = @Schema(type = "object", - description = "Streamed JSON object containing a 'tempFiles' array with DotTempFile references for each uploaded file"))), + schema = @Schema(implementation = TempFilesView.class), + examples = @ExampleObject( + value = "{\n" + + " \"tempFiles\": [\n" + + " {\n" + + " \"id\": \"temp_5311313004\",\n" + + " \"fileName\": \"hero.jpg\",\n" + + " \"length\": 84471,\n" + + " \"mimeType\": \"image/jpeg\",\n" + + " \"image\": true,\n" + + " \"referenceUrl\": \"/dA/temp_5311313004/tmp/hero.jpg\",\n" + + " \"metadata\": {\n" + + " \"contentType\": \"image/jpeg\",\n" + + " \"height\": 522,\n" + + " \"width\": 900,\n" + + " \"fileSize\": 84471,\n" + + " \"isImage\": true\n" + + " }\n" + + " }\n" + + " ]\n" + + "}"))), @ApiResponse(responseCode = "400", description = "Invalid file, origin, or referer"), @ApiResponse(responseCode = "401", description = "Authentication required (when anonymous access is disabled)"), @ApiResponse(responseCode = "404", description = "Temp file resource is not enabled") @@ -286,12 +310,35 @@ private void printResponseEntityViewResult(final OutputStream outputStream, description = "Downloads a file from the specified remote URL and stores it as a temporary resource. " + "The temporary file can then be referenced when creating content. " + "The URL must pass validation to prevent SSRF attacks. " + - "Anonymous access can be allowed via the TEMP_RESOURCE_ALLOW_ANONYMOUS configuration property.", + "Anonymous access can be allowed via the TEMP_RESOURCE_ALLOW_ANONYMOUS configuration property.\n\n" + + "**Use this endpoint to supply files for binary and image fields** (`ImmutableBinaryField`, `ImmutableImageField`) " + + "when a file is already accessible by URL. Send `{\"remoteUrl\": \"https://example.com/image.jpg\"}` " + + "and pass `tempFiles[0].id` (e.g. `\"temp_5311313004\"`) as the field value in the workflow fire endpoint. " + + "See `PUT /api/v1/workflow/actions/default/fire/{systemAction}` for the full pattern.", responses = { @ApiResponse(responseCode = "200", description = "Temporary file created from URL successfully", content = @Content(mediaType = "application/json", - schema = @Schema(type = "object", - description = "Map containing a 'tempFiles' array with DotTempFile references for the downloaded file"))), + schema = @Schema(implementation = TempFilesView.class), + examples = @ExampleObject( + value = "{\n" + + " \"tempFiles\": [\n" + + " {\n" + + " \"id\": \"temp_5311313004\",\n" + + " \"fileName\": \"hero.jpg\",\n" + + " \"length\": 84471,\n" + + " \"mimeType\": \"image/jpeg\",\n" + + " \"image\": true,\n" + + " \"referenceUrl\": \"/dA/temp_5311313004/tmp/hero.jpg\",\n" + + " \"metadata\": {\n" + + " \"contentType\": \"image/jpeg\",\n" + + " \"height\": 522,\n" + + " \"width\": 900,\n" + + " \"fileSize\": 84471,\n" + + " \"isImage\": true\n" + + " }\n" + + " }\n" + + " ]\n" + + "}"))), @ApiResponse(responseCode = "400", description = "Invalid URL, missing URL, or invalid origin/referer"), @ApiResponse(responseCode = "401", description = "Authentication required (when anonymous access is disabled)"), @ApiResponse(responseCode = "404", description = "Temp file resource is not enabled") diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/temp/TempFilesView.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/temp/TempFilesView.java new file mode 100644 index 000000000000..a949e13f9993 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/temp/TempFilesView.java @@ -0,0 +1,32 @@ +package com.dotcms.rest.api.v1.temp; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.Collections; +import java.util.List; + +/** + * Immutable schema view for the temp-file endpoints' response body: + * { "tempFiles": [DotTempFile, ...] }. + * Exposes the response shape to OpenAPI clients so generated SDKs and AI agents see a typed + * tempFiles array instead of an opaque Map<String, Object>. + * + * @see TempFileResource + */ +@Schema(description = "Response body for /api/v1/temp and /api/v1/temp/byUrl uploads. " + + "Contains the tempFiles array; use tempFiles[0].id (e.g. \"temp_5311313004\") " + + "as the field value when creating contentlets with binary or image fields.") +public final class TempFilesView { + + private final List tempFiles; + + public TempFilesView(final List tempFiles) { + this.tempFiles = tempFiles == null + ? Collections.emptyList() + : Collections.unmodifiableList(tempFiles); + } + + public List getTempFiles() { + return tempFiles; + } +} diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/workflow/WorkflowResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/workflow/WorkflowResource.java index 6ee14a95b624..54005964f7f6 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/workflow/WorkflowResource.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/workflow/WorkflowResource.java @@ -216,6 +216,21 @@ public class WorkflowResource { private static final String WORKFLOW_SUBMITTER = "workflow_submitter"; public static final String INCLUDE_SEPARATOR = "includeSeparator"; + private static final String INDEX_POLICY_CHAINING_NOTE = + "**When chaining workflow actions or reading state back immediately after firing, " + + "pass `indexPolicy=WAIT_FOR` on each call.** " + + "The default `DEFER` is asynchronous and can return stale index reads for several seconds, " + + "which can mimic server-side state bugs. For isolated one-off fires where nothing reads the result, " + + "leave the default."; + + private static final String BULK_FIRE_CONTRACT_NOTES = + "⚠️ **Important contract notes:**\n\n" + + "- `contentletIds` despite its name expects **inodes**, not identifiers. Passing identifiers " + + "results in a silent no-op with no error.\n" + + "- `additionalParams` must be present, even when empty (`{}`). Omitting it returns a `500` NPE.\n" + + "- The endpoint is **not step-aware**: if the action is not valid in a contentlet's current step, " + + "the input is dropped silently (no `fails[]` entry). Verify post-fire state via `POST /api/content/_search`."; + private final WorkflowHelper workflowHelper; private final ContentHelper contentHelper; @@ -592,7 +607,13 @@ public final Response findStepsByScheme(@Context final HttpServletRequest reques @Produces(MediaType.APPLICATION_JSON) @Operation(operationId = "getWorkflowActionsByContentletInode", summary = "Finds workflow actions by content inode", description = "Returns a list of [workflow actions](https://www.dotcms.com/docs/latest/managing-workflows#Actions) " + - "associated with a [contentlet](https://www.dotcms.com/docs/latest/content#Contentlets) specified by inode.", + "associated with a [contentlet](https://www.dotcms.com/docs/latest/content#Contentlets) specified by inode.\n\n" + + "Returns `[]` when the contentlet is in a terminal/resolved step with no available actions. " + + "An empty list does not distinguish 'no actions because terminal step' from 'no actions because permissions' — " + + "call `GET /api/v1/workflow/status/{inode}` to inspect the current step (`stepResolved: true` indicates terminal).\n\n" + + "Each action item includes an `actionInputs[]` array advertising the body keys the action expects. " + + "For some actions (e.g. Move, Copy) this list is currently empty even though the action requires body input; " + + "consult `PUT /api/v1/workflow/actions/{actionId}/fire` for documented body shapes.", tags = {"Workflow"}, responses = { @ApiResponse(responseCode = "200", description = "Scheme(s) returned successfully", @@ -755,7 +776,7 @@ public final Response getBulkActions(@Context final HttpServletRequest request, description = "Body consists of a JSON object with either of the following properties:\n\n" + "| Property | Type | Description |\n" + "|-|-|-|\n" + - "| `contentletIds` | List of Strings | A list of individual contentlet identifiers. |\n" + + "| `contentletIds` | List of Strings | A list of contentlet **inodes** (not identifiers, despite the property name). |\n" + "| `query` | String | [Lucene query](https://www.dotcms.com/docs/latest/content-search-syntax#Lucene); " + "uses all matching contentlets. |\n\n" + "If both properties are present, the operation will use the list of identifiers and disregard " + @@ -803,7 +824,12 @@ public final Response getBulkActions(@Context final HttpServletRequest request, @Operation(operationId = "putBulkActionsFire", summary = "Perform workflow actions on bulk content", description = "This operation allows you to specify a multiple content items (either by query or a list of " + "identifiers), a [workflow action](https://www.dotcms.com/docs/latest/managing-workflows#Actions) " + - "to perform on them, and additional parameters as needed by the selected action.", + "to perform on them, and additional parameters as needed by the selected action.\n\n" + + BULK_FIRE_CONTRACT_NOTES + "\n" + + "- `PUT /api/v1/workflow/contentlet/actions/bulk/fire` and " + + "`POST /api/v1/workflow/contentlet/actions/_bulkfire` behave identically; `_bulkfire` streams via SSE.\n" + + "- For batches larger than a few contentlets, the synchronous response can exceed common client " + + "timeouts (e.g. ~15 s for the MCP sandbox). The server-side work typically completes; verify with `_search`.", tags = {"Workflow"}, responses = { @ApiResponse(responseCode = "200", description = "Success", @@ -821,7 +847,7 @@ public final void fireBulkActions(@Context final HttpServletRequest request, description = "Body consists of a JSON object with the following possible properties:\n\n" + "| Property | Type | Description |\n" + "|-|-|-|\n" + - "| `contentletIds` | List of Strings | A list of individual contentlet identifiers. |\n" + + "| `contentletIds` | List of Strings | A list of contentlet **inodes** (not identifiers, despite the property name). |\n" + "| `query` | String | [Lucene query](https://www.dotcms.com/docs/latest/content-search-syntax#Lucene); " + "uses all matching contentlets. |\n" + "| `workflowActionId` | String | The identifier of the workflow action to be performed on the " + @@ -866,7 +892,11 @@ public final void fireBulkActions(@Context final HttpServletRequest request, @Operation(operationId = "postBulkActionsFire", summary = "Perform workflow actions on bulk content", description = "This operation allows you to specify a multiple content items (either by query or a list of " + "identifiers), a [workflow action](https://www.dotcms.com/docs/latest/managing-workflows#Actions) " + - "to perform on them, and additional parameters as needed by the selected action.", + "to perform on them, and additional parameters as needed by the selected action. " + + "Responses are streamed as Server-Sent Events.\n\n" + + BULK_FIRE_CONTRACT_NOTES + "\n" + + "- This endpoint behaves identically to `PUT /api/v1/workflow/contentlet/actions/bulk/fire`; " + + "only the response transport differs (SSE here vs JSON there).", tags = {"Workflow"}, responses = { @ApiResponse(responseCode = "200", description = "Success", @@ -883,7 +913,7 @@ public EventOutput fireBulkActions(@Context final HttpServletRequest request, description = "Body consists of a JSON object with the following possible properties:\n\n" + "| Property | Type | Description |\n" + "|-|-|-|\n" + - "| `contentletIds` | List of Strings | A list of individual contentlet identifiers. |\n" + + "| `contentletIds` | List of Strings | A list of contentlet **inodes** (not identifiers, despite the property name). |\n" + "| `query` | String | [Lucene query](https://www.dotcms.com/docs/latest/content-search-syntax#Lucene); " + "uses all matching contentlets. |\n" + "| `workflowActionId` | String | The identifier of the workflow action to be performed on the " + @@ -1604,7 +1634,14 @@ public final Response deletesSystemAction(@Context final HttpServletRequest requ @Produces(MediaType.APPLICATION_JSON) @Operation(operationId = "postActionsByWorkflowActionForm", summary = "Creates/saves a workflow action", description = "Creates or updates a [workflow action](https://www.dotcms.com/docs/latest/managing-workflows#Actions) " + - "from the properties specified in the payload. Returns the created workflow action.", + "from the properties specified in the payload. Returns the created workflow action.\n\n" + + "**Tip:** Passing a `stepId` in the body also **attaches the new action to that step** as a side-effect. " + + "The separate `POST /api/v1/workflow/steps/{stepId}/actions` endpoint is only needed when attaching " + + "an action that already exists; for new actions created via this endpoint, `stepId` performs both " + + "the create and the attach in a single call.\n\n" + + "**Anyone / public role:** there is no magic `\"anyone\"` constant for `whoCanUse` or `actionNextAssign`. " + + "Use the CMS Anonymous role id (`654b0931-1027-41f7-ad4d-173115ed8ec1`) on most installs, or look up " + + "the actual id by inspecting an existing System Workflow action.", tags = {"Workflow"}, responses = { @ApiResponse(responseCode = "200", description = "Workflow action created successfully", @@ -2415,7 +2452,9 @@ public final Response addStep(@Context final HttpServletRequest request, "| `enableEscalation` | Boolean | Determines whether a step is capable of automatic escalation " + "to the next step. (Read more about [schedule-enabled workflows]" + "(https://www.dotcms.com/docs/latest/schedule-enabled-workflow).) |\n" + - "| `escalationAction` | String | The identifier of the workflow action to execute on automatic escalation. |\n" + + "| `escalationAction` | String | The identifier of the workflow action to execute on automatic escalation. " + + "**Must be an empty string (`\"\"`) when `enableEscalation: false`** — `null` or omitting the key " + + "returns `400 \"may not be null\"`. |\n" + "| `escalationTime` | String | The time, in seconds, before the workflow automatically escalates. |\n" + "| `stepResolved` | Boolean | If true, any content which enters this workflow step will be considered resolved.\n" + "Content in a resolved step will not appear in the workflow queues of any users.\n |\n\n", @@ -2501,11 +2540,12 @@ public final Response findStepById(@Context final HttpServletRequest request, @Produces({MediaType.APPLICATION_JSON}) @Consumes(MediaType.MULTIPART_FORM_DATA) @Operation(operationId = "putFireActionByNameMultipart", summary = "Fire action by name (multipart form)", - description = "(Fires a [workflow action](https://www.dotcms.com/docs/latest/managing-workflows#Actions), " + + description = "Fires a [workflow action](https://www.dotcms.com/docs/latest/managing-workflows#Actions), " + "specified by name, on a target contentlet. Uses a multipart form to transmit its data.\n\n" + "Returns a map of the resultant contentlet, with an additional " + "`AUTO_ASSIGN_WORKFLOW` property, which can be referenced by delegate " + - "services that handle automatically assigning workflow schemes to content with none.", + "services that handle automatically assigning workflow schemes to content with none.\n\n" + + INDEX_POLICY_CHAINING_NOTE, tags = {"Workflow"}, responses = { @ApiResponse(responseCode = "200", description = "Fired action successfully", @@ -2649,7 +2689,8 @@ public final Response fireActionByNameMultipart(@Context final HttpServletReques description = "Fires a [workflow action](https://www.dotcms.com/docs/latest/managing-workflows#Actions), " + "specified by name, on a target contentlet.\n\nReturns a map of the resultant contentlet, " + "with an additional `AUTO_ASSIGN_WORKFLOW` property, which can be referenced by delegate " + - "services that handle automatically assigning workflow schemes to content with none.", + "services that handle automatically assigning workflow schemes to content with none.\n\n" + + INDEX_POLICY_CHAINING_NOTE, tags = {"Workflow"}, responses = { @ApiResponse(responseCode = "200", description = "Fired action successfully", @@ -2924,7 +2965,60 @@ private boolean needSave (final FireActionForm fireActionForm) { description = "Fire a [default system action](https://www.dotcms.com/docs/latest/managing-workflows#DefaultActions) " + "by name on a target contentlet.\n\nReturns a map of the resultant contentlet, " + "with an additional `AUTO_ASSIGN_WORKFLOW` property, which can be referenced by delegate " + - "services that handle automatically assigning workflow schemes to content with none.", + "services that handle automatically assigning workflow schemes to content with none.\n\n" + + "**Request body** — wrap field values in a `contentlet` key:\n\n" + + "```json\n" + + "{\n" + + " \"contentlet\": {\n" + + " \"contentType\": \"\",\n" + + " \"title\": \"My New Item\",\n" + + " \"...\": \"other field values\"\n" + + " }\n" + + "}\n" + + "```\n" + + "Field keys inside `contentlet` are the content type's field `variable` names " + + "(e.g., `title`, `body`, `image`). Unknown field names are silently dropped " + + "(a typo like `titel` will be ignored and may surface as a misleading 'title is required' error). " + + "Radio/Select/Checkbox values are not validated against the field's `values` list — out-of-range " + + "values are accepted as-is. Always verify spelling against `fields[].variable` from " + + "`GET /api/v1/contenttype/id/{idOrVar}`.\n\n" + + "**Validation error response shape:**\n\n" + + "```json\n" + + "{\n" + + " \"entity\": \"\",\n" + + " \"errors\": [{ \"errorCode\": \"required\", \"fieldName\": \"image\", \"message\": \"The field Image is required.\" }],\n" + + " \"i18nMessagesMap\": {}, \"messages\": [], \"pagination\": null, \"permissions\": []\n" + + "}\n" + + "```\n" + + "`errorCode` values: `required`, `unknown`. `fieldName` is the field `variable` for field-specific errors, " + + "or `null` for content-level errors. Note: when the content type is not found, `message` returns " + + "the raw translation key `Workflow-does-not-exists-content-type` instead of translated text.\n\n" + + "**Binary and image fields** — These fields cannot receive raw file data or asset paths in the JSON body. " + + "Use one of the patterns below.\n\n" + + "**Pattern A — single-use file (works for all binary/image fields):**\n\n" + + "1. `POST /api/v1/temp` (multipart `file` part) OR `POST /api/v1/temp/byUrl` " + + "(JSON `{\"remoteUrl\":\"https://...\"}`) → use `tempFiles[0].id` (e.g. `\"temp_5311313004\"`) as the field value.\n" + + "2. Pass that ID in the contentlet body: " + + "`{\"contentlet\": {\"contentType\": \"ResortActivities\", \"image\": \"temp_5311313004\", ...}}`.\n\n" + + "**Pattern B — reusable shared asset (`ImmutableImageField` only):**\n\n" + + "1. Upload via `/temp`, create a dotAsset contentlet: " + + "`PUT .../fire/PUBLISH` with `{\"contentlet\": {\"contentType\": \"dotAsset\", \"asset\": \"temp_\"}}`.\n" + + "2. Use the returned dotAsset `identifier` as the field value on any `ImmutableImageField`.\n\n" + + "| Field `clazz` | `temp_` | dotAsset `identifier` |\n" + + "|---|---|---|\n" + + "| `ImmutableBinaryField` | ✅ | ❌ (returns 400 \\\"field is required\\\") |\n" + + "| `ImmutableImageField` | ✅ | ✅ |\n\n" + + "Find a field's `clazz` by calling `GET /api/v1/contenttype/id/{idOrVar}` and reading `fields[].clazz`.\n\n" + + "⚠️ **Known issue:** Firing `PUBLISH` on an archived contentlet (`archived: true`) does not validate " + + "the archived state and can produce an inconsistent `live: true, archived: true` tri-state. " + + "Always fire `UNARCHIVE` before `PUBLISH` on archived content.\n\n" + + "⚠️ **Multi-scheme content types:** When a content type has multiple workflow schemes attached, " + + "firing a system action only initializes the contentlet into the scheme whose `systemActionMappings` " + + "entry resolved the fire. Other attached schemes will not have a task for that contentlet, and firing " + + "their actions later will fail with 'Workflow Action is not available in the Workflow Step the content " + + "is currently in.' To exercise actions in those other schemes, fire by action ID via " + + "`PUT /api/v1/workflow/actions/{actionId}/fire` using an action mapped to the desired scheme.\n\n" + + INDEX_POLICY_CHAINING_NOTE, tags = {"Workflow"}, responses = { @ApiResponse(responseCode = "200", description = "Fired action successfully", @@ -2932,7 +3026,24 @@ private boolean needSave (final FireActionForm fireActionForm) { schema = @Schema(implementation = ResponseEntityMapView.class) ) ), - @ApiResponse(responseCode = "400", description = "Bad request"), // invalid param string like `\` + @ApiResponse(responseCode = "400", + description = "Validation error. `errors[].errorCode` values: " + + "`required` (a required field is missing), `unknown` (unknown content type — " + + "`message` returns the raw translation key `Workflow-does-not-exists-content-type`). " + + "`fieldName` is the field `variable` for field-specific errors, or `null` otherwise.", + content = @Content(mediaType = "application/json", + examples = @ExampleObject( + name = "Required field missing", + value = "{\n" + + " \"entity\": \"\",\n" + + " \"errors\": [\n" + + " { \"errorCode\": \"required\", \"fieldName\": \"image\", \"message\": \"The field Image is required.\" }\n" + + " ],\n" + + " \"i18nMessagesMap\": {},\n" + + " \"messages\": [],\n" + + " \"pagination\": null,\n" + + " \"permissions\": []\n" + + "}"))), @ApiResponse(responseCode = "404", description = "Content not found"), @ApiResponse(responseCode = "415", description = "Unsupported Media Type") } @@ -3896,7 +4007,8 @@ private void checkContentletState(final Contentlet contentlet, final SystemActio "specified by identifier, on a target contentlet. Uses a multipart form to transmit its data.\n\n" + "Returns a map of the resultant contentlet, with an additional " + "`AUTO_ASSIGN_WORKFLOW` property, which can be referenced by delegate " + - "services that handle automatically assigning workflow schemes to content with none.", + "services that handle automatically assigning workflow schemes to content with none.\n\n" + + INDEX_POLICY_CHAINING_NOTE, tags = {"Workflow"}, responses = { @ApiResponse(responseCode = "200", description = "Fired action successfully", @@ -4044,7 +4156,8 @@ public final Response fireActionMultipart(@Context final HttpServl "on target contentlet. Uses a multipart form to transmit its data.\n\n" + "Returns a map of the resultant contentlet, with an additional " + "`AUTO_ASSIGN_WORKFLOW` property, which can be referenced by delegate " + - "services that handle automatically assigning workflow schemes to content with none.", + "services that handle automatically assigning workflow schemes to content with none.\n\n" + + INDEX_POLICY_CHAINING_NOTE, tags = {"Workflow"}, responses = { @ApiResponse(responseCode = "200", description = "Fired action successfully", @@ -4228,7 +4341,28 @@ public final Response fireActionDefaultMultipart( description = "Fires a [workflow action](https://www.dotcms.com/docs/latest/managing-workflows#Actions), " + "specified by identifier, on a target contentlet.\n\nReturns a map of the resultant contentlet, " + "with an additional `AUTO_ASSIGN_WORKFLOW` property, which can be referenced by delegate " + - "services that handle automatically assigning workflow schemes to content with none.", + "services that handle automatically assigning workflow schemes to content with none.\n\n" + + "**Use this endpoint to fire actions that are not represented as `SystemAction` tokens** " + + "(`NEW`, `EDIT`, `PUBLISH`, etc.). The two most common are `Move` and `Copy` on the System Workflow scheme.\n\n" + + "**Move action** — relocates a contentlet to a new folder/host. " + + "Request body shape (note: `pathToMove` is a sibling of `contentlet`, **not** nested inside it):\n\n" + + "```json\n" + + "{\n" + + " \"contentlet\": { \"identifier\": \"\" },\n" + + " \"pathToMove\": \"///\"\n" + + "}\n" + + "```\n" + + "Alternative shapes (`contentlet.host`+`contentlet.folder`, `contentlet.hostFolder`, " + + "`path` instead of `pathToMove`) all return `400 \"The host path is not valid: null\"`.\n\n" + + "**Copy action** — clones a contentlet. Fire with `?identifier=` and an empty body " + + "(or `{\"contentlet\": {\"identifier\": \"\"}}`). The Copy action id on the default " + + "System Workflow scheme is `963f6a04-5320-42e7-ab74-6d876d199946`; retrieve it for other " + + "environments via `GET /api/v1/workflow/schemes/{schemeId}/actions`. ⚠️ The response `entity` " + + "returns the **source** contentlet, not the newly-created copy — locate the copy via a " + + "follow-up `POST /api/content/_search` ordered by `modDate DESC`. The copy lands in " + + "`SYSTEM_HOST` / `SYSTEM_FOLDER`; destination hints (`pathToMove`, `host`, `folder`, `hostFolder`) " + + "are silently ignored. Fire the Move action afterwards to relocate.\n\n" + + INDEX_POLICY_CHAINING_NOTE, tags = {"Workflow"}, responses = { @ApiResponse(responseCode = "200", description = "Fired action successfully", diff --git a/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml b/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml index 22b5dafd3373..679ace927783 100644 --- a/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml +++ b/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml @@ -7711,16 +7711,28 @@ paths: schema: $ref: "#/components/schemas/ContentTypeForm" description: |- - Payload may consist of a single content type JSON object, or a list containing multiple content type objects. + Accepts either a single content-type object or an array. The body is the content-type object directly (not wrapped in a 'contentType' envelope). - Objects require `clazz` and `name` properties at minimum. + **Required properties:** + - `clazz` *(string)* — fully-qualified class name. One of: `com.dotcms.contenttype.model.type.ImmutableSimpleContentType`, `com.dotcms.contenttype.model.type.ImmutableWidgetContentType`, `com.dotcms.contenttype.model.type.ImmutableFormContentType`, `com.dotcms.contenttype.model.type.ImmutableFileAssetContentType`, `com.dotcms.contenttype.model.type.ImmutablePageContentType`, `com.dotcms.contenttype.model.type.ImmutablePersonaContentType`, `com.dotcms.contenttype.model.type.ImmutableVanityUrlContentType`, `com.dotcms.contenttype.model.type.ImmutableKeyValueContentType`, `com.dotcms.contenttype.model.type.ImmutableDotAssetContentType` + - `name` *(string)* — display name - May optionally include the following special properties: + **Common optional properties:** + - `variable` *(string)* — Velocity variable name (unique, alphanumeric, starts with a letter; auto-generated if omitted) + - `host` *(string)* — site identifier UUID or the literal `SYSTEM_HOST` (defaults to the default site) + - `folder` *(string)* — folder identifier UUID or the literal `SYSTEM_FOLDER` (defaults to `SYSTEM_FOLDER`) + - `description` *(string)* + - `workflow` *(array of workflow scheme UUIDs)* — e.g. `["d61a59e1-a49c-46f2-a929-db2b4bfa88b2"]` for System Workflow. ⚠️ **Note:** this is `workflow` (singular) in the request. GET responses return `workflows` (plural, array of objects) — clients round-tripping an object must rename this key. + - `fields` *(array of field objects)* — see field schema below + - `metadata` *(object)* — known keys: `CONTENT_EDITOR2_ENABLED` (boolean), `DOT_STYLE_EDITOR_SCHEMA` (JSON string) + - `systemActionMappings` *(object)* — maps system actions (`NEW`, `EDIT`, `PUBLISH`, `UNPUBLISH`, `ARCHIVE`, `UNARCHIVE`, `DELETE`, `DESTROY`) to workflow action UUIDs - | Property | Value | Description | - |-|-|-| - | `systemActionMappings` | JSON Object | Maps [Default Workflow Actions](https://www.dotcms.com/docs/latest/managing-workflows#DefaultActions) (as keys) to workflow action identifiers (as values) for this content type.| - | `workflow` | List of Strings | A list of identifiers of workflow schemes to be associated with the content type. + **Field object schema** (each item in `fields[]`): + - `clazz` *(string, required)* — e.g. `com.dotcms.contenttype.model.field.ImmutableTextField`, `ImmutableTextAreaField`, `ImmutableStoryBlockField`, `ImmutableBinaryField`, `ImmutableTagField`, `ImmutableRadioField`, `ImmutableSelectField`, `ImmutableDateField`, `ImmutableDateTimeField`, `ImmutableRowField` *(layout marker)*, `ImmutableColumnField` *(layout marker)* + - `name`, `variable`, `dataType` (one of `TEXT`, `LONG_TEXT`, `SYSTEM`, `BOOL`, `INTEGER`, `FLOAT`, `DATE`), `required`, `indexed`, `listed`, `sortOrder` *(integer, position in the fields array)* + - `values` *(string)* — for Radio/Select/Checkbox: newline-separated `Display|value` pairs. For a boolean field use `ImmutableRadioField` + `dataType: BOOL` + `values: 'True|true\r\nFalse|false'` — there is no dedicated Boolean field class. + + **Layout encoding:** Rows and columns are regular field entries placed in `fields[]`. `ImmutableRowField` begins a new row; `ImmutableColumnField` begins a new column inside that row; following content fields belong to the most-recent column until the next marker. required: true responses: "200": @@ -7774,8 +7786,10 @@ paths: - Content Type /v1/contenttype/_filter: post: - description: "Returns the list of content type objects that match the specified\ - \ filter, with optional pagination criteria." + description: |- + Returns the list of content type objects that match the specified filter, with optional pagination criteria. + + For simple substring filtering without pagination control, `GET /api/v1/contenttype?filter=` is equivalent and simpler. Use `_filter` when you need pagination, ordering, and direction together. operationId: postContentTypeFilter requestBody: content: @@ -7885,7 +7899,7 @@ paths: description: |- Deletes the content type based on the provided ID or Velocity variable name. - Returns JSON string containing the identifier of the deleted content type. + ⚠️ **Note:** The `entity` field in the response is a **JSON-encoded string**, not an object. Typed clients must call `JSON.parse(entity)` before use to get `{ "deleted": "" }`. This differs from other endpoints in the same family where `entity` is a direct object. operationId: deleteContentType parameters: - description: The ID or Velocity variable name of the content type to delete. @@ -7918,8 +7932,10 @@ paths: tags: - Content Type get: - description: Returns one content type based on the provided ID or Velocity variable - name. + description: |- + Returns one content type based on the provided ID or Velocity variable name. + + The response includes a `fields[]` array describing every field on the type. Each field has a `clazz` property (e.g. `ImmutableBinaryField`, `ImmutableImageField`, `ImmutableTextField`) that determines what values the field accepts. This is particularly important for file-like fields: `ImmutableBinaryField` only accepts a `temp_` from `POST /api/v1/temp`; `ImmutableImageField` accepts a `temp_` or a dotAsset `identifier`. Always inspect `fields[].clazz` before attempting to set binary or image field values via the workflow fire endpoint. operationId: getContentTypeIdVar parameters: - description: |- @@ -7997,9 +8013,16 @@ paths: description: |- Updates the content type based on the given ID or Velocity variable name. - Returns a copy of the updated content type object. + ⚠️ **Destructive semantics.** This endpoint treats the request body as the full desired state. Any editable property (including items in `fields[]` and `metadata` keys) absent from the body will be removed. + + **Recommended update pattern:** + 1. `GET /api/v1/contenttype/id/{idOrVar}` to fetch the current object. + 2. Mutate the returned object in place. Rename `workflows` (array of objects) → `workflow` (array of UUIDs) before sending. + 3. PUT the entire mutated object back. - > **Caution:** When updating a content type, any editable fields omitted from the request body will be removed from the content type. To update selected properties without deleting others,submit the full JSON entity with the desired items edited. + This is also the only supported way to add, remove, or modify individual fields — the `/api/v1/contenttype/{typeId}/fields/**` family is deprecated in favor of this full-CT PUT. + + ⚠️ **`systemActionMappings` is validated on write.** Each entry's `workflowAction.id` must still exist; if a mapping points to a deleted workflow action, the entire PUT fails with `"The workflow action with the id does not exists"`. When round-tripping the object, prune `systemActionMappings` entries you do not intend to update — or omit the `systemActionMappings` property entirely if no mapping changes are needed. operationId: putContentTypeUpdate parameters: - description: |- @@ -8346,12 +8369,12 @@ paths: | Property | Type | Description | |----------|--------|-------------| | `name` | String | **Required.** Name of new content type | - | `variable` | String | System variable of new content type | + | `variable` | String | Velocity variable name of the new content type | | `folder` | String | Folder in which new content type will live | | `host` | String | Site or host to which the new content type will belong | | `icon` | String | System icon to represent content type | - Values not specified default to values of the original content type. + The copy preserves: `description`, `host`, `folder`, full `fields[]` (with new field IDs), layout (Row/Column markers), `metadata`, and workflow assignments. Unspecified values default to those of the original content type. required: true responses: "200": @@ -11718,30 +11741,41 @@ paths: - Navigation /v1/nav/{uri}: get: - description: "Returns navigation metadata in JSON format for objects marked\ - \ as 'show on menu'. Builds a tree structure starting from the given URI path,\ - \ up to the specified depth. Example: /api/v1/nav/about-us?depth=2&languageId=1\ - \ returns the navigation tree under /about-us, 2 levels deep." + description: "Returns the dotCMS site navigation tree starting at the given\ + \ **folder** URI, up to the specified depth. Only objects marked as 'show\ + \ on menu' are included. The URI must resolve to a folder — page URIs (e.g.,\ + \ '/index', '/about-us/team') will return 404. Use '/' to fetch the navigation\ + \ tree for the site root." operationId: getNavigationTree parameters: - - description: "Starting URI path for the navigation tree (e.g., 'about-us')" + - description: "Starting folder URI for the navigation tree (e.g., '/about-us',\ + \ '/blog', or '/' for the site root). Must resolve to a folder — page URIs\ + \ return 404." in: path name: uri required: true schema: type: string pattern: .* - - description: "Number of levels deep to include in the tree (default: 1)" + - description: "Total number of levels to include, counting the starting node\ + \ as level 1. depth=1 returns only the starting node with no children; depth=2\ + \ returns the node plus its direct children. Values exceeding the actual\ + \ tree depth return the full subtree. Values less than 1 are treated as\ + \ 1. (default: 1)" in: query name: depth schema: - type: string - - description: Language ID for the navigation content (defaults to the request - language) + type: integer + format: int32 + - description: "Tags each returned node with this language ID. Note: folder\ + \ names are language-neutral in dotCMS and are not translated — this parameter\ + \ only affects the 'languageId' attribute on each node, not the visible\ + \ 'title'. Defaults to the language of the current request." in: query name: languageId schema: - type: string + type: integer + format: int64 responses: "200": content: @@ -16321,11 +16355,10 @@ paths: - Tag (v1) /v1/temp: post: - description: Uploads one or more files as temporary resources via multipart - form data. Files are stored temporarily and can be referenced when creating - content. The response streams back a JSON object with the created temporary - file references. Anonymous access can be allowed via the TEMP_RESOURCE_ALLOW_ANONYMOUS - configuration property. + description: |- + Uploads one or more files as temporary resources via multipart form data. Files are stored temporarily and can be referenced when creating content. The response streams back a JSON object with the created temporary file references. Anonymous access can be allowed via the TEMP_RESOURCE_ALLOW_ANONYMOUS configuration property. + + **Use this endpoint to supply files for binary and image fields** (`ImmutableBinaryField`, `ImmutableImageField`) when creating or updating contentlets. After uploading, pass `tempFiles[0].id` (e.g. `"temp_5311313004"`) as the field value in the workflow fire endpoint. See `PUT /api/v1/workflow/actions/default/fire/{systemAction}` for the full pattern. operationId: uploadTempFileMultipart parameters: - description: Maximum file length in bytes (-1 for default) @@ -16348,10 +16381,22 @@ paths: "200": content: application/json: - schema: - type: object - description: Streamed JSON object containing a 'tempFiles' array with - DotTempFile references for each uploaded file + example: + tempFiles: + - id: temp_5311313004 + fileName: hero.jpg + length: 84471 + mimeType: image/jpeg + image: true + referenceUrl: /dA/temp_5311313004/tmp/hero.jpg + metadata: + contentType: image/jpeg + height: 522 + width: 900 + fileSize: 84471 + isImage: true + schema: + $ref: "#/components/schemas/TempFilesView" description: Temporary files created successfully "400": description: "Invalid file, origin, or referer" @@ -16364,10 +16409,10 @@ paths: - Temporary Files /v1/temp/byUrl: post: - description: Downloads a file from the specified remote URL and stores it as - a temporary resource. The temporary file can then be referenced when creating - content. The URL must pass validation to prevent SSRF attacks. Anonymous access - can be allowed via the TEMP_RESOURCE_ALLOW_ANONYMOUS configuration property. + description: |- + Downloads a file from the specified remote URL and stores it as a temporary resource. The temporary file can then be referenced when creating content. The URL must pass validation to prevent SSRF attacks. Anonymous access can be allowed via the TEMP_RESOURCE_ALLOW_ANONYMOUS configuration property. + + **Use this endpoint to supply files for binary and image fields** (`ImmutableBinaryField`, `ImmutableImageField`) when a file is already accessible by URL. Send `{"remoteUrl": "https://example.com/image.jpg"}` and pass `tempFiles[0].id` (e.g. `"temp_5311313004"`) as the field value in the workflow fire endpoint. See `PUT /api/v1/workflow/actions/default/fire/{systemAction}` for the full pattern. operationId: createTempFileFromUrl requestBody: content: @@ -16378,10 +16423,22 @@ paths: "200": content: application/json: - schema: - type: object - description: Map containing a 'tempFiles' array with DotTempFile references - for the downloaded file + example: + tempFiles: + - id: temp_5311313004 + fileName: hero.jpg + length: 84471 + mimeType: image/jpeg + image: true + referenceUrl: /dA/temp_5311313004/tmp/hero.jpg + metadata: + contentType: image/jpeg + height: 522 + width: 900 + fileSize: 84471 + isImage: true + schema: + $ref: "#/components/schemas/TempFilesView" description: Temporary file created from URL successfully "400": description: "Invalid URL, missing URL, or invalid origin/referer" @@ -17738,9 +17795,12 @@ paths: - Workflow /v1/workflow/actions: post: - description: "Creates or updates a [workflow action](https://www.dotcms.com/docs/latest/managing-workflows#Actions)\ - \ from the properties specified in the payload. Returns the created workflow\ - \ action." + description: |- + Creates or updates a [workflow action](https://www.dotcms.com/docs/latest/managing-workflows#Actions) from the properties specified in the payload. Returns the created workflow action. + + **Tip:** Passing a `stepId` in the body also **attaches the new action to that step** as a side-effect. The separate `POST /api/v1/workflow/steps/{stepId}/actions` endpoint is only needed when attaching an action that already exists; for new actions created via this endpoint, `stepId` performs both the create and the attach in a single call. + + **Anyone / public role:** there is no magic `"anyone"` constant for `whoCanUse` or `actionNextAssign`. Use the CMS Anonymous role id (`654b0931-1027-41f7-ad4d-173115ed8ec1`) on most installs, or look up the actual id by inspecting an existing System Workflow action. operationId: postActionsByWorkflowActionForm requestBody: content: @@ -18061,6 +18121,55 @@ paths: Fire a [default system action](https://www.dotcms.com/docs/latest/managing-workflows#DefaultActions) by name on a target contentlet. Returns a map of the resultant contentlet, with an additional `AUTO_ASSIGN_WORKFLOW` property, which can be referenced by delegate services that handle automatically assigning workflow schemes to content with none. + + **Request body** — wrap field values in a `contentlet` key: + + ```json + { + "contentlet": { + "contentType": "", + "title": "My New Item", + "...": "other field values" + } + } + ``` + Field keys inside `contentlet` are the content type's field `variable` names (e.g., `title`, `body`, `image`). Unknown field names are silently dropped (a typo like `titel` will be ignored and may surface as a misleading 'title is required' error). Radio/Select/Checkbox values are not validated against the field's `values` list — out-of-range values are accepted as-is. Always verify spelling against `fields[].variable` from `GET /api/v1/contenttype/id/{idOrVar}`. + + **Validation error response shape:** + + ```json + { + "entity": "", + "errors": [{ "errorCode": "required", "fieldName": "image", "message": "The field Image is required." }], + "i18nMessagesMap": {}, "messages": [], "pagination": null, "permissions": [] + } + ``` + `errorCode` values: `required`, `unknown`. `fieldName` is the field `variable` for field-specific errors, or `null` for content-level errors. Note: when the content type is not found, `message` returns the raw translation key `Workflow-does-not-exists-content-type` instead of translated text. + + **Binary and image fields** — These fields cannot receive raw file data or asset paths in the JSON body. Use one of the patterns below. + + **Pattern A — single-use file (works for all binary/image fields):** + + 1. `POST /api/v1/temp` (multipart `file` part) OR `POST /api/v1/temp/byUrl` (JSON `{"remoteUrl":"https://..."}`) → use `tempFiles[0].id` (e.g. `"temp_5311313004"`) as the field value. + 2. Pass that ID in the contentlet body: `{"contentlet": {"contentType": "ResortActivities", "image": "temp_5311313004", ...}}`. + + **Pattern B — reusable shared asset (`ImmutableImageField` only):** + + 1. Upload via `/temp`, create a dotAsset contentlet: `PUT .../fire/PUBLISH` with `{"contentlet": {"contentType": "dotAsset", "asset": "temp_"}}`. + 2. Use the returned dotAsset `identifier` as the field value on any `ImmutableImageField`. + + | Field `clazz` | `temp_` | dotAsset `identifier` | + |---|---|---| + | `ImmutableBinaryField` | ✅ | ❌ (returns 400 \"field is required\") | + | `ImmutableImageField` | ✅ | ✅ | + + Find a field's `clazz` by calling `GET /api/v1/contenttype/id/{idOrVar}` and reading `fields[].clazz`. + + ⚠️ **Known issue:** Firing `PUBLISH` on an archived contentlet (`archived: true`) does not validate the archived state and can produce an inconsistent `live: true, archived: true` tri-state. Always fire `UNARCHIVE` before `PUBLISH` on archived content. + + ⚠️ **Multi-scheme content types:** When a content type has multiple workflow schemes attached, firing a system action only initializes the contentlet into the scheme whose `systemActionMappings` entry resolved the fire. Other attached schemes will not have a task for that contentlet, and firing their actions later will fail with 'Workflow Action is not available in the Workflow Step the content is currently in.' To exercise actions in those other schemes, fire by action ID via `PUT /api/v1/workflow/actions/{actionId}/fire` using an action mapped to the desired scheme. + + **When chaining workflow actions or reading state back immediately after firing, pass `indexPolicy=WAIT_FOR` on each call.** The default `DEFER` is asynchronous and can return stale index reads for several seconds, which can mimic server-side state bugs. For isolated one-off fires where nothing reads the result, leave the default. operationId: putFireDefaultSystemAction parameters: - description: Inode of the target content. @@ -18154,7 +18263,26 @@ paths: $ref: "#/components/schemas/ResponseEntityMapView" description: Fired action successfully "400": - description: Bad request + content: + application/json: + examples: + Required field missing: + description: Required field missing + value: + entity: "" + errors: + - errorCode: required + fieldName: image + message: The field Image is required. + i18nMessagesMap: {} + messages: [] + pagination: null + permissions: [] + description: "Validation error. `errors[].errorCode` values: `required`\ + \ (a required field is missing), `unknown` (unknown content type — `message`\ + \ returns the raw translation key `Workflow-does-not-exists-content-type`).\ + \ `fieldName` is the field `variable` for field-specific errors, or `null`\ + \ otherwise." "401": description: Invalid User "403": @@ -18176,6 +18304,8 @@ paths: Fires a default [system action](https://www.dotcms.com/docs/latest/managing-workflows#DefaultActions) on target contentlet. Uses a multipart form to transmit its data. Returns a map of the resultant contentlet, with an additional `AUTO_ASSIGN_WORKFLOW` property, which can be referenced by delegate services that handle automatically assigning workflow schemes to content with none. + + **When chaining workflow actions or reading state back immediately after firing, pass `indexPolicy=WAIT_FOR` on each call.** The default `DEFER` is asynchronous and can return stale index reads for several seconds, which can mimic server-side state bugs. For isolated one-off fires where nothing reads the result, leave the default. operationId: putFireDefaultActionMultipart parameters: - description: Inode of the target content. @@ -18264,6 +18394,8 @@ paths: Fires a [workflow action](https://www.dotcms.com/docs/latest/managing-workflows#Actions), specified by name, on a target contentlet. Returns a map of the resultant contentlet, with an additional `AUTO_ASSIGN_WORKFLOW` property, which can be referenced by delegate services that handle automatically assigning workflow schemes to content with none. + + **When chaining workflow actions or reading state back immediately after firing, pass `indexPolicy=WAIT_FOR` on each call.** The default `DEFER` is asynchronous and can return stale index reads for several seconds, which can mimic server-side state bugs. For isolated one-off fires where nothing reads the result, leave the default. operationId: putFireActionByName parameters: - description: Inode of the target content. @@ -18356,9 +18488,11 @@ paths: /v1/workflow/actions/firemultipart: put: description: |- - (Fires a [workflow action](https://www.dotcms.com/docs/latest/managing-workflows#Actions), specified by name, on a target contentlet. Uses a multipart form to transmit its data. + Fires a [workflow action](https://www.dotcms.com/docs/latest/managing-workflows#Actions), specified by name, on a target contentlet. Uses a multipart form to transmit its data. Returns a map of the resultant contentlet, with an additional `AUTO_ASSIGN_WORKFLOW` property, which can be referenced by delegate services that handle automatically assigning workflow schemes to content with none. + + **When chaining workflow actions or reading state back immediately after firing, pass `indexPolicy=WAIT_FOR` on each call.** The default `DEFER` is asynchronous and can return stale index reads for several seconds, which can mimic server-side state bugs. For isolated one-off fires where nothing reads the result, leave the default. operationId: putFireActionByNameMultipart parameters: - description: Inode of the target content. @@ -18774,6 +18908,22 @@ paths: Fires a [workflow action](https://www.dotcms.com/docs/latest/managing-workflows#Actions), specified by identifier, on a target contentlet. Returns a map of the resultant contentlet, with an additional `AUTO_ASSIGN_WORKFLOW` property, which can be referenced by delegate services that handle automatically assigning workflow schemes to content with none. + + **Use this endpoint to fire actions that are not represented as `SystemAction` tokens** (`NEW`, `EDIT`, `PUBLISH`, etc.). The two most common are `Move` and `Copy` on the System Workflow scheme. + + **Move action** — relocates a contentlet to a new folder/host. Request body shape (note: `pathToMove` is a sibling of `contentlet`, **not** nested inside it): + + ```json + { + "contentlet": { "identifier": "" }, + "pathToMove": "///" + } + ``` + Alternative shapes (`contentlet.host`+`contentlet.folder`, `contentlet.hostFolder`, `path` instead of `pathToMove`) all return `400 "The host path is not valid: null"`. + + **Copy action** — clones a contentlet. Fire with `?identifier=` and an empty body (or `{"contentlet": {"identifier": ""}}`). The Copy action id on the default System Workflow scheme is `963f6a04-5320-42e7-ab74-6d876d199946`; retrieve it for other environments via `GET /api/v1/workflow/schemes/{schemeId}/actions`. ⚠️ The response `entity` returns the **source** contentlet, not the newly-created copy — locate the copy via a follow-up `POST /api/content/_search` ordered by `modDate DESC`. The copy lands in `SYSTEM_HOST` / `SYSTEM_FOLDER`; destination hints (`pathToMove`, `host`, `folder`, `hostFolder`) are silently ignored. Fire the Move action afterwards to relocate. + + **When chaining workflow actions or reading state back immediately after firing, pass `indexPolicy=WAIT_FOR` on each call.** The default `DEFER` is asynchronous and can return stale index reads for several seconds, which can mimic server-side state bugs. For isolated one-off fires where nothing reads the result, leave the default. operationId: putFireActionById parameters: - description: |- @@ -18876,6 +19026,8 @@ paths: Fires a [workflow action](https://www.dotcms.com/docs/latest/managing-workflows#Actions), specified by identifier, on a target contentlet. Uses a multipart form to transmit its data. Returns a map of the resultant contentlet, with an additional `AUTO_ASSIGN_WORKFLOW` property, which can be referenced by delegate services that handle automatically assigning workflow schemes to content with none. + + **When chaining workflow actions or reading state back immediately after firing, pass `indexPolicy=WAIT_FOR` on each call.** The default `DEFER` is asynchronous and can return stale index reads for several seconds, which can mimic server-side state bugs. For isolated one-off fires where nothing reads the result, leave the default. operationId: putFireActionByIdMultipart parameters: - description: |- @@ -18954,10 +19106,15 @@ paths: - Workflow /v1/workflow/contentlet/actions/_bulkfire: post: - description: "This operation allows you to specify a multiple content items\ - \ (either by query or a list of identifiers), a [workflow action](https://www.dotcms.com/docs/latest/managing-workflows#Actions)\ - \ to perform on them, and additional parameters as needed by the selected\ - \ action." + description: |- + This operation allows you to specify a multiple content items (either by query or a list of identifiers), a [workflow action](https://www.dotcms.com/docs/latest/managing-workflows#Actions) to perform on them, and additional parameters as needed by the selected action. Responses are streamed as Server-Sent Events. + + ⚠️ **Important contract notes:** + + - `contentletIds` despite its name expects **inodes**, not identifiers. Passing identifiers results in a silent no-op with no error. + - `additionalParams` must be present, even when empty (`{}`). Omitting it returns a `500` NPE. + - The endpoint is **not step-aware**: if the action is not valid in a contentlet's current step, the input is dropped silently (no `fails[]` entry). Verify post-fire state via `POST /api/content/_search`. + - This endpoint behaves identically to `PUT /api/v1/workflow/contentlet/actions/bulk/fire`; only the response transport differs (SSE here vs JSON there). operationId: postBulkActionsFire requestBody: content: @@ -18969,7 +19126,7 @@ paths: | Property | Type | Description | |-|-|-| - | `contentletIds` | List of Strings | A list of individual contentlet identifiers. | + | `contentletIds` | List of Strings | A list of contentlet **inodes** (not identifiers, despite the property name). | | `query` | String | [Lucene query](https://www.dotcms.com/docs/latest/content-search-syntax#Lucene); uses all matching contentlets. | | `workflowActionId` | String | The identifier of the workflow action to be performed on the selected content. | | `additionalParams` | Object | Further parameters and properties are conveyed here, depending on the particulars of the selected action.

For a complete list of possible parameters, refer to the various keys listed in `GET /workflow/actionlets`. | @@ -19018,7 +19175,7 @@ paths: | Property | Type | Description | |-|-|-| - | `contentletIds` | List of Strings | A list of individual contentlet identifiers. | + | `contentletIds` | List of Strings | A list of contentlet **inodes** (not identifiers, despite the property name). | | `query` | String | [Lucene query](https://www.dotcms.com/docs/latest/content-search-syntax#Lucene); uses all matching contentlets. | If both properties are present, the operation will use the list of identifiers and disregard the query. @@ -19047,10 +19204,16 @@ paths: - Workflow /v1/workflow/contentlet/actions/bulk/fire: put: - description: "This operation allows you to specify a multiple content items\ - \ (either by query or a list of identifiers), a [workflow action](https://www.dotcms.com/docs/latest/managing-workflows#Actions)\ - \ to perform on them, and additional parameters as needed by the selected\ - \ action." + description: |- + This operation allows you to specify a multiple content items (either by query or a list of identifiers), a [workflow action](https://www.dotcms.com/docs/latest/managing-workflows#Actions) to perform on them, and additional parameters as needed by the selected action. + + ⚠️ **Important contract notes:** + + - `contentletIds` despite its name expects **inodes**, not identifiers. Passing identifiers results in a silent no-op with no error. + - `additionalParams` must be present, even when empty (`{}`). Omitting it returns a `500` NPE. + - The endpoint is **not step-aware**: if the action is not valid in a contentlet's current step, the input is dropped silently (no `fails[]` entry). Verify post-fire state via `POST /api/content/_search`. + - `PUT /api/v1/workflow/contentlet/actions/bulk/fire` and `POST /api/v1/workflow/contentlet/actions/_bulkfire` behave identically; `_bulkfire` streams via SSE. + - For batches larger than a few contentlets, the synchronous response can exceed common client timeouts (e.g. ~15 s for the MCP sandbox). The server-side work typically completes; verify with `_search`. operationId: putBulkActionsFire requestBody: content: @@ -19062,7 +19225,7 @@ paths: | Property | Type | Description | |-|-|-| - | `contentletIds` | List of Strings | A list of individual contentlet identifiers. | + | `contentletIds` | List of Strings | A list of contentlet **inodes** (not identifiers, despite the property name). | | `query` | String | [Lucene query](https://www.dotcms.com/docs/latest/content-search-syntax#Lucene); uses all matching contentlets. | | `workflowActionId` | String | The identifier of the workflow action to be performed on the selected content. | | `additionalParams` | Object | Further parameters and properties are conveyed here, depending on the particulars of the selected action.

For a complete list of possible parameters, refer to the various keys listed in `GET /workflow/actionlets`. | @@ -19093,9 +19256,12 @@ paths: - Workflow /v1/workflow/contentlet/{inode}/actions: get: - description: "Returns a list of [workflow actions](https://www.dotcms.com/docs/latest/managing-workflows#Actions)\ - \ associated with a [contentlet](https://www.dotcms.com/docs/latest/content#Contentlets)\ - \ specified by inode." + description: |- + Returns a list of [workflow actions](https://www.dotcms.com/docs/latest/managing-workflows#Actions) associated with a [contentlet](https://www.dotcms.com/docs/latest/content#Contentlets) specified by inode. + + Returns `[]` when the contentlet is in a terminal/resolved step with no available actions. An empty list does not distinguish 'no actions because terminal step' from 'no actions because permissions' — call `GET /api/v1/workflow/status/{inode}` to inspect the current step (`stepResolved: true` indicates terminal). + + Each action item includes an `actionInputs[]` array advertising the body keys the action expects. For some actions (e.g. Move, Copy) this list is currently empty even though the action requires body input; consult `PUT /api/v1/workflow/actions/{actionId}/fire` for documented body shapes. operationId: getWorkflowActionsByContentletInode parameters: - description: |+ @@ -20067,7 +20233,7 @@ paths: | `schemeId` | String | The identifier of the [workflow scheme](https://www.dotcms.com/docs/latest/managing-workflows#Schemes) to which the step will be added. | | `stepName` | String | The name of the workflow step. | | `enableEscalation` | Boolean | Determines whether a step is capable of automatic escalation to the next step. (Read more about [schedule-enabled workflows](https://www.dotcms.com/docs/latest/schedule-enabled-workflow).) | - | `escalationAction` | String | The identifier of the workflow action to execute on automatic escalation. | + | `escalationAction` | String | The identifier of the workflow action to execute on automatic escalation. **Must be an empty string (`""`) when `enableEscalation: false`** — `null` or omitting the key returns `400 "may not be null"`. | | `escalationTime` | String | The time, in seconds, before the workflow automatically escalates. | | `stepResolved` | Boolean | If true, any content which enters this workflow step will be considered resolved. Content in a resolved step will not appear in the workflow queues of any users. @@ -25070,6 +25236,30 @@ components: type: string versionable: type: boolean + DotTempFile: + type: object + properties: + fileName: + type: string + folder: + type: string + id: + type: string + image: + type: boolean + length: + type: integer + format: int64 + metadata: + type: object + additionalProperties: + type: object + mimeType: + type: string + referenceUrl: + type: string + thumbnailUrl: + type: string DropOldVersionsResultView: type: object properties: @@ -34272,6 +34462,16 @@ components: properties: empty: type: boolean + TempFilesView: + type: object + description: "Response body for /api/v1/temp and /api/v1/temp/byUrl uploads.\ + \ Contains the tempFiles array; use tempFiles[0].id (e.g. \"temp_5311313004\"\ + ) as the field value when creating contentlets with binary or image fields." + properties: + tempFiles: + type: array + items: + $ref: "#/components/schemas/DotTempFile" Template: type: object properties: diff --git a/dotCMS/src/test/java/com/dotcms/rest/api/MultiPartUtilsTest.java b/dotCMS/src/test/java/com/dotcms/rest/api/MultiPartUtilsTest.java new file mode 100644 index 000000000000..7e089b008cbf --- /dev/null +++ b/dotCMS/src/test/java/com/dotcms/rest/api/MultiPartUtilsTest.java @@ -0,0 +1,63 @@ +package com.dotcms.rest.api; + +import com.dotmarketing.portlets.fileassets.business.FileAssetAPI; +import org.glassfish.jersey.media.multipart.FormDataBodyPart; +import org.glassfish.jersey.media.multipart.FormDataMultiPart; +import org.junit.Test; + +import java.io.File; +import java.io.IOException; +import java.util.Collections; +import java.util.List; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link MultiPartUtils}, specifically guarding the + * getBinariesFromMultipart code path against a Jersey quirk where + * FormDataMultiPart.getFields(name) returns null + * (not an empty list) when no part with that name exists. + */ +public class MultiPartUtilsTest { + + /** + * Given scenario: A multipart request with no "file" form part is received + * (e.g. the caller forgot to attach a file but sent a valid "contentlet" JSON). + * Expected result: getBinariesFromMultipart returns an empty list — not a + * NullPointerException. Downstream validation can then report the missing + * required field cleanly instead of the server emitting a 500. + */ + @Test + public void getBinariesFromMultipart_returnsEmptyListWhenNoFilePart() throws IOException { + final FormDataMultiPart multipart = mock(FormDataMultiPart.class); + when(multipart.getFields("file")).thenReturn(null); + + final List binaries = new MultiPartUtils(mock(FileAssetAPI.class)) + .getBinariesFromMultipart(multipart); + + assertNotNull(binaries); + assertTrue(binaries.isEmpty()); + } + + /** + * Given scenario: A multipart request includes a "file" form key but the + * list of parts under that key is empty. + * Expected result: getBinariesFromMultipart returns an empty list and does + * not throw. Ensures the null-guard does not regress the previously-working + * empty-list path. + */ + @Test + public void getBinariesFromMultipart_returnsEmptyListWhenFilePartListIsEmpty() throws IOException { + final FormDataMultiPart multipart = mock(FormDataMultiPart.class); + when(multipart.getFields("file")).thenReturn(Collections.emptyList()); + + final List binaries = new MultiPartUtils(mock(FileAssetAPI.class)) + .getBinariesFromMultipart(multipart); + + assertNotNull(binaries); + assertTrue(binaries.isEmpty()); + } +}