Skip to content

feat(rest): add /api/v2/assets endpoint for reading and writing file assets#36112

Open
fmontes wants to merge 6 commits into
mainfrom
fmontes/v2-assets
Open

feat(rest): add /api/v2/assets endpoint for reading and writing file assets#36112
fmontes wants to merge 6 commits into
mainfrom
fmontes/v2-assets

Conversation

@fmontes

@fmontes fmontes commented Jun 10, 2026

Copy link
Copy Markdown
Member

Proposed Changes

Closes #35928

Adds a clean v2 REST surface for reading and writing dotCMS file assets (.vtl, .dotsass, .css, .js, …) in place, addressable by host-qualified path or identifier. All business logic stays in v1's WebAssetHelper; /api/v1/assets (which backs the CLI) is contract-unchanged.

New endpoints (com.dotcms.rest.api.v2.asset.WebAssetResourceV2):

Endpoint Purpose
GET /api/v2/assets?path=//host/... Raw content read by host-qualified path (language, version=working|live query params)
GET /api/v2/assets/{identifier} Raw content read by identifier — same defaults as path reads (working, default language), READ permission enforced before bytes are served
PUT /api/v2/assets/save Create/update the working version only — live keeps serving its bytes
PUT /api/v2/assets/publish Create/update and publish

Writes use a flat multipart (file, path, optional language) — no nested JSON detail blob, publish intent lives in the URL. Reads stream raw bytes with explicit content type and Content-Disposition (no content-negotiation 406s, no large-file truncation).

Contract guarantees

  • Working-save preserves the live version; only publish promotes (checkin always creates a new version, so writes are rollback-able)
  • Write responses include the persisted fileSize so callers can assert what actually landed (closes the silent-success failure mode that once published a 0-byte template.vtl)
  • Zero-byte writes rejected with 400 — distinct messages for a missing file part vs a present-but-empty one
  • Omitted/blank language → site default; unknown language → 400
  • Clean statuses throughout: unknown host / path / version 404, invalid request 400, oversize upload 400, insufficient permission 403 — no generic 500s, no stacktraces in 404 bodies

Checklist

  • Java integration tests: WebAssetResourceV2IntegrationTest (round-trip reads, save-preserves-live/publish-promotes, both zero-byte 400s, unknown language, permission 403, folder-path 400, unknown host 404, fileSize assertion, .dotsass)
  • Postman: new v2 folder in WebAssets.postman_collection.json (8 requests incl. error-status cases)
  • OpenAPI annotations + regenerated openapi.yaml
  • Validated live against a local instance: full curl pass of every contract item, including v1 sanity check (both versions listed, contract untouched)

Notes for the reviewer

  • Only v1 change: WebAssetHelper.parseLang widened from package-private to public (additive, no behavior change)
  • DotContentletStateException (core's 0-length checkin guard) already maps to 400 via the existing DotStateExceptionMapper; the resource-level zero-byte check makes the message explicit and distinct
  • Known cosmetic edge: a path pointing at a folder returns the correct 400, but the IllegalArgumentException body includes a stacktrace (same generic mapper used elsewhere) — can be tightened in a follow-up

🤖 Generated with Claude Code

…assets

Adds a purpose-built v2 REST surface for file assets, addressable by
host-qualified path (//host/...) or identifier, with publish intent
expressed in the URL instead of request-body flags:

- GET /api/v2/assets?path=//host/... and GET /api/v2/assets/{identifier}
  stream raw bytes (explicit content type + Content-Disposition, no
  content negotiation), defaulting to working version / default language
- PUT /api/v2/assets/save writes the working version only; the live
  version keeps serving its bytes until PUT /api/v2/assets/publish
- Flat multipart (file, path, language) — no nested JSON detail blob
- Write responses include the persisted fileSize so callers can assert
  what actually landed
- Zero-byte uploads rejected with 400: distinct messages for a missing
  file part vs a present-but-empty one
- Clean error statuses throughout: unknown host / path / language
  version 404, invalid request 400, oversize 400, no permission 403
- Identifier reads enforce READ permission before serving bytes

Business logic stays in v1's WebAssetHelper (only parseLang visibility
widened); /api/v1/assets contract is unchanged.

Includes integration tests (WebAssetResourceV2IntegrationTest), a v2
folder in the WebAssets Postman collection, and regenerated OpenAPI spec.

Closes #35928

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@claude

claude Bot commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Claude finished @fmontes's task in 2m 19s —— View job


Code Review

  • Read changed files
  • Analyze for bugs, security issues, design problems
  • Post findings

Issues Found

1. Language.toString() used as a re-parseable language tag (fragile, medium risk)

WebAssetResourceV2.java:200 and :544:

final String resolvedLang = resolveLanguage(language).toString();

The resolved Language object's toString() is fed back into AssetsRequestForm.language(), which the v1 helper will re-parse via parseLang(). Language.toString() is not documented to return a parseLang-compatible string. If Language.toString() returns something like Language{languageCode=en, countryCode=US, ...} or just the display name, parseLang will fail to match it and the round-trip silently breaks. Use an explicit format instead:

final String resolvedLang = lang.getLanguageCode() + "-" + lang.getCountryCode();

or use the language ID directly. The integration tests currently pass null language (defaults), so this path may not be exercised under test. Fix this →


2. IllegalStateException in buildFileAssetResponse is unmapped (medium)

WebAssetResourceV2.java:564-567:

if (!(view instanceof AssetView)) {
    throw new IllegalStateException(
            "Unexpected view type returned by saveUpdateAsset: " + view.getClass().getName());
}

IllegalStateException has no JAX-RS exception mapper in this codebase. Clients would receive a 500 with a full stack trace instead of a clean error response. This defensive branch should either be mapped (e.g. throw a BadRequestException or DotRuntimeException) or at minimum log before throwing. Fix this →


3. Permission test mutates shared folder state without cleanup (test reliability, low-medium)

WebAssetResourceV2IntegrationTest.java:482-483:

final PermissionAPI permissionAPI = APILocator.getPermissionAPI();
permissionAPI.resetPermissionsUnder(folder);

folder is the shared static test fixture. resetPermissionsUnder modifies it globally and is never restored. Any test that runs after this one and depends on the default permissions of that folder will see unexpected behavior. Add a @After/@AfterClass cleanup or use a locally scoped folder for this test. Fix this →


4. Dead variable f2 in testSavePreservesLive_thenPublishPromotes (test clarity, low)

WebAssetResourceV2IntegrationTest.java:199:

final File f2 = tmpFile(f1.getName().replace(".txt", ""), ".txt", content2);

f2 is assigned but never referenced again. The disposition(f1) on line 201 uses f1's metadata for both save calls, which means the "save v2" step uploads content2 bytes but with f1's filename in the disposition. Whether this is intentional is unclear but f2 should either be used or removed. Fix this →


5. Path validation before authentication in getByPath (minor)

WebAssetResourceV2.java:188-193 — the path null-check fires before authenticate(), so unauthenticated callers can probe the validation logic without credentials. Low impact (no data leaks), but the pattern is inconsistent with getById which authenticates first. Worth aligning for consistency.


6. PUT /save and PUT /publish return 200 for both creates and updates

REST convention is 201 on create, 200 on update. The helper doesn't currently distinguish, so this would need changes downstream. Noting for awareness — not a blocker.


Clean

  • resolveVersionParam() correctly rejects unknown values with 400 ✓
  • Try.of(...).getOrNull() wrapper in findContentlet was correctly dropped; exceptions propagate ✓
  • isFileAsset() check before permission check in getById is correctly ordered ✓
  • BufferedInputStream mark/reset approach for zero-byte detection is correct ✓
  • UtilMethods.encodeURL() on filename before Content-Disposition is correct ✓

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new /api/v2/assets REST surface for streaming raw file-asset bytes (by host-qualified path or identifier) and for writing assets via a simplified multipart contract, while reusing the existing v1 WebAssetHelper business logic to keep /api/v1/assets contract-stable (CLI-safe).

Changes:

  • Introduces WebAssetResourceV2 with GET-by-path/identifier and PUT save/publish endpoints, plus dedicated response/view models that include persisted fileSize.
  • Updates OpenAPI spec to document the new v2 endpoints and schemas.
  • Adds Postman coverage and a new Java integration test suite validating key contract guarantees and error cases.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml Adds “File Assets” tag and documents /v2/assets endpoints + response schemas.
dotCMS/src/main/java/com/dotcms/rest/api/v2/asset/WebAssetResourceV2.java New v2 resource implementing streaming reads and multipart writes reusing v1 helper logic.
dotCMS/src/main/java/com/dotcms/rest/api/v2/asset/ResponseEntityFileAssetView.java Concrete ResponseEntityView wrapper for OpenAPI/schema accuracy.
dotCMS/src/main/java/com/dotcms/rest/api/v2/asset/FileAssetView.java New lightweight write-response view including fileSize.
dotCMS/src/main/java/com/dotcms/rest/api/v1/asset/WebAssetHelper.java Makes parseLang public to reuse language parsing from v2.
dotcms-postman/src/main/resources/postman/WebAssets.postman_collection.json Adds a v2 Postman folder covering success and error-status cases.
dotcms-integration/src/test/java/com/dotcms/rest/api/v2/asset/WebAssetResourceV2IntegrationTest.java New integration tests validating read/write semantics, permissions, and validation behavior.

Comment thread dotCMS/src/main/java/com/dotcms/rest/api/v2/asset/WebAssetResourceV2.java Outdated
Comment thread dotCMS/src/main/java/com/dotcms/rest/api/v2/asset/WebAssetResourceV2.java Outdated
RestEndpointAnnotationComplianceTest flagged the new resource:
- @tag description removed from the class (descriptions belong in
  DotRestApplication, where the File Assets tag is already defined)
- save/publish @operation now declare an operation-level @RequestBody
  with a multipart/form-data schema, per the multipart documentation
  standard

Regenerated openapi.yaml accordingly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Reject malformed multipart where the file stream is present but the
  Content-Disposition is missing (400 instead of NPE -> 500)
- Encode the filename in the Content-Disposition response header
  (UtilMethods.encodeURL, matching BinaryExporterServlet) to prevent
  invalid headers / header injection from stored filenames
- Validate the version query param: only 'working' and 'live' accepted,
  anything else is 400 instead of silently selecting working
- Stop swallowing lookup exceptions in findContentlet: DotDataException
  and DotSecurityException now propagate to their mappers instead of
  being masked as 404

Adds integration tests for the invalid-version and missing-disposition
400s; regenerated openapi.yaml.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Collapse resolveLanguageParam (returned tag String) and resolveLanguageId
(returned long id) into a single resolveLanguage() that returns the
resolved Language. Call sites derive the tag (path reads/writes) or id
(identifier reads) as needed. Removes the duplicated parseLang/blank-default
logic and the two slightly-divergent "Unknown language tag" messages.

No behavior change; openapi.yaml unchanged (internal refactor only).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Quality cleanup, no behavior change:
- Use the shared MimeTypeUtils.getMimeType(File) instead of a hand-rolled
  URLConnection.guessContentTypeFromName fallback (better detection chain,
  fewer lines)
- Collapse the duplicated WebResource.InitBuilder boilerplate (repeated in
  all four endpoints) into a single authenticate() helper so the auth policy
  stays consistent
- Avoid the double file stat in buildFileResponse (exists() + length() ->
  one length() read)

Verified: core build, RestEndpointAnnotationComplianceTest, and
dotcms-integration test-compile all pass; openapi.yaml unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.

Comment thread dotCMS/src/main/java/com/dotcms/rest/api/v2/asset/WebAssetResourceV2.java Outdated
- getById: check isFileAsset() before the READ-permission check so a
  non-file identifier always returns the contract-required 404 regardless
  of the caller's permission, instead of a 403
- Remove the unused java.io.Serializable import
- Remove the dead assetPath2() test helper

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI: Safe To Rollback Area : Backend PR changes Java/Maven backend code

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

feat(rest): add /api/v2/assets endpoint for reading and writing file assets

2 participants