This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git
The following commit(s) were added to refs/heads/main by this push:
new 5de9bf9476 feat(file-service): complete the model file API for the
management UI (#7922)
5de9bf9476 is described below
commit 5de9bf94766d441e7d1655bf326a9208dc6769a5
Author: Tanishq Gandhi <[email protected]>
AuthorDate: Wed Aug 26 04:16:48 2026 +0000
feat(file-service): complete the model file API for the management UI
(#7922)
### What changes were proposed in this PR?
#6869 and #6872 gave models create/list/access/version-upload. This PR
adds the remaining read and staged-change endpoints, so the model
management UI can be built as frontend-only work.
Nine endpoints on `ModelResource`:
| Endpoint | Purpose |
| --- | --- |
| `GET /model/presign-download` (+ `-s3`, and `public-` variants of
both) | signed URL for one file — preview, and how a worker reads a
model |
| `GET /model/{mid}/diff` | list staged (uncommitted) changes |
| `PUT /model/{mid}/diff?filePath=` | discard one staged change |
| `POST /model/{mid}/existing-upload-files` | which files the repo
already holds at the same size, so the client can skip re-uploading
large weights |
| `GET /model/{mid}/versionZip?mvid=\|latest=true` | download a whole
version |
| `GET /model/user-model-owners` | owner facet for the list page |
None of it is a second copy of `DatasetResource`. Each piece moved into
`ResourceUploadService`/`ResourceAccess` parameterised by
`ResourceStorage`, and the dataset endpoints now call the same code:
`DatasetResource` **−285 lines**, shared layer +226, `ModelResource`
+233. Nine new endpoints for a net reduction in production code.
Also fixes `listModels` reporting `size = 0` for models reached by grant
while computing it for public ones.
`framework` and `format` are now validated against a known set (400
otherwise) so a loader can dispatch on the value. They stay metadata
only — a model is a folder, and no per-file extension check is applied.
### Any related issues, documentation, discussions?
- Closes #6498
- Part of #6494
- Replaces #6974, which could not be reopened after the branch was
rebuilt on the current stack
- Stacked on #6872, which is stacked on #6869; base is `main`, so the
diff carries those two PRs' commits until they merge. The other two
parents, #7762 and #7764, have already landed.
### How was this PR tested?
New `ModelApiForUiSpec` and `ModelDownloadResourceSpec` cover every
endpoint, including negative cases (no access, private model anonymous,
`mvid` and `latest` both/neither). The presign tests fetch the URL and
compare bytes end to end through MinIO.
```
sbt "FileService/test" # 376/376
sbt "WorkflowCore/test" # 787/787
sbt scalafmtCheckAll "scalafixAll --check"
```
### Was this PR authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Claude Opus 5)
---------
Co-authored-by: ali <[email protected]>
Co-authored-by: Claude Opus 4.8 <[email protected]>
---
.../texera/service/resource/DatasetResource.scala | 276 ++-------------
.../texera/service/resource/ModelResource.scala | 237 +++++++++++--
.../texera/service/resource/ResourceAccess.scala | 20 ++
.../service/resource/ResourceUploadService.scala | 280 ++++++++++++++-
.../texera/service/type/ResourceUploadTypes.scala | 36 ++
.../service/resource/DatasetResourceSpec.scala | 29 +-
.../service/resource/ModelApiForUiSpec.scala | 380 +++++++++++++++++++++
.../resource/ModelDownloadResourceSpec.scala | 352 +++++++++++++++++++
.../resource/ModelResourcePermissionsSpec.scala | 12 +-
.../service/resource/ModelUploadResourceSpec.scala | 31 +-
.../service/resource/ResourceTestHelpers.scala | 61 ++++
11 files changed, 1405 insertions(+), 309 deletions(-)
diff --git
a/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala
b/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala
index 9f34dae154..f8bc532909 100644
---
a/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala
+++
b/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala
@@ -36,7 +36,6 @@ import org.apache.texera.dao.SqlServer.withTransaction
import org.apache.texera.dao.jooq.generated.enums.{PrivilegeEnum, UserRoleEnum}
import org.apache.texera.dao.jooq.generated.tables.Dataset.DATASET
import
org.apache.texera.dao.jooq.generated.tables.DatasetContributor.DATASET_CONTRIBUTOR
-import
org.apache.texera.dao.jooq.generated.tables.DatasetUserAccess.DATASET_USER_ACCESS
import
org.apache.texera.dao.jooq.generated.tables.DatasetVersion.DATASET_VERSION
import org.apache.texera.dao.jooq.generated.tables.User.USER
import org.apache.texera.dao.jooq.generated.tables.daos.{
@@ -49,7 +48,7 @@ import org.apache.texera.dao.jooq.generated.tables.pojos.{
DatasetUserAccess,
DatasetVersion
}
-import org.apache.texera.service.`type`.LakeFSFileNode
+import org.apache.texera.service.`type`.{Diff, ExistingUploadFilesRequest,
LakeFSFileNode}
import org.apache.texera.service.resource.DatasetAccessResource._
import org.apache.texera.service.resource.ResourceTables.{Dataset =>
DATASET_RESOURCE}
import org.apache.texera.service.resource.DatasetResource.{context, _}
@@ -57,13 +56,10 @@ import org.apache.texera.service.util.S3StorageClient
import org.jooq.impl.DSL
import org.jooq.{DSLContext, EnumType}
-import java.io.{InputStream, OutputStream}
-import java.net.{URI, URLDecoder}
-import java.nio.charset.StandardCharsets
-import java.nio.file.{Files, Paths}
+import java.io.InputStream
+import java.net.URI
import java.util
import java.util.Optional
-import java.util.zip.{ZipEntry, ZipOutputStream}
import scala.jdk.CollectionConverters._
import scala.jdk.OptionConverters._
import org.apache.commons.io.FilenameUtils
@@ -264,16 +260,8 @@ object DatasetResource {
contributors: Option[List[Contributor]] = None
)
- case class Diff(
- path: String,
- pathType: String,
- diffType: String, // "added", "removed", "changed", etc.
- sizeBytes: Option[Long] // Size of the changed file (None for
directories)
- )
-
- case class ExistingUploadFile(path: String, sizeBytes: Long)
-
- case class ExistingUploadFilesRequest(files: List[ExistingUploadFile])
+ val ExistingUploadFilesRequest:
org.apache.texera.service.`type`.ExistingUploadFilesRequest.type =
+ org.apache.texera.service.`type`.ExistingUploadFilesRequest
case class DatasetDescriptionModification(did: Integer, description: String)
@@ -820,28 +808,7 @@ class DatasetResource extends LazyLogging {
@PathParam("did") did: Integer,
@Auth user: SessionUser
): List[Diff] = {
- val uid = user.getUid
- withTransaction(context) { ctx =>
- if (!userHasReadAccess(ctx, did, uid)) {
- throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_DATASET_MESSAGE)
- }
-
- // Retrieve staged (uncommitted) changes from LakeFS
- val dataset = getDatasetByID(ctx, did)
- val lakefsDiffs = withLakeFSErrorHandling {
-
LakeFSStorageClient.retrieveUncommittedObjects(dataset.getRepositoryName)
- }
-
- // Convert LakeFS Diff objects to our custom Diff case class
- lakefsDiffs.map(d =>
- new Diff(
- d.getPath,
- d.getPathType.getValue,
- d.getType.getValue,
- Option(d.getSizeBytes).map(_.longValue())
- )
- )
- }
+ ResourceUploadService.stagedChanges(ResourceStorage.Dataset, did,
user.getUid)
}
@POST
@@ -853,54 +820,13 @@ class DatasetResource extends LazyLogging {
request: ExistingUploadFilesRequest,
@Auth user: SessionUser
): Response = {
- val uid = user.getUid
- withTransaction(context) { ctx =>
- if (!userHasWriteAccess(ctx, did, uid)) {
- throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_DATASET_MESSAGE)
- }
-
- val requested = Option(request)
- .flatMap(request => Option(request.files))
- .getOrElse(List.empty)
- .map { file =>
- val originalPath = file.path
- val path =
ResourceNaming.validateAndNormalizeFilePathOrThrow(originalPath)
- if (file.sizeBytes < 0L) throw new BadRequestException("sizeBytes
must be >= 0")
- (path, originalPath, file.sizeBytes)
- }
-
- val dataset = getDatasetByID(ctx, did)
- val committed = getLatestDatasetVersion(ctx, did)
- .map { v =>
- withLakeFSErrorHandling(
- s"retrieving committed files of dataset '${dataset.getName}'"
- ) {
- LakeFSStorageClient
- .retrieveObjectsOfVersion(dataset.getRepositoryName,
v.getVersionHash)
- .map(obj => obj.getPath -> obj.getSizeBytes.longValue())
- }
- }
- .getOrElse(List.empty)
-
- val staged = withLakeFSErrorHandling(
- s"retrieving staged files of dataset '${dataset.getName}'"
- ) {
-
LakeFSStorageClient.retrieveUncommittedObjects(dataset.getRepositoryName)
- }
- .filterNot(diff =>
Option(diff.getType).exists(_.getValue.equalsIgnoreCase("removed")))
- .flatMap(diff => Option(diff.getSizeBytes).map(size => diff.getPath ->
size.longValue()))
-
- val existing = (committed ++ staged).toMap
- val matches = requested
- .collect {
- case (path, originalPath, size) if existing.get(path).contains(size)
=> originalPath
- }
- .toList
- .distinct
- .sorted
-
- Response.ok(Map("filePaths" -> matches.asJava)).build()
- }
+ ResourceUploadService.matchExistingUploads(
+ ResourceStorage.Dataset,
+ did,
+ user.getUid,
+ request,
+ ctx => getLatestDatasetVersion(ctx, did).map(_.getVersionHash)
+ )
}
@PUT
@@ -912,20 +838,12 @@ class DatasetResource extends LazyLogging {
@QueryParam("filePath") encodedFilePath: String,
@Auth user: SessionUser
): Response = {
- val uid = user.getUid
- withTransaction(context) { ctx =>
- if (!userHasWriteAccess(ctx, did, uid)) {
- throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_DATASET_MESSAGE)
- }
- val repositoryName = getDatasetByID(ctx, did).getRepositoryName
-
- // Decode the file path
- val filePath = URLDecoder.decode(encodedFilePath,
StandardCharsets.UTF_8.name())
- withLakeFSErrorHandling(s"resetting uncommitted changes of file
'$filePath'") {
- LakeFSStorageClient.resetObjectUploadOrDeletion(repositoryName,
filePath)
- }
- Response.ok().build()
- }
+ ResourceUploadService.resetStagedChange(
+ ResourceStorage.Dataset,
+ did,
+ encodedFilePath,
+ user.getUid
+ )
}
/**
@@ -1085,50 +1003,12 @@ class DatasetResource extends LazyLogging {
throw new BadRequestException("Invalid parameters")
}
- // Retrieve dataset and version details
- val datasetName = dataset.getName
- val repositoryName = dataset.getRepositoryName
- val versionHash = datasetVersion.getVersionHash
- val objects = withLakeFSErrorHandling(
- s"listing files of version '$versionHash' of dataset '$datasetName'"
- ) {
- LakeFSStorageClient.retrieveObjectsOfVersion(repositoryName,
versionHash)
- }
-
- if (objects.isEmpty) {
- return Response
- .status(Response.Status.NOT_FOUND)
- .entity(s"No objects found in version $versionHash of repository
$repositoryName")
- .build()
- }
-
- // StreamingOutput for ZIP download
- val streamingOutput = new StreamingOutput {
- override def write(outputStream: OutputStream): Unit = {
- val zipOut = new ZipOutputStream(outputStream)
- try {
- objects.foreach { obj =>
- val filePath = obj.getPath
- val file = withLakeFSErrorHandling(s"downloading file
'$filePath' for the zip") {
- LakeFSStorageClient.getFileFromRepo(repositoryName,
versionHash, filePath)
- }
-
- zipOut.putNextEntry(new ZipEntry(filePath))
- Files.copy(Paths.get(file.toURI), zipOut)
- zipOut.closeEntry()
- }
- } finally {
- zipOut.close()
- }
- }
- }
-
- val zipFilename = s"""attachment;
filename="$datasetName-${datasetVersion.getName}.zip""""
-
- Response
- .ok(streamingOutput, "application/zip")
- .header("Content-Disposition", zipFilename)
- .build()
+ ResourceUploadService.versionZipResponse(
+ dataset.getRepositoryName,
+ datasetVersion.getVersionHash,
+ dataset.getName,
+ datasetVersion.getName
+ )
}
}
@@ -1182,17 +1062,10 @@ class DatasetResource extends LazyLogging {
@GET
@RolesAllowed(Array("REGULAR", "ADMIN"))
@Path("/user-dataset-owners")
- def retrieveOwners(@Auth user: SessionUser): util.List[String] = {
- context
- .selectDistinct(USER.EMAIL)
- .from(USER)
- .join(DATASET)
- .on(DATASET.OWNER_UID.eq(USER.UID))
- .join(DATASET_USER_ACCESS)
- .on(DATASET_USER_ACCESS.DID.eq(DATASET.DID))
- .where(DATASET_USER_ACCESS.UID.eq(user.getUid))
- .fetchInto(classOf[String])
- }
+ def retrieveOwners(@Auth user: SessionUser): util.List[String] =
+ withTransaction(context)(ctx =>
+ ResourceAccess.ownerEmailsVisibleTo(ctx, DATASET_RESOURCE, user.getUid)
+ )
/** @see [[ResourceNaming.validateName]] */
private def validateDatasetName(name: String): Unit =
@@ -1236,89 +1109,14 @@ class DatasetResource extends LazyLogging {
repositoryName: String,
commitHash: String,
uid: Integer
- ): Response = {
- resolveDatasetAndPath(encodedUrl, repositoryName, commitHash, uid) match {
- case Left(errorResponse) =>
- errorResponse
-
- case Right((resolvedRepositoryName, resolvedCommitHash,
resolvedFilePath)) =>
- val url = withLakeFSErrorHandling(
- s"generating a presigned URL for file '$resolvedFilePath'"
- ) {
- LakeFSStorageClient.getFilePresignedUrl(
- resolvedRepositoryName,
- resolvedCommitHash,
- resolvedFilePath
- )
- }
-
- Response.ok(Map("presignedUrl" -> url)).build()
- }
- }
-
- private def resolveDatasetAndPath(
- encodedUrl: String,
- repositoryName: String,
- commitHash: String,
- uid: Integer
- ): Either[Response, (String, String, String)] = {
- val decodedPathStr = URLDecoder.decode(encodedUrl,
StandardCharsets.UTF_8.name())
-
- (Option(repositoryName), Option(commitHash)) match {
- case (Some(_), None) | (None, Some(_)) =>
- // Case 1: Only one parameter is provided (error case)
- Left(
- Response
- .status(Response.Status.BAD_REQUEST)
- .entity(
- "Both repositoryName and commitHash must be provided together,
or neither should be provided."
- )
- .build()
- )
-
- case (Some(repositoryName), Some(commit)) =>
- // Case 2: repositoryName and commitHash are provided, validate access
- val response = withTransaction(context) { ctx =>
- val datasetDao = new DatasetDao(ctx.configuration())
- val datasets =
datasetDao.fetchByRepositoryName(repositoryName).asScala.toList
-
- if (datasets.isEmpty || !userHasReadAccess(ctx,
datasets.head.getDid, uid))
- throw new
ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_DATASET_MESSAGE)
-
- val dataset = datasets.head
- // Standard read access check only - download restrictions handled
per endpoint
- // Non-download operations (viewing) should work for all public
datasets
-
- (repositoryName, commit, decodedPathStr)
- }
- Right(response)
-
- case (None, None) =>
- // Case 3: Neither repositoryName nor commitHash are provided, resolve
normally
- val response = withTransaction(context) { ctx =>
- val fileUri = FileResolver.resolve(decodedPathStr)
- val document =
-
DocumentFactory.openReadonlyDocument(fileUri).asInstanceOf[OnVersionedFileResource]
- val datasetDao = new DatasetDao(ctx.configuration())
- val datasets =
-
datasetDao.fetchByRepositoryName(document.getRepositoryName()).asScala.toList
-
- if (datasets.isEmpty || !userHasReadAccess(ctx,
datasets.head.getDid, uid))
- throw new
ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_DATASET_MESSAGE)
-
- val dataset = datasets.head
- // Standard read access check only - download restrictions handled
per endpoint
- // Non-download operations (viewing) should work for all public
datasets
-
- (
- document.getRepositoryName(),
- document.getVersionHash(),
- document.getFileRelativePath()
- )
- }
- Right(response)
- }
- }
+ ): Response =
+ ResourceUploadService.presignedUrlResponse(
+ ResourceStorage.Dataset,
+ encodedUrl,
+ repositoryName,
+ commitHash,
+ uid
+ )
// === Multipart helpers ===
diff --git
a/file-service/src/main/scala/org/apache/texera/service/resource/ModelResource.scala
b/file-service/src/main/scala/org/apache/texera/service/resource/ModelResource.scala
index ec7c1ac774..7c323928c8 100644
---
a/file-service/src/main/scala/org/apache/texera/service/resource/ModelResource.scala
+++
b/file-service/src/main/scala/org/apache/texera/service/resource/ModelResource.scala
@@ -28,7 +28,7 @@ import org.apache.texera.amber.core.storage.ResourceType
import org.apache.texera.amber.core.storage.util.LakeFSStorageClient
import org.apache.texera.auth.SessionUser
import org.apache.texera.common.config.StorageConfig
-import org.apache.texera.dao.SqlServer
+import org.apache.texera.dao.{SiteSettings, SqlServer}
import org.apache.texera.dao.SqlServer.withTransaction
import org.apache.texera.dao.jooq.generated.enums.PrivilegeEnum
import org.apache.texera.dao.jooq.generated.tables.Model.MODEL
@@ -36,7 +36,7 @@ import
org.apache.texera.dao.jooq.generated.tables.ModelVersion.MODEL_VERSION
import org.apache.texera.dao.jooq.generated.tables.User.USER
import org.apache.texera.dao.jooq.generated.tables.daos.{ModelDao,
ModelUserAccessDao}
import org.apache.texera.dao.jooq.generated.tables.pojos.{Model,
ModelUserAccess, ModelVersion}
-import org.apache.texera.service.`type`.LakeFSFileNode
+import org.apache.texera.service.`type`.{Diff, ExistingUploadFilesRequest,
LakeFSFileNode}
import org.apache.texera.service.resource.ResourceTables.{Model =>
MODEL_RESOURCE}
import org.apache.texera.service.resource.ModelAccessResource._
import org.apache.texera.service.resource.ModelResource.{context, _}
@@ -52,7 +52,24 @@ import scala.jdk.OptionConverters._
object ModelResource {
// MVP supports a single framework; stored on the model so later frameworks
can be added.
- private val DEFAULT_FRAMEWORK = "pytorch"
+ // Callers may omit the framework; it is a display label, not a validation
gate for files.
+ val DEFAULT_FRAMEWORK = "pytorch"
+
+ // Recognised values for the `framework` and `format` labels. They are
metadata, not file
+ // checks -- a loader dispatches on them, so an unknown value is rejected at
creation
+ // rather than surfacing later as an unloadable model.
+ val SUPPORTED_FRAMEWORKS: Set[String] = Set("pytorch", "tensorflow", "onnx",
"sklearn")
+
+ val SUPPORTED_FORMATS: Set[String] =
+ Set("torchscript", "state-dict", "safetensors", "onnx", "savedmodel",
"joblib", "pickle")
+
+ private def validateLabel(field: String, value: String, allowed:
Set[String]): Unit = {
+ if (!allowed.contains(value)) {
+ throw new BadRequestException(
+ s"Unsupported $field '$value'. Supported values:
${allowed.toList.sorted.mkString(", ")}."
+ )
+ }
+ }
// Matches model_version.name VARCHAR(128).
private val MAX_VERSION_NAME_LENGTH = 128
@@ -64,6 +81,9 @@ object ModelResource {
.getInstance()
.createDSLContext()
+ private def singleFileUploadMaxBytes(defaultMiB: Long = 20L): Long =
+ SiteSettings.getLong("single_file_upload_max_size_mib", defaultMiB) *
1024L * 1024L
+
/**
* Helper function to get the model from DB using mid
*/
@@ -206,8 +226,14 @@ class ModelResource extends LazyLogging {
model.setIsPublic(isModelPublic)
model.setIsDownloadable(isModelDownloadable)
model.setOwnerUid(uid)
-
model.setFramework(Option(request.framework).filter(_.nonEmpty).getOrElse(DEFAULT_FRAMEWORK))
- model.setFormat(request.format)
+ val framework =
+
Option(request.framework).map(_.trim).filter(_.nonEmpty).getOrElse(DEFAULT_FRAMEWORK)
+ validateLabel("framework", framework, SUPPORTED_FRAMEWORKS)
+ val format = Option(request.format).map(_.trim).filter(_.nonEmpty)
+ format.foreach(validateLabel("format", _, SUPPORTED_FORMATS))
+
+ model.setFramework(framework)
+ model.setFormat(format.orNull)
// insert record and get created model with mid
val createdModel =
ResourceNaming.failOnDuplicateName(MODEL_RESOURCE.label) {
@@ -418,28 +444,19 @@ class ModelResource extends LazyLogging {
model = model,
accessPrivilege = privilege,
ownerEmail = ownerEmail,
- size = 0
+ size = repositorySizeOrZero(model)
)
),
fromPublic = (model, ownerEmail) =>
- try {
- Some(
- DashboardModel(
- isOwner = false,
- model = model,
- accessPrivilege = PrivilegeEnum.READ,
- ownerEmail = ownerEmail,
- size =
LakeFSStorageClient.retrieveRepositorySize(model.getRepositoryName)
- )
+ Some(
+ DashboardModel(
+ isOwner = false,
+ model = model,
+ accessPrivilege = PrivilegeEnum.READ,
+ ownerEmail = ownerEmail,
+ size = repositorySizeOrZero(model)
)
- } catch {
- case e: io.lakefs.clients.sdk.ApiException =>
- logger.error(
- s"LakeFS ApiException for model repository
'${model.getRepositoryName}': ${e.getMessage}",
- e
- )
- None
- }
+ )
)
})
}
@@ -464,6 +481,180 @@ class ModelResource extends LazyLogging {
withTransaction(context)(ctx => getDashboardModel(ctx, mid, None))
}
+ @GET
+ @RolesAllowed(Array("REGULAR", "ADMIN"))
+ @Path("/{mid}/versionZip")
+ def getModelVersionZip(
+ @PathParam("mid") mid: Integer,
+ @QueryParam("mvid") mvid: Integer,
+ @QueryParam("latest") latest: java.lang.Boolean,
+ @Auth user: SessionUser
+ ): Response =
+ withTransaction(context) { ctx =>
+ if ((mvid != null && latest != null) || (mvid == null && latest ==
null)) {
+ throw new BadRequestException("Specify exactly one: mvid=<ID> OR
latest=true")
+ }
+
+ val uid = user.getUid
+ if (!userHasReadAccess(ctx, mid, uid)) {
+ throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_MODEL_MESSAGE)
+ }
+
+ val model = getModelByID(ctx, mid)
+ // Non-owners may download only while the owner leaves the model
downloadable.
+ if (!userOwnModel(ctx, mid, uid) && !model.getIsDownloadable) {
+ throw new ForbiddenException("Model download is not allowed")
+ }
+
+ // latest=false is not "give me the latest": only TRUE selects it,
anything else is a 400.
+ val modelVersion =
+ if (mvid != null) getModelVersionByID(ctx, mid, mvid)
+ else if (java.lang.Boolean.TRUE.equals(latest))
+ getLatestModelVersion(ctx, mid).getOrElse(
+ throw new NotFoundException(ERR_MODEL_VERSION_NOT_FOUND_MESSAGE)
+ )
+ else throw new BadRequestException("Invalid parameters")
+
+ ResourceUploadService.versionZipResponse(
+ model.getRepositoryName,
+ modelVersion.getVersionHash,
+ model.getName,
+ modelVersion.getName
+ )
+ }
+
+ /** Owner facet for the model list page. */
+ @GET
+ @RolesAllowed(Array("REGULAR", "ADMIN"))
+ @Path("/user-model-owners")
+ def retrieveOwners(@Auth user: SessionUser): java.util.List[String] =
+ withTransaction(context)(ctx =>
+ ResourceAccess.ownerEmailsVisibleTo(ctx, MODEL_RESOURCE, user.getUid)
+ )
+
+ //
===========================================================================
+ // Staged changes
+ //
===========================================================================
+
+ @GET
+ @RolesAllowed(Array("REGULAR", "ADMIN"))
+ @Path("/{mid}/diff")
+ def getModelDiff(
+ @PathParam("mid") mid: Integer,
+ @Auth user: SessionUser
+ ): List[Diff] =
+ ResourceUploadService.stagedChanges(ResourceStorage.Model, mid,
user.getUid)
+
+ @PUT
+ @RolesAllowed(Array("REGULAR", "ADMIN"))
+ @Path("/{mid}/diff")
+ def resetModelFileDiff(
+ @PathParam("mid") mid: Integer,
+ @QueryParam("filePath") encodedFilePath: String,
+ @Auth user: SessionUser
+ ): Response =
+ ResourceUploadService.resetStagedChange(
+ ResourceStorage.Model,
+ mid,
+ encodedFilePath,
+ user.getUid
+ )
+
+ @POST
+ @RolesAllowed(Array("REGULAR", "ADMIN"))
+ @Path("/{mid}/existing-upload-files")
+ @Consumes(Array(MediaType.APPLICATION_JSON))
+ def findExistingUploadFiles(
+ @PathParam("mid") mid: Integer,
+ request: ExistingUploadFilesRequest,
+ @Auth user: SessionUser
+ ): Response =
+ ResourceUploadService.matchExistingUploads(
+ ResourceStorage.Model,
+ mid,
+ user.getUid,
+ request,
+ ctx => getLatestModelVersion(ctx, mid).map(_.getVersionHash)
+ )
+
+ //
===========================================================================
+ // Presigned downloads
+ //
===========================================================================
+
+ /**
+ * Resolves a presign request against the model tables and wraps the signed
URL.
+ * The resolution itself is shared with datasets; only the descriptor
differs.
+ */
+ /** Size of a model's LakeFS repository, or 0 if LakeFS cannot answer. */
+ private def repositorySizeOrZero(model: Model): Long = {
+ try {
+ LakeFSStorageClient.retrieveRepositorySize(model.getRepositoryName)
+ } catch {
+ case e: io.lakefs.clients.sdk.ApiException =>
+ logger.error(
+ s"LakeFS ApiException for model repository
'${model.getRepositoryName}': ${e.getMessage}",
+ e
+ )
+ 0L
+ }
+ }
+
+ private def generatePresignedResponse(
+ encodedUrl: String,
+ repositoryName: String,
+ commitHash: String,
+ uid: Integer
+ ): Response =
+ ResourceUploadService.presignedUrlResponse(
+ ResourceStorage.Model,
+ encodedUrl,
+ repositoryName,
+ commitHash,
+ uid
+ )
+
+ @GET
+ @RolesAllowed(Array("REGULAR", "ADMIN"))
+ @Path("/presign-download")
+ def getPresignedUrl(
+ @QueryParam("filePath") encodedUrl: String,
+ @QueryParam("repositoryName") repositoryName: String,
+ @QueryParam("commitHash") commitHash: String,
+ @Auth user: SessionUser
+ ): Response =
+ generatePresignedResponse(encodedUrl, repositoryName, commitHash,
user.getUid)
+
+ @GET
+ @RolesAllowed(Array("REGULAR", "ADMIN"))
+ @Path("/presign-download-s3")
+ def getPresignedUrlWithS3(
+ @QueryParam("filePath") encodedUrl: String,
+ @QueryParam("repositoryName") repositoryName: String,
+ @QueryParam("commitHash") commitHash: String,
+ @Auth user: SessionUser
+ ): Response =
+ generatePresignedResponse(encodedUrl, repositoryName, commitHash,
user.getUid)
+
+ @GET
+ @PermitAll
+ @Path("/public-presign-download")
+ def getPublicPresignedUrl(
+ @QueryParam("filePath") encodedUrl: String,
+ @QueryParam("repositoryName") repositoryName: String,
+ @QueryParam("commitHash") commitHash: String
+ ): Response =
+ generatePresignedResponse(encodedUrl, repositoryName, commitHash, null)
+
+ @GET
+ @PermitAll
+ @Path("/public-presign-download-s3")
+ def getPublicPresignedUrlWithS3(
+ @QueryParam("filePath") encodedUrl: String,
+ @QueryParam("repositoryName") repositoryName: String,
+ @QueryParam("commitHash") commitHash: String
+ ): Response =
+ generatePresignedResponse(encodedUrl, repositoryName, commitHash, null)
+
//
===========================================================================
// Versioning
//
===========================================================================
diff --git
a/file-service/src/main/scala/org/apache/texera/service/resource/ResourceAccess.scala
b/file-service/src/main/scala/org/apache/texera/service/resource/ResourceAccess.scala
index 6fffd1d335..3276d32b68 100644
---
a/file-service/src/main/scala/org/apache/texera/service/resource/ResourceAccess.scala
+++
b/file-service/src/main/scala/org/apache/texera/service/resource/ResourceAccess.scala
@@ -318,4 +318,24 @@ object ResourceAccess {
s"You do not have access to ${resource.label} $id"
)
}
+
+ /**
+ * Emails of the owners of every resource the caller has an explicit grant
on, for the
+ * owner facet on list pages.
+ */
+ def ownerEmailsVisibleTo[R <: Record, A <: Record](
+ ctx: DSLContext,
+ resource: ResourceTables[R, A],
+ uid: Integer
+ ): java.util.List[String] =
+ ctx
+ .selectDistinct(USER.EMAIL)
+ .from(USER)
+ .join(resource.table)
+ .on(resource.ownerUidField.eq(USER.UID))
+ .join(resource.accessTable)
+ .on(resource.accessIdField.eq(resource.idField))
+ .where(resource.accessUidField.eq(uid))
+ .fetchInto(classOf[String])
+
}
diff --git
a/file-service/src/main/scala/org/apache/texera/service/resource/ResourceUploadService.scala
b/file-service/src/main/scala/org/apache/texera/service/resource/ResourceUploadService.scala
index 693e25c510..b974cf31a8 100644
---
a/file-service/src/main/scala/org/apache/texera/service/resource/ResourceUploadService.scala
+++
b/file-service/src/main/scala/org/apache/texera/service/resource/ResourceUploadService.scala
@@ -20,9 +20,11 @@
package org.apache.texera.service.resource
import jakarta.ws.rs._
-import jakarta.ws.rs.core.{HttpHeaders, Response}
+import jakarta.ws.rs.core.{HttpHeaders, Response, StreamingOutput}
import org.apache.texera.amber.core.storage.ResourceType
+import org.apache.texera.amber.core.storage.model.OnVersionedFileResource
import org.apache.texera.amber.core.storage.util.LakeFSStorageClient
+import org.apache.texera.amber.core.storage.{DocumentFactory, FileResolver}
import org.apache.texera.common.config.StorageConfig
import org.apache.texera.dao.{SiteSettings, SqlServer}
import org.apache.texera.dao.SqlServer.withTransaction
@@ -56,9 +58,11 @@ import org.jooq.impl.DSL.{inline => inl}
import org.jooq.{DSLContext, Record, Record2, Result, Table, TableField}
import software.amazon.awssdk.services.s3.model.UploadPartResponse
-import java.io.InputStream
+import java.io.{InputStream, OutputStream}
import java.net.URLDecoder
import java.nio.charset.StandardCharsets
+import java.nio.file.{Files, Paths}
+import java.util.zip.{ZipEntry, ZipOutputStream}
import java.sql.SQLException
import java.time.OffsetDateTime
import java.util.Optional
@@ -234,6 +238,278 @@ object ResourceUploadService {
throw new NotFoundException(s"${s.resource.label.capitalize} $resourceId
not found")
)
+ /**
+ * Resolves a presign-download request to (repositoryName, commitHash,
relativePath),
+ * after confirming the caller may read the owning resource.
+ *
+ * A caller either addresses the file directly (repositoryName +
commitHash) or hands over a
+ * logical path and lets the server resolve it; supplying exactly one of
the pair is a client
+ * error. Both routes then converge: find the resource that owns the
repository, check read
+ * access, and hand back the triple. `FileResolver` already dispatches on
the path's
+ * resource-type prefix, so the logical-path route needs nothing
resource-specific here.
+ */
+ def resolveVersionedFile[R <: Record, A <: Record, S <: Record, P <: Record](
+ s: ResourceStorage[R, A, S, P],
+ encodedUrl: String,
+ repositoryName: String,
+ commitHash: String,
+ uid: Integer
+ ): Either[Response, (String, String, String)] = {
+ val decodedPath = URLDecoder.decode(encodedUrl,
StandardCharsets.UTF_8.name())
+
+ requireBothOrNeither(repositoryName, commitHash) match {
+ case Some(badRequest) => Left(badRequest)
+ case None =>
+ Right(withTransaction(context) { ctx =>
+ if (repositoryName != null) {
+ requireReadAccessToRepository(ctx, s, repositoryName, uid)
+ (repositoryName, commitHash, decodedPath)
+ } else {
+ val document = DocumentFactory
+ .openReadonlyDocument(FileResolver.resolve(decodedPath))
+ .asInstanceOf[OnVersionedFileResource]
+ requireReadAccessToRepository(ctx, s,
document.getRepositoryName(), uid)
+ (
+ document.getRepositoryName(),
+ document.getVersionHash(),
+ document.getFileRelativePath()
+ )
+ }
+ })
+ }
+ }
+
+ /**
+ * The whole presign-download endpoint: resolve the addressed file, check
read
+ * access, and hand back the signed URL. Both resources' endpoints are this
call.
+ */
+ def presignedUrlResponse[R <: Record, A <: Record, S <: Record, P <: Record](
+ s: ResourceStorage[R, A, S, P],
+ encodedUrl: String,
+ repositoryName: String,
+ commitHash: String,
+ uid: Integer
+ ): Response =
+ resolveVersionedFile(s, encodedUrl, repositoryName, commitHash, uid) match
{
+ case Left(errorResponse) => errorResponse
+ case Right((repo, commit, path)) => presignedResponse(repo, commit, path)
+ }
+
+ /**
+ * A caller either addresses a file directly (repositoryName + commitHash)
or lets
+ * the server resolve it from a logical path (neither). Exactly one is a
client error.
+ *
+ * @return Some(400 response) when only one of the two was provided, None
otherwise
+ */
+ private def requireBothOrNeither(
+ repositoryName: String,
+ commitHash: String
+ ): Option[Response] =
+ (Option(repositoryName), Option(commitHash)) match {
+ case (Some(_), None) | (None, Some(_)) =>
+ Some(
+ Response
+ .status(Response.Status.BAD_REQUEST)
+ .entity(
+ "Both repositoryName and commitHash must be provided together, "
+
+ "or neither should be provided."
+ )
+ .build()
+ )
+ case _ => None
+ }
+
+ /** Wrap a LakeFS presigned URL in the response shape the frontend expects.
*/
+ private def presignedResponse(
+ repositoryName: String,
+ commitHash: String,
+ filePath: String
+ ): Response = {
+ val url = withLakeFSErrorHandling(
+ s"generating a presigned URL for file '$filePath'"
+ ) {
+ LakeFSStorageClient.getFilePresignedUrl(repositoryName, commitHash,
filePath)
+ }
+
+ Response.ok(Map("presignedUrl" -> url)).build()
+ }
+
+ /** Read access is checked on the resource that owns the repository, not on
the file. */
+ private def requireReadAccessToRepository[R <: Record, A <: Record, S <:
Record, P <: Record](
+ ctx: DSLContext,
+ s: ResourceStorage[R, A, S, P],
+ repositoryName: String,
+ uid: Integer
+ ): Unit = {
+ val id = ctx
+ .select(s.resource.idField)
+ .from(s.resource.table)
+ .where(s.repositoryNameField.eq(repositoryName))
+ .fetchOne(s.resource.idField)
+
+ if (id == null || !ResourceAccess.userHasReadAccess(ctx, s.resource, id,
uid)) {
+ throw new ForbiddenException(noAccessMessage(s))
+ }
+ }
+
+ /**
+ * Streams every file of one committed version as a ZIP.
+ *
+ * Resolving which version, and whether the caller may download it, stays
with the
+ * resource: the download flag is not part of [[ResourceTables]]. This is
the part that
+ * is identical either way -- listing the version's objects and piping them
into a zip.
+ */
+ def versionZipResponse(
+ repositoryName: String,
+ versionHash: String,
+ displayName: String,
+ versionName: String
+ ): Response = {
+ val objects = withLakeFSErrorHandling(
+ s"listing files of version '$versionHash' of '$displayName'"
+ ) {
+ LakeFSStorageClient.retrieveObjectsOfVersion(repositoryName, versionHash)
+ }
+
+ if (objects.isEmpty) {
+ Response
+ .status(Response.Status.NOT_FOUND)
+ .entity(s"No objects found in version $versionHash of repository
$repositoryName")
+ .build()
+ } else {
+ val streamingOutput = new StreamingOutput {
+ override def write(outputStream: OutputStream): Unit = {
+ val zipOut = new ZipOutputStream(outputStream)
+ try {
+ objects.foreach { obj =>
+ val filePath = obj.getPath
+ val file = withLakeFSErrorHandling(s"downloading file
'$filePath' for the zip") {
+ LakeFSStorageClient.getFileFromRepo(repositoryName,
versionHash, filePath)
+ }
+ zipOut.putNextEntry(new ZipEntry(filePath))
+ Files.copy(Paths.get(file.toURI), zipOut)
+ zipOut.closeEntry()
+ }
+ } finally {
+ zipOut.close()
+ }
+ }
+ }
+
+ Response
+ .ok(streamingOutput, "application/zip")
+ .header("Content-Disposition", s"""attachment;
filename="$displayName-$versionName.zip"""")
+ .build()
+ }
+ }
+
+ /** Staged (uncommitted) changes in a resource's repository. Requires read
access. */
+ def stagedChanges[R <: Record, A <: Record, S <: Record, P <: Record](
+ s: ResourceStorage[R, A, S, P],
+ resourceId: Integer,
+ uid: Integer
+ ): List[org.apache.texera.service.`type`.Diff] =
+ withTransaction(context) { ctx =>
+ if (!ResourceAccess.userHasReadAccess(ctx, s.resource, resourceId, uid))
{
+ throw new ForbiddenException(noAccessMessage(s))
+ }
+ val repositoryName = repositoryNameOf(ctx, s, resourceId)
+ withLakeFSErrorHandling {
+ LakeFSStorageClient.retrieveUncommittedObjects(repositoryName)
+ }.map(d =>
+ org.apache.texera.service.`type`.Diff(
+ d.getPath,
+ d.getPathType.getValue,
+ d.getType.getValue,
+ Option(d.getSizeBytes).map(_.longValue())
+ )
+ )
+ }
+
+ /** Discards one staged change, restoring the file to its last committed
state. */
+ def resetStagedChange[R <: Record, A <: Record, S <: Record, P <: Record](
+ s: ResourceStorage[R, A, S, P],
+ resourceId: Integer,
+ encodedFilePath: String,
+ uid: Integer
+ ): Response =
+ withTransaction(context) { ctx =>
+ if (!ResourceAccess.userHasWriteAccess(ctx, s.resource, resourceId,
uid)) {
+ throw new ForbiddenException(noAccessMessage(s))
+ }
+ val repositoryName = repositoryNameOf(ctx, s, resourceId)
+ val filePath = URLDecoder.decode(encodedFilePath,
StandardCharsets.UTF_8.name())
+ withLakeFSErrorHandling(s"resetting uncommitted changes of file
'$filePath'") {
+ LakeFSStorageClient.resetObjectUploadOrDeletion(repositoryName,
filePath)
+ }
+ Response.ok().build()
+ }
+
+ /**
+ * Reports which of the requested files the repository already holds at the
same size,
+ * so a client can skip re-uploading them. Committed and staged files both
count. A
+ * staged deletion contributes no match of its own, but it does not
withdraw the
+ * committed file either -- behaviour carried over verbatim from the
dataset endpoint
+ * this was extracted from.
+ *
+ * The latest committed version is read through `latestVersionHash` because
the version
+ * table is the one part of this that is resource-specific.
+ */
+ def matchExistingUploads[R <: Record, A <: Record, S <: Record, P <: Record](
+ s: ResourceStorage[R, A, S, P],
+ resourceId: Integer,
+ uid: Integer,
+ request: org.apache.texera.service.`type`.ExistingUploadFilesRequest,
+ latestVersionHash: DSLContext => Option[String]
+ ): Response =
+ withTransaction(context) { ctx =>
+ if (!ResourceAccess.userHasWriteAccess(ctx, s.resource, resourceId,
uid)) {
+ throw new ForbiddenException(noAccessMessage(s))
+ }
+
+ val requested = Option(request)
+ .flatMap(r => Option(r.files))
+ .getOrElse(List.empty)
+ .map { file =>
+ val originalPath = file.path
+ val path =
ResourceNaming.validateAndNormalizeFilePathOrThrow(originalPath)
+ if (file.sizeBytes < 0L) throw new BadRequestException("sizeBytes
must be >= 0")
+ (path, originalPath, file.sizeBytes)
+ }
+
+ val repositoryName = repositoryNameOf(ctx, s, resourceId)
+ val committed = latestVersionHash(ctx)
+ .map { hash =>
+ withLakeFSErrorHandling(
+ s"retrieving committed files of ${s.resource.label} $resourceId"
+ ) {
+ LakeFSStorageClient
+ .retrieveObjectsOfVersion(repositoryName, hash)
+ .map(obj => obj.getPath -> obj.getSizeBytes.longValue())
+ }
+ }
+ .getOrElse(List.empty)
+
+ val staged = withLakeFSErrorHandling(
+ s"retrieving staged files of ${s.resource.label} $resourceId"
+ ) {
+ LakeFSStorageClient.retrieveUncommittedObjects(repositoryName)
+ }
+ .filterNot(diff =>
Option(diff.getType).exists(_.getValue.equalsIgnoreCase("removed")))
+ .flatMap(diff => Option(diff.getSizeBytes).map(size => diff.getPath ->
size.longValue()))
+
+ val existing = (committed ++ staged).toMap
+ val matches = requested
+ .collect {
+ case (path, originalPath, size) if existing.get(path).contains(size)
=> originalPath
+ }
+ .toList
+ .distinct
+ .sorted
+
+ Response.ok(Map("filePaths" -> matches.asJava)).build()
+ }
+
/** Removes one staged (uncommitted) file from a resource's repository. */
def deleteStagedFile[R <: Record, A <: Record, S <: Record, P <: Record](
s: ResourceStorage[R, A, S, P],
diff --git
a/file-service/src/main/scala/org/apache/texera/service/type/ResourceUploadTypes.scala
b/file-service/src/main/scala/org/apache/texera/service/type/ResourceUploadTypes.scala
new file mode 100644
index 0000000000..9a9a775067
--- /dev/null
+++
b/file-service/src/main/scala/org/apache/texera/service/type/ResourceUploadTypes.scala
@@ -0,0 +1,36 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.texera.service.`type`
+
+/**
+ * A staged (uncommitted) change in a versioned resource repository.
+ *
+ * @param sizeBytes size of the changed file, None for directories
+ */
+case class Diff(
+ path: String,
+ pathType: String,
+ diffType: String, // "added", "removed", "changed", etc.
+ sizeBytes: Option[Long]
+)
+
+case class ExistingUploadFile(path: String, sizeBytes: Long)
+
+case class ExistingUploadFilesRequest(files: List[ExistingUploadFile])
diff --git
a/file-service/src/test/scala/org/apache/texera/service/resource/DatasetResourceSpec.scala
b/file-service/src/test/scala/org/apache/texera/service/resource/DatasetResourceSpec.scala
index 0dfb39909a..fd1a525331 100644
---
a/file-service/src/test/scala/org/apache/texera/service/resource/DatasetResourceSpec.scala
+++
b/file-service/src/test/scala/org/apache/texera/service/resource/DatasetResourceSpec.scala
@@ -43,6 +43,7 @@ import org.apache.texera.dao.jooq.generated.tables.pojos.{
User
}
import org.apache.texera.service.MockLakeFS
+import org.apache.texera.service.`type`.{ExistingUploadFile,
ExistingUploadFilesRequest}
import org.apache.texera.service.util.S3StorageClient
import org.jooq.SQLDialect
import org.jooq.impl.DSL
@@ -791,12 +792,12 @@ class DatasetResourceSpec
val resp = datasetResource.findExistingUploadFiles(
dataset.getDid,
- DatasetResource.ExistingUploadFilesRequest(
+ ExistingUploadFilesRequest(
List(
- DatasetResource.ExistingUploadFile("committed.csv",
committed.length),
- DatasetResource.ExistingUploadFile("staged.csv", staged.length),
- DatasetResource.ExistingUploadFile("wrong-size.csv", staged.length +
1),
- DatasetResource.ExistingUploadFile("missing.csv", 1L)
+ ExistingUploadFile("committed.csv", committed.length),
+ ExistingUploadFile("staged.csv", staged.length),
+ ExistingUploadFile("wrong-size.csv", staged.length + 1),
+ ExistingUploadFile("missing.csv", 1L)
)
),
sessionUser
@@ -838,8 +839,8 @@ class DatasetResourceSpec
val requestPath = "folder/../committed.csv"
val resp = datasetResource.findExistingUploadFiles(
dataset.getDid,
- DatasetResource.ExistingUploadFilesRequest(
- List(DatasetResource.ExistingUploadFile(requestPath, committed.length))
+ ExistingUploadFilesRequest(
+ List(ExistingUploadFile(requestPath, committed.length))
),
sessionUser
)
@@ -862,7 +863,7 @@ class DatasetResourceSpec
val resp = datasetResource.findExistingUploadFiles(
dataset.getDid,
- DatasetResource.ExistingUploadFilesRequest(null),
+ ExistingUploadFilesRequest(null),
sessionUser
)
@@ -874,8 +875,8 @@ class DatasetResourceSpec
val ex = intercept[BadRequestException] {
datasetResource.findExistingUploadFiles(
baseDataset.getDid,
- DatasetResource.ExistingUploadFilesRequest(
- List(DatasetResource.ExistingUploadFile("bad-size.csv", -1L))
+ ExistingUploadFilesRequest(
+ List(ExistingUploadFile("bad-size.csv", -1L))
),
sessionUser
)
@@ -888,8 +889,8 @@ class DatasetResourceSpec
val ex = intercept[ForbiddenException] {
datasetResource.findExistingUploadFiles(
multipartDataset.getDid,
- DatasetResource.ExistingUploadFilesRequest(
- List(DatasetResource.ExistingUploadFile("private.csv", 1L))
+ ExistingUploadFilesRequest(
+ List(ExistingUploadFile("private.csv", 1L))
),
multipartNoWriteSessionUser
)
@@ -919,8 +920,8 @@ class DatasetResourceSpec
val ex = intercept[NotFoundException] {
datasetResource.findExistingUploadFiles(
dataset.getDid,
- DatasetResource.ExistingUploadFilesRequest(
- List(DatasetResource.ExistingUploadFile("missing.csv", 1L))
+ ExistingUploadFilesRequest(
+ List(ExistingUploadFile("missing.csv", 1L))
),
sessionUser
)
diff --git
a/file-service/src/test/scala/org/apache/texera/service/resource/ModelApiForUiSpec.scala
b/file-service/src/test/scala/org/apache/texera/service/resource/ModelApiForUiSpec.scala
new file mode 100644
index 0000000000..cf55ca09fa
--- /dev/null
+++
b/file-service/src/test/scala/org/apache/texera/service/resource/ModelApiForUiSpec.scala
@@ -0,0 +1,380 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.texera.service.resource
+
+import jakarta.ws.rs._
+import org.apache.texera.amber.core.storage.util.LakeFSStorageClient
+import org.apache.texera.auth.SessionUser
+import org.apache.texera.dao.MockTexeraDB
+import org.apache.texera.dao.jooq.generated.enums.UserRoleEnum
+import org.apache.texera.dao.jooq.generated.tables.daos.UserDao
+import org.apache.texera.dao.jooq.generated.tables.pojos.User
+import org.apache.texera.service.MockLakeFS
+import org.apache.texera.service.`type`.{ExistingUploadFile,
ExistingUploadFilesRequest}
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+import org.scalatest.{BeforeAndAfterAll, BeforeAndAfterEach}
+
+import java.io.ByteArrayInputStream
+import scala.jdk.CollectionConverters._
+
+// Covers the model endpoints the UI needs beyond upload and download: the
staged
+// (uncommitted) change list and its per-file reset, the "already uploaded"
probe
+// that lets a client skip re-sending bytes, the owner facet for the list page,
+// and the framework/format metadata labels.
+class ModelApiForUiSpec
+ extends AnyFlatSpec
+ with Matchers
+ with MockTexeraDB
+ with MockLakeFS
+ with BeforeAndAfterAll
+ with BeforeAndAfterEach
+ with ResourceTestHelpers {
+
+ private def mkUser(name: String): User = {
+ val user = new User
+ user.setName(name)
+ user.setEmail(s"[email protected]")
+ user.setRole(UserRoleEnum.ADMIN)
+ user
+ }
+
+ private val ownerUser: User = mkUser("model_ui_owner")
+ private val strangerUser: User = mkUser("model_ui_stranger")
+
+ lazy val modelResource = new ModelResource()
+ lazy val sessionUser = new SessionUser(ownerUser)
+ lazy val strangerSession = new SessionUser(strangerUser)
+
+ override protected def beforeAll(): Unit = {
+ super.beforeAll()
+ initializeDBAndReplaceDSLContext()
+ val userDao = new UserDao(getDSLContext.configuration())
+ userDao.insert(ownerUser)
+ userDao.insert(strangerUser)
+ }
+
+ override protected def afterAll(): Unit = {
+ try shutdownDB()
+ finally super.afterAll()
+ }
+
+ // ---------- helpers ----------
+ private def newModel(
+ framework: String = "pytorch",
+ format: String = null,
+ isPublic: Boolean = false
+ ): ModelResource.DashboardModel =
+ modelResource.createModel(
+ ModelResource.CreateModelRequest(
+ modelName = uniqueName("ui-model"),
+ modelDescription = "for model-api-for-ui tests",
+ isModelPublic = isPublic,
+ isModelDownloadable = true,
+ framework = framework,
+ format = format
+ ),
+ sessionUser
+ )
+
+ private def upload(mid: Integer, path: String, bytes: Array[Byte]): Unit =
+ modelResource
+ .uploadOneFileToModel(
+ mid,
+ urlEnc(path),
+ "upload",
+ new ByteArrayInputStream(bytes),
+ mkHeaders(bytes.length.toLong),
+ sessionUser
+ )
+ .getStatus shouldEqual 200
+
+ private def matchedPaths(response: core.Response): List[String] =
+ response.getEntity
+ .asInstanceOf[Map[String, java.util.List[String]]]("filePaths")
+ .asScala
+ .toList
+
+ //
===========================================================================
+ // staged changes
+ //
===========================================================================
+ "getModelDiff" should "list an uploaded-but-uncommitted file as added" in {
+ val mid = newModel().model.getMid
+ upload(mid, "model.pt", Array.fill[Byte](64)(0x1))
+
+ val diffs = modelResource.getModelDiff(mid, sessionUser)
+
+ diffs.map(_.path) should contain("model.pt")
+ val diff = diffs.find(_.path == "model.pt").get
+ diff.diffType shouldEqual "added"
+ diff.sizeBytes shouldEqual Some(64L)
+ }
+
+ it should "report nothing once the changes are committed" in {
+ val mid = newModel().model.getMid
+ upload(mid, "model.pt", Array.fill[Byte](64)(0x1))
+ modelResource.createModelVersion("v1", mid, sessionUser)
+
+ modelResource.getModelDiff(mid, sessionUser) shouldBe empty
+ }
+
+ it should "report a deletion of a committed file as a staged change" in {
+ val mid = newModel().model.getMid
+ upload(mid, "model.pt", Array.fill[Byte](64)(0x1))
+ modelResource.createModelVersion("v1", mid, sessionUser)
+ modelResource.deleteModelFile(mid, urlEnc("model.pt"),
sessionUser).getStatus shouldEqual 200
+
+ modelResource.getModelDiff(mid, sessionUser).map(_.diffType) should
contain("removed")
+ }
+
+ it should "refuse a caller with no access to the model" in {
+ val mid = newModel().model.getMid
+
+ assertThrows[ForbiddenException] {
+ modelResource.getModelDiff(mid, strangerSession)
+ }
+ }
+
+ "resetModelFileDiff" should "drop a staged upload" in {
+ val mid = newModel().model.getMid
+ upload(mid, "model.pt", Array.fill[Byte](64)(0x1))
+
+ modelResource
+ .resetModelFileDiff(mid, urlEnc("model.pt"), sessionUser)
+ .getStatus shouldEqual 200
+
+ modelResource.getModelDiff(mid, sessionUser) shouldBe empty
+ }
+
+ it should "restore a committed file whose deletion was staged" in {
+ val mid = newModel().model.getMid
+ upload(mid, "model.pt", Array.fill[Byte](64)(0x1))
+ modelResource.createModelVersion("v1", mid, sessionUser)
+ modelResource.deleteModelFile(mid, urlEnc("model.pt"), sessionUser)
+
+ modelResource.resetModelFileDiff(mid, urlEnc("model.pt"), sessionUser)
+
+ modelResource.getModelDiff(mid, sessionUser) shouldBe empty
+ }
+
+ it should "refuse a caller with no write access to the model" in {
+ val mid = newModel(isPublic = true).model.getMid
+
+ assertThrows[ForbiddenException] {
+ modelResource.resetModelFileDiff(mid, urlEnc("model.pt"),
strangerSession)
+ }
+ }
+
+ //
===========================================================================
+ // existing-upload-files
+ //
===========================================================================
+ "findExistingUploadFiles" should "match a committed file of the same size"
in {
+ val mid = newModel().model.getMid
+ upload(mid, "model.pt", Array.fill[Byte](64)(0x1))
+ modelResource.createModelVersion("v1", mid, sessionUser)
+
+ val response = modelResource.findExistingUploadFiles(
+ mid,
+ ExistingUploadFilesRequest(List(ExistingUploadFile("model.pt", 64L))),
+ sessionUser
+ )
+
+ matchedPaths(response) shouldEqual List("model.pt")
+ }
+
+ it should "match a staged file that has not been committed yet" in {
+ val mid = newModel().model.getMid
+ upload(mid, "model.pt", Array.fill[Byte](64)(0x1))
+
+ val response = modelResource.findExistingUploadFiles(
+ mid,
+ ExistingUploadFilesRequest(List(ExistingUploadFile("model.pt", 64L))),
+ sessionUser
+ )
+
+ matchedPaths(response) shouldEqual List("model.pt")
+ }
+
+ it should "not match a file whose size differs" in {
+ val mid = newModel().model.getMid
+ upload(mid, "model.pt", Array.fill[Byte](64)(0x1))
+ modelResource.createModelVersion("v1", mid, sessionUser)
+
+ val response = modelResource.findExistingUploadFiles(
+ mid,
+ ExistingUploadFilesRequest(List(ExistingUploadFile("model.pt", 65L))),
+ sessionUser
+ )
+
+ matchedPaths(response) shouldBe empty
+ }
+
+ // A staged deletion does not withdraw the committed file from the match
set. This is
+ // the dataset endpoint's behaviour, preserved by the extraction rather than
chosen here.
+ it should "still match a committed file whose deletion is staged" in {
+ val mid = newModel().model.getMid
+ upload(mid, "model.pt", Array.fill[Byte](64)(0x1))
+ modelResource.createModelVersion("v1", mid, sessionUser)
+ modelResource.deleteModelFile(mid, urlEnc("model.pt"), sessionUser)
+
+ val response = modelResource.findExistingUploadFiles(
+ mid,
+ ExistingUploadFilesRequest(List(ExistingUploadFile("model.pt", 64L))),
+ sessionUser
+ )
+
+ matchedPaths(response) shouldEqual List("model.pt")
+ }
+
+ it should "report matches for a model with no version yet as empty" in {
+ val mid = newModel().model.getMid
+
+ val response = modelResource.findExistingUploadFiles(
+ mid,
+ ExistingUploadFilesRequest(List(ExistingUploadFile("model.pt", 64L))),
+ sessionUser
+ )
+
+ matchedPaths(response) shouldBe empty
+ }
+
+ it should "reject a negative size" in {
+ val mid = newModel().model.getMid
+
+ assertThrows[BadRequestException] {
+ modelResource.findExistingUploadFiles(
+ mid,
+ ExistingUploadFilesRequest(List(ExistingUploadFile("model.pt", -1L))),
+ sessionUser
+ )
+ }
+ }
+
+ it should "reject a path that escapes the repository root" in {
+ val mid = newModel().model.getMid
+
+ assertThrows[BadRequestException] {
+ modelResource.findExistingUploadFiles(
+ mid,
+ ExistingUploadFilesRequest(List(ExistingUploadFile("../secret.pt",
64L))),
+ sessionUser
+ )
+ }
+ }
+
+ it should "refuse a caller with no write access to the model" in {
+ val mid = newModel(isPublic = true).model.getMid
+
+ assertThrows[ForbiddenException] {
+ modelResource.findExistingUploadFiles(
+ mid,
+ ExistingUploadFilesRequest(List(ExistingUploadFile("model.pt", 64L))),
+ strangerSession
+ )
+ }
+ }
+
+ //
===========================================================================
+ // owner facet
+ //
===========================================================================
+ "retrieveOwners" should "include the caller once they own a model" in {
+ newModel()
+
+ modelResource.retrieveOwners(sessionUser).asScala should
contain(ownerUser.getEmail)
+ }
+
+ it should "not leak the owner of a private model to a stranger" in {
+ newModel(isPublic = false)
+
+ modelResource.retrieveOwners(strangerSession).asScala should not contain
ownerUser.getEmail
+ }
+
+ // The facet is scoped by explicit grant, not by readability: a public model
the caller
+ // was never granted does not put its owner in their filter list. Matches
datasets.
+ it should "not surface the owner of a public model the caller holds no grant
on" in {
+ newModel(isPublic = true)
+
+ modelResource.retrieveOwners(strangerSession).asScala should not contain
ownerUser.getEmail
+ }
+
+ //
===========================================================================
+ // framework / format labels
+ //
===========================================================================
+ "createModel" should "accept every supported framework" in {
+ ModelResource.SUPPORTED_FRAMEWORKS.foreach { framework =>
+ newModel(framework = framework).model.getFramework shouldEqual framework
+ }
+ }
+
+ it should "accept every supported format" in {
+ ModelResource.SUPPORTED_FORMATS.foreach { format =>
+ newModel(format = format).model.getFormat shouldEqual format
+ }
+ }
+
+ it should "reject an unsupported framework" in {
+ val ex = intercept[BadRequestException](newModel(framework = "caffe"))
+ ex.getMessage should include("Unsupported framework 'caffe'")
+ }
+
+ it should "reject an unsupported format" in {
+ assertThrows[BadRequestException](newModel(format = "gguf"))
+ }
+
+ it should "fall back to the default framework when none is given" in {
+ newModel(framework = null).model.getFramework shouldEqual
ModelResource.DEFAULT_FRAMEWORK
+ }
+
+ it should "treat a blank framework as absent rather than invalid" in {
+ newModel(framework = " ").model.getFramework shouldEqual
ModelResource.DEFAULT_FRAMEWORK
+ }
+
+ it should "leave the format unset when none is given" in {
+ newModel(format = null).model.getFormat shouldBe null
+ }
+
+ //
===========================================================================
+ // listing sizes
+ //
===========================================================================
+ "listModels" should "report the repository size of an owned model" in {
+ val model = newModel()
+ upload(model.model.getMid, "sized.pt", Array.fill[Byte](4096)(0x6))
+ modelResource.createModelVersion("v1", model.model.getMid, sessionUser)
+
+ val listed = modelResource
+ .listModels(sessionUser)
+ .find(_.model.getMid == model.model.getMid)
+ .getOrElse(fail("the owned model should be listed"))
+
+ listed.isOwner shouldBe true
+ listed.size should be >= 4096L
+ }
+
+ it should "still list a model whose repository size cannot be read" in {
+ // An unreadable size degrades to 0 rather than dropping the row.
+ val model = newModel()
+ LakeFSStorageClient.deleteRepo(model.model.getRepositoryName)
+
+ val listed = modelResource.listModels(sessionUser).find(_.model.getMid ==
model.model.getMid)
+
+ listed should not be empty
+ listed.get.size shouldEqual 0L
+ }
+}
diff --git
a/file-service/src/test/scala/org/apache/texera/service/resource/ModelDownloadResourceSpec.scala
b/file-service/src/test/scala/org/apache/texera/service/resource/ModelDownloadResourceSpec.scala
new file mode 100644
index 0000000000..9ee52955f8
--- /dev/null
+++
b/file-service/src/test/scala/org/apache/texera/service/resource/ModelDownloadResourceSpec.scala
@@ -0,0 +1,352 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.texera.service.resource
+
+import jakarta.ws.rs._
+import jakarta.ws.rs.core._
+import org.apache.texera.auth.SessionUser
+import org.apache.texera.dao.MockTexeraDB
+import org.apache.texera.dao.jooq.generated.enums.UserRoleEnum
+import org.apache.texera.dao.jooq.generated.tables.daos.UserDao
+import org.apache.texera.dao.jooq.generated.tables.pojos.User
+import org.apache.texera.service.MockLakeFS
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+import org.scalatest.{BeforeAndAfterAll, BeforeAndAfterEach}
+
+import java.io.{ByteArrayInputStream, ByteArrayOutputStream}
+import java.util.zip.ZipInputStream
+
+// Covers the model download surface: presigned single-file URLs (which
+// ModelFileDocument also depends on) and the version zip.
+class ModelDownloadResourceSpec
+ extends AnyFlatSpec
+ with Matchers
+ with MockTexeraDB
+ with MockLakeFS
+ with BeforeAndAfterAll
+ with BeforeAndAfterEach
+ with ResourceTestHelpers {
+
+ private def mkUser(name: String): User = {
+ val user = new User
+ user.setName(name)
+ user.setEmail(s"[email protected]")
+ user.setRole(UserRoleEnum.ADMIN)
+ user
+ }
+
+ private val ownerUser: User = mkUser("model_download_owner")
+ private val strangerUser: User = mkUser("model_download_stranger")
+
+ lazy val modelResource = new ModelResource()
+ lazy val sessionUser = new SessionUser(ownerUser)
+ lazy val strangerSession = new SessionUser(strangerUser)
+
+ override protected def beforeAll(): Unit = {
+ super.beforeAll()
+ initializeDBAndReplaceDSLContext()
+ val userDao = new UserDao(getDSLContext.configuration())
+ userDao.insert(ownerUser)
+ userDao.insert(strangerUser)
+ }
+
+ override protected def afterAll(): Unit = {
+ try shutdownDB()
+ finally super.afterAll()
+ }
+
+ // ---------- helpers ----------
+ private def newModel(
+ isPublic: Boolean = false,
+ isDownloadable: Boolean = true
+ ): ModelResource.DashboardModel =
+ modelResource.createModel(
+ ModelResource.CreateModelRequest(
+ modelName = uniqueName("dl-model"),
+ modelDescription = "for download tests",
+ isModelPublic = isPublic,
+ isModelDownloadable = isDownloadable,
+ framework = "pytorch",
+ format = null
+ ),
+ sessionUser
+ )
+
+ private def upload(mid: Integer, path: String, bytes: Array[Byte]): Unit =
+ modelResource
+ .uploadOneFileToModel(
+ mid,
+ urlEnc(path),
+ "upload",
+ new ByteArrayInputStream(bytes),
+ mkHeaders(bytes.length.toLong),
+ sessionUser
+ )
+ .getStatus shouldEqual 200
+
+ /** Creates a model holding one committed file, returning it with its commit
hash. */
+ private def modelWithCommittedFile(
+ path: String = "model.pt",
+ bytes: Array[Byte] = Array.fill[Byte](1024)(0x7),
+ isPublic: Boolean = false,
+ isDownloadable: Boolean = true
+ ): (ModelResource.DashboardModel, String) = {
+ val model = newModel(isPublic, isDownloadable)
+ upload(model.model.getMid, path, bytes)
+ val version = modelResource.createModelVersion("v", model.model.getMid,
sessionUser)
+ (model, version.modelVersion.getVersionHash)
+ }
+
+ private def presignedUrlOf(response: Response): String =
+ response.getEntity.asInstanceOf[Map[String, String]]("presignedUrl")
+
+ /** MinIO is bound to a fixed host port precisely so presigned URLs resolve
here. */
+ private def fetch(url: String): Array[Byte] = {
+ val stream = new java.net.URL(url).openStream()
+ try stream.readAllBytes()
+ finally stream.close()
+ }
+ //
===========================================================================
+ // presign-download
+ //
===========================================================================
+ "getPresignedUrl" should "return a presigned URL that serves the file's
bytes" in {
+ val bytes = Array.tabulate[Byte](1024)(i => (i % 251).toByte)
+ val (model, commitHash) = modelWithCommittedFile(bytes = bytes)
+
+ val response = modelResource.getPresignedUrl(
+ urlEnc("model.pt"),
+ model.model.getRepositoryName,
+ commitHash,
+ sessionUser
+ )
+
+ response.getStatus shouldEqual 200
+ // The URL addresses the physical object, not the logical path, so the only
+ // meaningful assertion is that fetching it yields exactly what was
uploaded.
+ val url = presignedUrlOf(response)
+ url should include("X-Amz-Signature")
+ fetch(url) shouldEqual bytes
+ }
+
+ it should "refuse a caller with no access to the model" in {
+ val (model, commitHash) = modelWithCommittedFile()
+
+ assertThrows[ForbiddenException] {
+ modelResource.getPresignedUrl(
+ urlEnc("model.pt"),
+ model.model.getRepositoryName,
+ commitHash,
+ strangerSession
+ )
+ }
+ }
+
+ it should "refuse a repository that belongs to no model" in {
+ assertThrows[ForbiddenException] {
+ modelResource.getPresignedUrl(
+ urlEnc("model.pt"),
+ "model-does-not-exist",
+ "deadbeef",
+ sessionUser
+ )
+ }
+ }
+
+ it should "reject a repositoryName without a commitHash" in {
+ val response = modelResource.getPresignedUrl(urlEnc("model.pt"),
"model-1", null, sessionUser)
+ response.getStatus shouldEqual Response.Status.BAD_REQUEST.getStatusCode
+ }
+
+ it should "reject a commitHash without a repositoryName" in {
+ val response = modelResource.getPresignedUrl(urlEnc("model.pt"), null,
"abc123", sessionUser)
+ response.getStatus shouldEqual Response.Status.BAD_REQUEST.getStatusCode
+ }
+
+ "getPresignedUrlWithS3" should "resolve the same file as the non-S3 variant"
in {
+ val (model, commitHash) = modelWithCommittedFile()
+
+ modelResource
+ .getPresignedUrlWithS3(
+ urlEnc("model.pt"),
+ model.model.getRepositoryName,
+ commitHash,
+ sessionUser
+ )
+ .getStatus shouldEqual 200
+ }
+
+ "getPublicPresignedUrl" should "serve a file of a public model to an
anonymous caller" in {
+ val (model, commitHash) = modelWithCommittedFile(isPublic = true)
+
+ modelResource
+ .getPublicPresignedUrl(urlEnc("model.pt"),
model.model.getRepositoryName, commitHash)
+ .getStatus shouldEqual 200
+ }
+
+ it should "refuse a file of a private model to an anonymous caller" in {
+ val (model, commitHash) = modelWithCommittedFile(isPublic = false)
+
+ assertThrows[ForbiddenException] {
+ modelResource.getPublicPresignedUrl(
+ urlEnc("model.pt"),
+ model.model.getRepositoryName,
+ commitHash
+ )
+ }
+ }
+
+ //
===========================================================================
+ // versionZip
+ //
===========================================================================
+ "getModelVersionZip" should "stream a zip holding every file of the version"
in {
+ val model = newModel()
+ val mid = model.model.getMid
+ upload(mid, "model.pt", Array.fill[Byte](512)(0x1))
+ upload(mid, "tokenizer/vocab.txt", Array.fill[Byte](64)(0x2))
+ val version = modelResource.createModelVersion("v1", mid, sessionUser)
+
+ val response =
+ modelResource.getModelVersionZip(mid, version.modelVersion.getMvid,
null, sessionUser)
+
+ response.getStatus shouldEqual 200
+ response.getHeaderString("Content-Disposition") should include(".zip")
+
+ val buffer = new ByteArrayOutputStream()
+ response.getEntity.asInstanceOf[StreamingOutput].write(buffer)
+
+ val zip = new ZipInputStream(new
java.io.ByteArrayInputStream(buffer.toByteArray))
+ val entries = Iterator
+ .continually(zip.getNextEntry)
+ .takeWhile(_ != null)
+ .map(_.getName)
+ .toList
+ zip.close()
+
+ entries should contain allOf ("model.pt", "tokenizer/vocab.txt")
+ }
+
+ it should "accept latest=true instead of an explicit version id" in {
+ val (model, _) = modelWithCommittedFile()
+
+ modelResource
+ .getModelVersionZip(model.model.getMid, null, java.lang.Boolean.TRUE,
sessionUser)
+ .getStatus shouldEqual 200
+ }
+
+ it should "reject being given both a version id and latest=true" in {
+ val (model, _) = modelWithCommittedFile()
+ val version = modelResource.retrieveLatestModelVersion(model.model.getMid,
sessionUser)
+
+ assertThrows[BadRequestException] {
+ modelResource.getModelVersionZip(
+ model.model.getMid,
+ version.modelVersion.getMvid,
+ java.lang.Boolean.TRUE,
+ sessionUser
+ )
+ }
+ }
+
+ it should "reject being given neither a version id nor latest=true" in {
+ val (model, _) = modelWithCommittedFile()
+
+ assertThrows[BadRequestException] {
+ modelResource.getModelVersionZip(model.model.getMid, null, null,
sessionUser)
+ }
+ }
+
+ it should "report not-found for a version whose files were all deleted" in {
+ // Deleting the only file is itself a staged change, so a genuinely empty
+ // version can be committed — the zip of it has nothing to stream.
+ val model = newModel()
+ val mid = model.model.getMid
+ upload(mid, "only.pt", Array.fill[Byte](64)(0x1))
+ modelResource.createModelVersion("v1", mid, sessionUser)
+ modelResource.deleteModelFile(mid, urlEnc("only.pt"),
sessionUser).getStatus shouldEqual 200
+ val emptyVersion = modelResource.createModelVersion("v2-empty", mid,
sessionUser)
+
+ val response =
+ modelResource.getModelVersionZip(mid, emptyVersion.modelVersion.getMvid,
null, sessionUser)
+
+ response.getStatus shouldEqual Response.Status.NOT_FOUND.getStatusCode
+ }
+
+ it should "report not-found for a model that has no version yet" in {
+ val model = newModel()
+
+ assertThrows[NotFoundException] {
+ modelResource.getModelVersionZip(
+ model.model.getMid,
+ null,
+ java.lang.Boolean.TRUE,
+ sessionUser
+ )
+ }
+ }
+
+ it should "refuse a caller with no access to the model" in {
+ val (model, _) = modelWithCommittedFile()
+
+ assertThrows[ForbiddenException] {
+ modelResource.getModelVersionZip(
+ model.model.getMid,
+ null,
+ java.lang.Boolean.TRUE,
+ strangerSession
+ )
+ }
+ }
+
+ it should "refuse a non-owner when the model is not downloadable" in {
+ // readable because it is public, but the owner disabled downloads
+ val (model, _) = modelWithCommittedFile(isPublic = true, isDownloadable =
false)
+
+ assertThrows[ForbiddenException] {
+ modelResource.getModelVersionZip(
+ model.model.getMid,
+ null,
+ java.lang.Boolean.TRUE,
+ strangerSession
+ )
+ }
+ }
+
+ it should "reject latest=false instead of treating it as latest" in {
+ val (model, _) = modelWithCommittedFile()
+
+ assertThrows[BadRequestException] {
+ modelResource.getModelVersionZip(
+ model.model.getMid,
+ null,
+ java.lang.Boolean.FALSE,
+ sessionUser
+ )
+ }
+ }
+
+ it should "still let the owner download a model that is not downloadable" in
{
+ val (model, _) = modelWithCommittedFile(isPublic = false, isDownloadable =
false)
+
+ modelResource
+ .getModelVersionZip(model.model.getMid, null, java.lang.Boolean.TRUE,
sessionUser)
+ .getStatus shouldEqual 200
+ }
+}
diff --git
a/file-service/src/test/scala/org/apache/texera/service/resource/ModelResourcePermissionsSpec.scala
b/file-service/src/test/scala/org/apache/texera/service/resource/ModelResourcePermissionsSpec.scala
index cc4e3ed2c4..01851aa64b 100644
---
a/file-service/src/test/scala/org/apache/texera/service/resource/ModelResourcePermissionsSpec.scala
+++
b/file-service/src/test/scala/org/apache/texera/service/resource/ModelResourcePermissionsSpec.scala
@@ -29,7 +29,7 @@ import java.lang.reflect.Method
// FileService registers RolesAllowedDynamicFeature, so each endpoint is
enforced by its own method-level
// annotation, and one carrying neither @PermitAll nor @RolesAllowed defaults
to
// OPEN. These tests pin that contract: every endpoint carries exactly one of
the
-// two, the non-public ones require REGULAR/ADMIN, and only the public read
stays
+// two, the non-public ones require REGULAR/ADMIN, and only the public reads
stay
// anonymous-accessible.
class ModelResourcePermissionsSpec extends AnyFlatSpec with Matchers {
@@ -43,7 +43,13 @@ class ModelResourcePermissionsSpec extends AnyFlatSpec with
Matchers {
private def rolesOf(m: Method): Option[RolesAllowed] =
Option(m.getAnnotation(classOf[RolesAllowed]))
- private val publicEndpointMethods: Set[String] = Set("getPublicModel")
+ // Anonymous-readable, mirroring the dataset side: the public model read
plus the two
+ // presign routes a public model's files are fetched through.
+ private val publicEndpointMethods: Set[String] = Set(
+ "getPublicModel",
+ "getPublicPresignedUrl",
+ "getPublicPresignedUrlWithS3"
+ )
"ModelResource" should "expose HTTP endpoints (sanity check for the
reflection scan)" in {
endpointMethods should not be empty
@@ -75,7 +81,7 @@ class ModelResourcePermissionsSpec extends AnyFlatSpec with
Matchers {
}
}
- it should "keep getPublicModel @PermitAll, and it alone" in {
+ it should "keep the public model endpoints @PermitAll, and them alone" in {
val present =
endpointMethods.map(_.getName).toSet.intersect(publicEndpointMethods)
withClue("public endpoints missing from ModelResource (renamed?): ") {
present shouldBe publicEndpointMethods
diff --git
a/file-service/src/test/scala/org/apache/texera/service/resource/ModelUploadResourceSpec.scala
b/file-service/src/test/scala/org/apache/texera/service/resource/ModelUploadResourceSpec.scala
index 82444491a2..f4f300b7dc 100644
---
a/file-service/src/test/scala/org/apache/texera/service/resource/ModelUploadResourceSpec.scala
+++
b/file-service/src/test/scala/org/apache/texera/service/resource/ModelUploadResourceSpec.scala
@@ -33,10 +33,8 @@ import org.scalatest.matchers.should.Matchers
import org.scalatest.{BeforeAndAfterAll, BeforeAndAfterEach}
import java.io.ByteArrayInputStream
-import java.net.URLEncoder
import java.nio.charset.StandardCharsets
-import java.util.{Collections, Date, Locale, Optional}
-import scala.util.Random
+import java.util.Optional
class ModelUploadResourceSpec
extends AnyFlatSpec
@@ -44,7 +42,8 @@ class ModelUploadResourceSpec
with MockTexeraDB
with MockLakeFS
with BeforeAndAfterAll
- with BeforeAndAfterEach {
+ with BeforeAndAfterEach
+ with ResourceTestHelpers {
private val ownerUser: User = {
val user = new User
@@ -81,30 +80,6 @@ class ModelUploadResourceSpec
}
// ---------- helpers ----------
- private def urlEnc(raw: String): String =
- URLEncoder.encode(raw, StandardCharsets.UTF_8.name())
-
- private def uniqueName(prefix: String): String =
-
s"$prefix-${System.nanoTime()}-${Random.alphanumeric.take(6).mkString.toLowerCase}"
-
- /** Minimal HttpHeaders exposing only Content-Length, which the upload paths
read. */
- private def mkHeaders(contentLength: Long): HttpHeaders =
- new HttpHeaders {
- private val headers = new MultivaluedHashMap[String, String]()
- headers.putSingle(HttpHeaders.CONTENT_LENGTH, contentLength.toString)
- override def getHeaderString(name: String): String =
headers.getFirst(name)
- override def getRequestHeaders: MultivaluedMap[String, String] = headers
- override def getRequestHeader(name: String): java.util.List[String] =
- Option(headers.get(name)).getOrElse(Collections.emptyList[String]())
- override def getAcceptableMediaTypes: java.util.List[MediaType] =
Collections.emptyList()
- override def getAcceptableLanguages: java.util.List[Locale] =
Collections.emptyList()
- override def getMediaType: MediaType = null
- override def getLanguage: Locale = null
- override def getCookies: java.util.Map[String, Cookie] =
Collections.emptyMap()
- override def getDate: Date = null
- override def getLength: Int = contentLength.toInt
- }
-
/** Creates a fresh model (provisions its LakeFS repo) and returns it. */
private def newModel(): ModelResource.DashboardModel =
modelResource.createModel(
diff --git
a/file-service/src/test/scala/org/apache/texera/service/resource/ResourceTestHelpers.scala
b/file-service/src/test/scala/org/apache/texera/service/resource/ResourceTestHelpers.scala
new file mode 100644
index 0000000000..b41c1399a6
--- /dev/null
+++
b/file-service/src/test/scala/org/apache/texera/service/resource/ResourceTestHelpers.scala
@@ -0,0 +1,61 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.texera.service.resource
+
+import jakarta.ws.rs.core._
+
+import java.net.URLEncoder
+import java.nio.charset.StandardCharsets
+import java.util.{Collections, Date, Locale}
+import scala.util.Random
+
+/**
+ * Plumbing shared by the resource specs that drive the JAX-RS methods
directly
+ * rather than over HTTP: query-parameter encoding, collision-free resource
names,
+ * and the HttpHeaders the upload paths read.
+ */
+trait ResourceTestHelpers {
+
+ /** Endpoints take file paths as a single URL-encoded query parameter. */
+ protected def urlEnc(raw: String): String =
+ URLEncoder.encode(raw, StandardCharsets.UTF_8.name())
+
+ /** Names are unique per owner, so specs sharing a DB must not collide. */
+ protected def uniqueName(prefix: String): String =
+
s"$prefix-${System.nanoTime()}-${Random.alphanumeric.take(6).mkString.toLowerCase}"
+
+ /** Minimal HttpHeaders exposing only Content-Length, which the upload paths
read. */
+ protected def mkHeaders(contentLength: Long): HttpHeaders =
+ new HttpHeaders {
+ private val headers = new MultivaluedHashMap[String, String]()
+ headers.putSingle(HttpHeaders.CONTENT_LENGTH, contentLength.toString)
+ override def getHeaderString(name: String): String =
headers.getFirst(name)
+ override def getRequestHeaders: MultivaluedMap[String, String] = headers
+ override def getRequestHeader(name: String): java.util.List[String] =
+ Option(headers.get(name)).getOrElse(Collections.emptyList[String]())
+ override def getAcceptableMediaTypes: java.util.List[MediaType] =
Collections.emptyList()
+ override def getAcceptableLanguages: java.util.List[Locale] =
Collections.emptyList()
+ override def getMediaType: MediaType = null
+ override def getLanguage: Locale = null
+ override def getCookies: java.util.Map[String, Cookie] =
Collections.emptyMap()
+ override def getDate: Date = null
+ override def getLength: Int = contentLength.toInt
+ }
+}