diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/hf/ClarinHuggingFaceController.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/hf/ClarinHuggingFaceController.java
new file mode 100644
index 000000000000..329a4ecd75be
--- /dev/null
+++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/hf/ClarinHuggingFaceController.java
@@ -0,0 +1,627 @@
+/**
+ * The contents of this file are subject to the license and copyright
+ * detailed in the LICENSE and NOTICE files at the root of the source
+ * tree and available online at
+ *
+ * http://www.dspace.org/license/
+ */
+package org.dspace.app.rest.hf;
+
+import java.io.IOException;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.sql.SQLException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import javax.ws.rs.InternalServerErrorException;
+
+import org.apache.catalina.connector.ClientAbortException;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.apache.tomcat.util.http.FastHttpDateFormat;
+import org.dspace.app.rest.utils.ContextUtil;
+import org.dspace.app.rest.utils.HttpHeadersInitializer;
+import org.dspace.app.statistics.clarin.ClarinMatomoBitstreamTracker;
+import org.dspace.authorize.AuthorizeException;
+import org.dspace.authorize.service.AuthorizeService;
+import org.dspace.content.Bitstream;
+import org.dspace.content.BitstreamFormat;
+import org.dspace.content.Item;
+import org.dspace.core.Constants;
+import org.dspace.core.Context;
+import org.dspace.eperson.EPerson;
+import org.dspace.services.ConfigurationService;
+import org.dspace.services.EventService;
+import org.dspace.storage.bitstore.S3BitStoreService;
+import org.dspace.storage.bitstore.service.S3DirectDownloadService;
+import org.dspace.usage.UsageEvent;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestMethod;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.util.UriUtils;
+
+/**
+ * HuggingFace-Hub-compatible facade controller (read-only).
+ *
+ *
Exposes a subset of the {@code huggingface_hub} REST API shape (model info, tree listing, file
+ * download/"resolve" and {@code whoami-v2}) backed by {@link ClarinHuggingFaceService}, so that DSpace
+ * Items flagged as "machine learning models" (see the {@code hf.api.*} configuration keys) can be
+ * consumed directly by HuggingFace-Hub-aware tooling (e.g. the {@code huggingface_hub} Python client)
+ * using the Item handle as the repository id.
+ *
+ * The file-download ({@code resolve}) route streams (or, when configured for S3 direct download,
+ * redirects to) the ORIGINAL bitstreams of an exposed Item.
+ *
+ * Note: {@code @PreAuthorize} is not used because authorization depends on the resolved Item (looked
+ * up by handle), not on a UUID path variable. Authorization is explicitly checked via
+ * {@link AuthorizeService#authorizeAction} after the Item is resolved, mirroring
+ * {@code BitstreamByHandleRestController}.
+ *
+ * @author DSpace at UFAL
+ */
+@RestController
+@RequestMapping("/api/hf")
+public class ClarinHuggingFaceController {
+
+ private static final Logger log = LogManager.getLogger(ClarinHuggingFaceController.class);
+
+ private static final String ERROR_CODE_HEADER = "X-Error-Code";
+
+ private static final String REPO_COMMIT_HEADER = "X-Repo-Commit";
+
+ private static final String LINKED_ETAG_HEADER = "X-Linked-Etag";
+
+ private static final String LINKED_SIZE_HEADER = "X-Linked-Size";
+
+ private static final String RESOLVE_MARKER = "/resolve/";
+
+ // Most file systems are configured to use block sizes of 4096 or 8192 and our buffer should be a
+ // multiple of that (mirrors BitstreamRestController / BitstreamByHandleRestController).
+ private static final int BUFFER_SIZE = 4096 * 10;
+
+ @Autowired
+ private ClarinHuggingFaceService huggingFaceService;
+
+ @Autowired
+ private AuthorizeService authorizeService;
+
+ @Autowired
+ private ConfigurationService configurationService;
+
+ @Autowired
+ private EventService eventService;
+
+ @Autowired
+ private ClarinMatomoBitstreamTracker matomoBitstreamTracker;
+
+ @Autowired
+ private S3DirectDownloadService s3DirectDownloadService;
+
+ @Autowired
+ private S3BitStoreService s3BitStoreService;
+
+ /**
+ * Get the model info of an exposed Item, at the implicit "main" revision.
+ *
+ * @param prefix the handle prefix (e.g. "11234")
+ * @param suffix the handle suffix (e.g. "1-5814")
+ * @param request the HTTP request
+ * @return the model info, or an error response
+ * @throws SQLException if a database error occurs
+ */
+ @GetMapping("/api/models/{prefix}/{suffix}")
+ public ResponseEntity modelInfo(@PathVariable String prefix, @PathVariable String suffix,
+ HttpServletRequest request) throws SQLException {
+ if (!huggingFaceService.isEnabled()) {
+ return ResponseEntity.notFound().build();
+ }
+
+ Context context = ContextUtil.obtainContext(request);
+ try {
+ ResolvedRepository repository = resolveAuthorizedRepository(context, prefix, suffix);
+ HuggingFaceModelInfo modelInfo =
+ huggingFaceService.toModelInfo(context, repository.item, repository.sha);
+ context.complete();
+ return ResponseEntity.ok(modelInfo);
+ } catch (HuggingFaceApiException e) {
+ return e.toResponse();
+ }
+ }
+
+ /**
+ * Get the model info of an exposed Item, at a specific (validated) revision.
+ *
+ * @param prefix the handle prefix (e.g. "11234")
+ * @param suffix the handle suffix (e.g. "1-5814")
+ * @param revision the requested revision; must be {@code "main"} or the item's current sha
+ * @param request the HTTP request
+ * @return the model info, or an error response
+ * @throws SQLException if a database error occurs
+ */
+ @GetMapping("/api/models/{prefix}/{suffix}/revision/{revision}")
+ public ResponseEntity modelInfoAtRevision(@PathVariable String prefix, @PathVariable String suffix,
+ @PathVariable String revision, HttpServletRequest request) throws SQLException {
+ if (!huggingFaceService.isEnabled()) {
+ return ResponseEntity.notFound().build();
+ }
+
+ Context context = ContextUtil.obtainContext(request);
+ try {
+ ResolvedRepository repository = resolveAuthorizedRepository(context, prefix, suffix);
+ requireValidRevision(revision, repository.sha);
+ HuggingFaceModelInfo modelInfo =
+ huggingFaceService.toModelInfo(context, repository.item, repository.sha);
+ context.complete();
+ return ResponseEntity.ok(modelInfo);
+ } catch (HuggingFaceApiException e) {
+ return e.toResponse();
+ }
+ }
+
+ /**
+ * Get the flat file tree of an exposed Item, at a specific (validated) revision.
+ *
+ * @param prefix the handle prefix (e.g. "11234")
+ * @param suffix the handle suffix (e.g. "1-5814")
+ * @param revision the requested revision; must be {@code "main"} or the item's current sha
+ * @param recursive accepted and ignored (single-page flat listing regardless of its value)
+ * @param request the HTTP request
+ * @return the flat list of tree entries, or an error response
+ * @throws SQLException if a database error occurs
+ */
+ @GetMapping("/api/models/{prefix}/{suffix}/tree/{revision}")
+ public ResponseEntity tree(@PathVariable String prefix, @PathVariable String suffix,
+ @PathVariable String revision,
+ @RequestParam(required = false) String recursive,
+ HttpServletRequest request) throws SQLException {
+ if (!huggingFaceService.isEnabled()) {
+ return ResponseEntity.notFound().build();
+ }
+
+ Context context = ContextUtil.obtainContext(request);
+ try {
+ ResolvedRepository repository = resolveAuthorizedRepository(context, prefix, suffix);
+ requireValidRevision(revision, repository.sha);
+ List entries = huggingFaceService.toTreeEntries(context, repository.item);
+ context.complete();
+ return ResponseEntity.ok(entries);
+ } catch (HuggingFaceApiException e) {
+ return e.toResponse();
+ }
+ }
+
+ /**
+ * List exposed Items ("models"), HuggingFace-Hub {@code GET /api/models} style.
+ *
+ * This is a metadata-only listing of exposed items: only items that pass
+ * {@link ClarinHuggingFaceService#isExposed(Context, Item)} (fail-closed, public-by-configuration) AND
+ * that the current context user is authorized to {@code READ} are ever returned -- the {@code READ}
+ * check is performed by {@link ClarinHuggingFaceService#findExposedItems(Context, String, int)} itself,
+ * before pagination, so that embargoed/private items never count towards {@code limit} and are never
+ * leaked to a user who could not otherwise read them.
+ *
+ * @param search an optional, case-insensitive substring to match against the item title
+ * @param limit the maximum number of items to return (default 20, capped at 100)
+ * @param request the HTTP request
+ * @return the matching model-info objects, as a JSON array
+ * @throws SQLException if a database error occurs
+ */
+ @GetMapping("/api/models")
+ public ResponseEntity listModels(@RequestParam(required = false) String search,
+ @RequestParam(required = false, defaultValue = "20") int limit,
+ HttpServletRequest request) throws SQLException {
+ if (!huggingFaceService.isEnabled()) {
+ return ResponseEntity.notFound().build();
+ }
+
+ Context context = ContextUtil.obtainContext(request);
+ List models = new ArrayList<>();
+ for (Item item : huggingFaceService.findExposedItems(context, search, limit)) {
+ String sha = huggingFaceService.computeShaForItem(context, item);
+ models.add(huggingFaceService.toModelInfo(context, item, sha));
+ }
+ context.complete();
+ return ResponseEntity.ok(models);
+ }
+
+ /**
+ * Placeholder for the HuggingFace Hub {@code whoami-v2} endpoint. Always responds with 401, since
+ * this facade does not (yet) support Bearer-token authentication.
+ *
+ * @return a 401 response with a small JSON error body
+ */
+ @GetMapping("/api/whoami-v2")
+ public ResponseEntity whoAmI() {
+ // TODO: phase 2 - map Authorization: Bearer to CLARIN Personal Access Token here
+ return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
+ .body(Collections.singletonMap("error", "Invalid user token"));
+ }
+
+ /**
+ * Download (GET) or probe (HEAD) a single ORIGINAL bitstream of an exposed Item, HuggingFace-Hub
+ * "resolve" style: {@code /{prefix}/{suffix}/resolve/{revision}/{filename}}.
+ *
+ * The filename is everything after the revision path segment (it may contain slashes and
+ * URL-encoded characters, decoded once). HEAD requests are always answered locally with the metadata
+ * headers ({@code X-Repo-Commit}, {@code ETag}, {@code X-Linked-Etag}, {@code Content-Length},
+ * {@code X-Linked-Size}, {@code Accept-Ranges}) and an empty body; they are never redirected. GET
+ * requests are either redirected (302) to an S3 presigned URL when direct S3 download is enabled, or
+ * streamed with full Range/conditional-request support.
+ *
+ * @param prefix the handle prefix (e.g. "11234")
+ * @param suffix the handle suffix (e.g. "1-5814")
+ * @param revision the requested revision; must be {@code "main"} or the item's current sha
+ * @param request the HTTP request
+ * @param response the HTTP response
+ * @return the response entity (file content, redirect or error), or {@code null} when the response
+ * has already been committed (e.g. 304/412 shortcuts or a client abort)
+ * @throws SQLException if a database error occurs
+ * @throws IOException if an I/O error occurs while initialising the response
+ */
+ @RequestMapping(method = {RequestMethod.GET, RequestMethod.HEAD},
+ value = "/{prefix}/{suffix}/resolve/{revision}/**")
+ public ResponseEntity resolveFile(@PathVariable String prefix, @PathVariable String suffix,
+ @PathVariable String revision, HttpServletRequest request, HttpServletResponse response)
+ throws SQLException, IOException {
+ if (!huggingFaceService.isEnabled()) {
+ return ResponseEntity.notFound().build();
+ }
+
+ Context context = ContextUtil.obtainContext(request);
+ try {
+ if (Objects.isNull(context)) {
+ log.error("Cannot obtain the context from the request.");
+ throw new HuggingFaceApiException(HttpStatus.INTERNAL_SERVER_ERROR, null, "Internal error.");
+ }
+
+ String handle = prefix + "/" + suffix;
+ Item item = huggingFaceService.resolveExposedItem(context, handle);
+ if (item == null) {
+ throw new HuggingFaceApiException(HttpStatus.NOT_FOUND, "RepoNotFound", "Repository not found");
+ }
+
+ String sha = huggingFaceService.computeShaForItem(context, item);
+ requireValidRevision(revision, sha);
+
+ String filename = extractResolveFilename(request);
+ if (StringUtils.isBlank(filename)) {
+ throw new HuggingFaceApiException(HttpStatus.NOT_FOUND, "EntryNotFound", "Entry not found");
+ }
+
+ Bitstream bitstream = huggingFaceService.getOriginalFiles(item).get(filename);
+ if (bitstream == null) {
+ throw new HuggingFaceApiException(HttpStatus.NOT_FOUND, "EntryNotFound", "Entry not found");
+ }
+
+ try {
+ // Authorization is checked explicitly here (not via @PreAuthorize) because the bitstream
+ // identity is resolved from handle + filename, not from a UUID path variable.
+ authorizeService.authorizeAction(context, bitstream, Constants.READ);
+ } catch (AuthorizeException e) {
+ // TODO: phase 2 - Bearer/PAT auth: map Authorization: Bearer to a CLARIN Personal Access
+ // Token and re-check authorization with the resolved EPerson before falling back to the
+ // anonymous 401/403 distinction below.
+ log.warn("Unauthorized access to file '{}' of HuggingFace repository '{}'.", filename, handle);
+ HttpStatus status = context.getCurrentUser() == null
+ ? HttpStatus.UNAUTHORIZED : HttpStatus.FORBIDDEN;
+ String landingPage = configurationService.getProperty("dspace.ui.url") + "/handle/" + handle;
+ throw new HuggingFaceApiException(status, null,
+ "You are not authorized to download this file. Please visit the item landing page at "
+ + landingPage + " to request access.");
+ }
+
+ return serveBitstream(context, request, response, item, bitstream, sha);
+ } catch (HuggingFaceApiException e) {
+ return e.toResponse();
+ }
+ }
+
+ /**
+ * Serve a resolved and authorized bitstream: HEAD locally, GET via an S3 presigned-URL redirect when
+ * direct S3 download is enabled, or by streaming the content otherwise.
+ *
+ * @param context the DSpace context
+ * @param request the HTTP request
+ * @param response the HTTP response
+ * @param item the owning item (source of {@code Last-Modified})
+ * @param bitstream the bitstream to serve
+ * @param sha the item's current sha (sent as {@code X-Repo-Commit})
+ * @return the response entity, or {@code null} when the response has already been committed
+ * @throws SQLException if a database error occurs
+ * @throws IOException if an I/O error occurs while initialising the response
+ */
+ private ResponseEntity serveBitstream(Context context, HttpServletRequest request,
+ HttpServletResponse response, Item item, Bitstream bitstream, String sha)
+ throws SQLException, IOException {
+ BitstreamFormat format = bitstream.getFormat(context);
+ String mimetype = format != null ? format.getMIMEType() : MediaType.APPLICATION_OCTET_STREAM_VALUE;
+ String name = StringUtils.isNotBlank(bitstream.getName())
+ ? bitstream.getName() : bitstream.getID().toString();
+ String quotedEtag = "\"" + bitstream.getChecksum() + "\"";
+ long sizeBytes = bitstream.getSizeBytes();
+ long lastModifiedMillis = item.getLastModified() != null ? item.getLastModified().getTime() : 0L;
+
+ if (RequestMethod.HEAD.name().equals(request.getMethod())) {
+ // HEAD is always answered locally (never redirected to S3), so that HuggingFace clients can
+ // read the metadata headers without following a presigned URL.
+ HttpHeaders headers = buildCommonHeaders(sha, quotedEtag, sizeBytes, mimetype, lastModifiedMillis);
+ context.complete();
+ return ResponseEntity.ok().headers(headers).build();
+ }
+
+ // We only log a download for requests without a Range header, because a client always sends a
+ // regular request first to check for Range support (mirrors BitstreamByHandleRestController).
+ if (StringUtils.isBlank(request.getHeader("Range"))) {
+ eventService.fireEvent(
+ new UsageEvent(
+ UsageEvent.Action.VIEW,
+ request,
+ context,
+ bitstream));
+
+ // Track the download in Matomo - only if the downloading has started (the condition is
+ // inside the method)
+ matomoBitstreamTracker.trackBitstreamDownload(context, request, bitstream, false);
+ }
+
+ try {
+ boolean s3DirectDownload = configurationService.getBooleanProperty("s3.download.direct.enabled");
+ boolean s3AssetstoreEnabled = configurationService.getBooleanProperty("assetstore.s3.enabled");
+ if (s3DirectDownload && s3AssetstoreEnabled) {
+ // Download only files which are stored in the `ORIGINAL` bundle, because some specific
+ // files are not correctly downloaded and displayed in the UI when using presigned URLs.
+ boolean hasOriginalBundle = bitstream.getBundles().stream()
+ .anyMatch(bundle -> Constants.CONTENT_BUNDLE_NAME.equals(bundle.getName()));
+ if (hasOriginalBundle) {
+ HttpHeaders headers =
+ buildCommonHeaders(sha, quotedEtag, sizeBytes, mimetype, lastModifiedMillis);
+ // Close the DB connection before redirecting
+ context.complete();
+ return redirectToS3DownloadUrl(headers, name, bitstream.getInternalId());
+ }
+ }
+
+ EPerson currentUser = context.getCurrentUser();
+ org.dspace.app.rest.utils.BitstreamResource bitstreamResource =
+ new org.dspace.app.rest.utils.BitstreamResource(name, bitstream.getID(),
+ currentUser != null ? currentUser.getID() : null,
+ context.getSpecialGroupUuids(), false);
+
+ HttpHeadersInitializer httpHeadersInitializer = new HttpHeadersInitializer()
+ .withBufferSize(BUFFER_SIZE)
+ .withFileName(name)
+ .withChecksum(quotedEtag)
+ .withLength(bitstreamResource.contentLength())
+ .withMimetype(mimetype)
+ .withLastModified(lastModifiedMillis)
+ .withDisposition(HttpHeadersInitializer.CONTENT_DISPOSITION_ATTACHMENT)
+ .with(request)
+ .with(response);
+
+ // We have all the data we need, close the connection to the database so that it doesn't
+ // stay open during download/streaming
+ context.complete();
+
+ if (httpHeadersInitializer.isValid()) {
+ HttpHeaders httpHeaders = httpHeadersInitializer.initialiseHeaders();
+ httpHeaders.set(REPO_COMMIT_HEADER, sha);
+ httpHeaders.set(LINKED_ETAG_HEADER, quotedEtag);
+ httpHeaders.set(LINKED_SIZE_HEADER, String.valueOf(sizeBytes));
+ return ResponseEntity.ok().headers(httpHeaders).body(bitstreamResource);
+ }
+ } catch (ClientAbortException ex) {
+ log.debug("Client aborted the request before the download was completed. "
+ + "Client is probably switching to a Range request.", ex);
+ }
+ return null;
+ }
+
+ /**
+ * Build the metadata headers shared by every successful {@code resolve} response (HEAD, 302 redirect
+ * and streamed GET).
+ *
+ * @param sha the item's current sha
+ * @param quotedEtag the bitstream's MD5 checksum, wrapped in double quotes
+ * @param sizeBytes the bitstream size in bytes
+ * @param mimetype the bitstream MIME type
+ * @param lastModifiedMillis the item's last-modified timestamp, in epoch milliseconds
+ * @return the headers
+ */
+ private HttpHeaders buildCommonHeaders(String sha, String quotedEtag, long sizeBytes, String mimetype,
+ long lastModifiedMillis) {
+ HttpHeaders headers = new HttpHeaders();
+ headers.set(REPO_COMMIT_HEADER, sha);
+ headers.set(HttpHeaders.ETAG, quotedEtag);
+ headers.set(LINKED_ETAG_HEADER, quotedEtag);
+ headers.set(HttpHeaders.CONTENT_LENGTH, String.valueOf(sizeBytes));
+ headers.set(LINKED_SIZE_HEADER, String.valueOf(sizeBytes));
+ headers.set(HttpHeaders.ACCEPT_RANGES, "bytes");
+ headers.set(HttpHeaders.CONTENT_TYPE, mimetype);
+ headers.set(HttpHeaders.LAST_MODIFIED, FastHttpDateFormat.formatDate(lastModifiedMillis));
+ return headers;
+ }
+
+ /**
+ * Extract the requested filename from the raw request URI of a {@code resolve} request: everything
+ * after the {@code /resolve/} marker and the following (revision) path segment, decoded once. This
+ * supports filenames containing slashes, spaces, parentheses and other URL-encoded characters.
+ *
+ * @param request the HTTP request
+ * @return the decoded filename, or {@code null} if the URI contains no filename part
+ */
+ private String extractResolveFilename(HttpServletRequest request) {
+ String uri = request.getRequestURI();
+ int markerIndex = uri.indexOf(RESOLVE_MARKER);
+ if (markerIndex < 0) {
+ return null;
+ }
+ String rest = uri.substring(markerIndex + RESOLVE_MARKER.length());
+ int slashIndex = rest.indexOf('/');
+ if (slashIndex < 0) {
+ return null;
+ }
+ String rawFilename = rest.substring(slashIndex + 1);
+ return UriUtils.decode(rawFilename, StandardCharsets.UTF_8);
+ }
+
+ /**
+ * This method will handle the S3 direct download by generating a presigned URL for the bitstream and
+ * returning a redirect response to the client (mirrors {@code BitstreamRestController}).
+ *
+ * @param httpHeaders headers needed to form a proper response when returning the Bitstream/File
+ * @param bitName name of the bitstream
+ * @param bitInternalId internal id of the bitstream
+ * @return ResponseEntity with the location header set to the presigned URL
+ */
+ private ResponseEntity redirectToS3DownloadUrl(HttpHeaders httpHeaders, String bitName,
+ String bitInternalId) {
+ try {
+ String bucket = configurationService.getProperty("assetstore.s3.bucketName", "");
+ if (StringUtils.isBlank(bucket)) {
+ throw new InternalServerErrorException("S3 bucket name is not configured");
+ }
+
+ // Get the full path to the bitstream in the S3 bucket
+ String bitstreamPath = s3BitStoreService.getFullKey(bitInternalId);
+ if (StringUtils.isBlank(bitstreamPath)) {
+ throw new InternalServerErrorException("Failed to get bitstream path for internal ID: "
+ + bitInternalId);
+ }
+
+ // Generate a presigned URL for the bitstream with a configurable expiration time
+ int expirationTime = configurationService.getIntProperty("s3.download.direct.expiration", 3600);
+ log.debug("Generating presigned URL with expiration time of {} seconds", expirationTime);
+ String presignedUrl =
+ s3DirectDownloadService.generatePresignedUrl(bucket, bitstreamPath, expirationTime, bitName);
+
+ if (StringUtils.isBlank(presignedUrl)) {
+ throw new InternalServerErrorException("Failed to generate presigned URL for bitstream: "
+ + bitInternalId);
+ }
+
+ // Set the Location header to the presigned URL - this will redirect the client to the S3 URL
+ httpHeaders.setLocation(URI.create(presignedUrl));
+ return ResponseEntity.status(HttpStatus.FOUND).headers(httpHeaders).build();
+ } catch (Exception e) {
+ throw new InternalServerErrorException("Error generating S3 presigned URL for bitstream: "
+ + bitInternalId, e);
+ }
+ }
+
+ /**
+ * Common flow shared by the model-info, revision and tree endpoints: resolve the handle to an
+ * exposed Item, check READ authorization on it, and compute its current sha.
+ *
+ * @param context the DSpace context
+ * @param prefix the handle prefix
+ * @param suffix the handle suffix
+ * @return the resolved item together with its current sha
+ * @throws SQLException if a database error occurs while resolving the handle
+ * @throws HuggingFaceApiException if the handle does not resolve to an exposed item, or the
+ * current user is not authorized to read it
+ */
+ private ResolvedRepository resolveAuthorizedRepository(Context context, String prefix, String suffix)
+ throws SQLException {
+ String handle = prefix + "/" + suffix;
+
+ if (Objects.isNull(context)) {
+ log.error("Cannot obtain the context from the request.");
+ throw new HuggingFaceApiException(HttpStatus.INTERNAL_SERVER_ERROR, null, "Internal error.");
+ }
+
+ Item item = huggingFaceService.resolveExposedItem(context, handle);
+ if (item == null) {
+ throw new HuggingFaceApiException(HttpStatus.NOT_FOUND, "RepoNotFound", "Repository not found");
+ }
+
+ try {
+ authorizeService.authorizeAction(context, item, Constants.READ);
+ } catch (AuthorizeException e) {
+ // TODO: phase 2 - Bearer token auth: map Authorization: Bearer to a CLARIN Personal Access
+ // Token and re-check authorization with the resolved EPerson before falling back to the
+ // anonymous 401/403 distinction below.
+ log.warn("Unauthorized access to HuggingFace repository '{}'.", handle);
+ HttpStatus status = context.getCurrentUser() == null ? HttpStatus.UNAUTHORIZED : HttpStatus.FORBIDDEN;
+ throw new HuggingFaceApiException(status, null, "You are not authorized to access this repository.");
+ }
+
+ String sha = huggingFaceService.computeShaForItem(context, item);
+ return new ResolvedRepository(item, sha);
+ }
+
+ /**
+ * Validate a requested revision against the resolved item's current sha.
+ *
+ * @param revision the requested revision
+ * @param currentSha the item's current sha
+ * @throws HuggingFaceApiException if the revision is not valid
+ */
+ private void requireValidRevision(String revision, String currentSha) {
+ if (!ClarinHuggingFaceService.isValidRevision(revision, currentSha)) {
+ throw new HuggingFaceApiException(HttpStatus.NOT_FOUND, "RevisionNotFound", "Revision not found");
+ }
+ }
+
+ /**
+ * Simple holder for the result of {@link #resolveAuthorizedRepository(Context, String, String)}.
+ */
+ private static final class ResolvedRepository {
+
+ private final Item item;
+
+ private final String sha;
+
+ private ResolvedRepository(Item item, String sha) {
+ this.item = item;
+ this.sha = sha;
+ }
+ }
+
+ /**
+ * Internal unchecked exception used to short-circuit the common resolve/authorize/validate-revision
+ * flow shared by the model-info, revision and tree endpoints, carrying the HTTP status, optional
+ * {@code X-Error-Code} header value and JSON error message to send back to the client.
+ */
+ private static final class HuggingFaceApiException extends RuntimeException {
+
+ private static final long serialVersionUID = 1L;
+
+ private final HttpStatus status;
+
+ private final String errorCode;
+
+ private final String message;
+
+ private HuggingFaceApiException(HttpStatus status, String errorCode, String message) {
+ this.status = status;
+ this.errorCode = errorCode;
+ this.message = message;
+ }
+
+ /**
+ * Build the JSON error {@link ResponseEntity} for this exception.
+ *
+ * @return the response entity
+ */
+ private ResponseEntity toResponse() {
+ ResponseEntity.BodyBuilder builder = ResponseEntity.status(status);
+ if (errorCode != null) {
+ builder = builder.header(ERROR_CODE_HEADER, errorCode);
+ }
+ return builder.body(Collections.singletonMap("error", message));
+ }
+ }
+}
diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/hf/ClarinHuggingFaceService.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/hf/ClarinHuggingFaceService.java
new file mode 100644
index 000000000000..bdf34f09a91e
--- /dev/null
+++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/hf/ClarinHuggingFaceService.java
@@ -0,0 +1,510 @@
+/**
+ * The contents of this file are subject to the license and copyright
+ * detailed in the LICENSE and NOTICE files at the root of the source
+ * tree and available online at
+ *
+ * http://www.dspace.org/license/
+ */
+package org.dspace.app.rest.hf;
+
+import java.sql.SQLException;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import org.apache.commons.codec.digest.DigestUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.dspace.authorize.AuthorizeException;
+import org.dspace.authorize.service.AuthorizeService;
+import org.dspace.content.Bitstream;
+import org.dspace.content.Bundle;
+import org.dspace.content.Collection;
+import org.dspace.content.DSpaceObject;
+import org.dspace.content.Item;
+import org.dspace.content.MetadataValue;
+import org.dspace.content.service.ItemService;
+import org.dspace.core.Constants;
+import org.dspace.core.Context;
+import org.dspace.handle.service.HandleService;
+import org.dspace.services.ConfigurationService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+/**
+ * Service backing the (future, Milestone 2) HuggingFace-Hub-compatible facade controller.
+ *
+ * This service is responsible for:
+ *
+ * deciding whether the facade is enabled at all ({@link #isEnabled()});
+ * deciding whether a given Item is allowed to be exposed through the facade
+ * ({@link #isExposed(Context, Item)});
+ * resolving a handle to an exposed Item ({@link #resolveExposedItem(Context, String)});
+ * computing a deterministic "sha" (revision) for an Item's ORIGINAL bitstreams
+ * ({@link #computeSha(String, long, Map)} / {@link #computeShaForItem(Context, Item)});
+ * validating a requested revision against the current sha ({@link #isValidRevision(String, String)});
+ * building the HuggingFace-Hub-shaped JSON model ({@link #toModelInfo(Context, Item, String)}) and
+ * file tree ({@link #toTreeEntries(Context, Item)}) for an Item.
+ *
+ *
+ * @author DSpace at UFAL
+ */
+@Component
+public class ClarinHuggingFaceService {
+
+ private static final Logger log = LogManager.getLogger(ClarinHuggingFaceService.class);
+
+ private static final String PROP_ENABLED = "hf.api.enabled";
+
+ private static final String PROP_FILTER_METADATA_FIELD = "hf.api.filter.metadata-field";
+
+ private static final String PROP_FILTER_VALUE = "hf.api.filter.value";
+
+ private static final String PROP_EXPOSED_COLLECTIONS = "hf.api.exposed.collections";
+
+ private static final String DEFAULT_FILTER_METADATA_FIELD = "dc.type";
+
+ private static final String MAIN_REVISION = "main";
+
+ private static final int DEFAULT_LIST_LIMIT = 20;
+
+ private static final int MAX_LIST_LIMIT = 100;
+
+ /**
+ * Guard so that the "no exposure filter configured" warning is only logged once per JVM lifetime,
+ * instead of once per checked Item.
+ */
+ private static final AtomicBoolean NO_FILTER_CONFIGURED_WARNING_LOGGED = new AtomicBoolean(false);
+
+ @Autowired
+ private ConfigurationService configurationService;
+
+ @Autowired
+ private ItemService itemService;
+
+ @Autowired
+ private HandleService handleService;
+
+ @Autowired
+ private AuthorizeService authorizeService;
+
+ /**
+ * Master switch for the HuggingFace-Hub-compatible facade.
+ *
+ * @return {@code true} if the facade is enabled via {@code hf.api.enabled}, {@code false} otherwise
+ */
+ public boolean isEnabled() {
+ return configurationService.getBooleanProperty(PROP_ENABLED, false);
+ }
+
+ /**
+ * Fail-closed exposure filter deciding whether a given Item may be exposed through the HuggingFace facade.
+ *
+ * An Item is exposed iff:
+ *
+ * the item is non-null, archived and not withdrawn, AND
+ * either (a) the configured metadata field ({@code hf.api.filter.metadata-field}) has a value on
+ * the item equal to the configured filter value ({@code hf.api.filter.value}) -- only evaluated when
+ * both properties are non-blank -- or (b) the item's owning collection handle is listed in
+ * {@code hf.api.exposed.collections}.
+ *
+ *
+ * If neither the metadata filter nor the collection allow-list is configured, a warning is logged
+ * (once) and the method fails closed, returning {@code false}.
+ *
+ * @param context the DSpace context
+ * @param item the item to check
+ * @return {@code true} if the item may be exposed through the HuggingFace facade
+ */
+ public boolean isExposed(Context context, Item item) {
+ if (item == null || item.isWithdrawn() || !item.isArchived()) {
+ return false;
+ }
+
+ String filterMetadataField = configurationService.getProperty(PROP_FILTER_METADATA_FIELD,
+ DEFAULT_FILTER_METADATA_FIELD);
+ String filterValue = configurationService.getProperty(PROP_FILTER_VALUE);
+ boolean filterConfigured = StringUtils.isNotBlank(filterMetadataField) && StringUtils.isNotBlank(filterValue);
+
+ String[] exposedCollections = configurationService.getArrayProperty(PROP_EXPOSED_COLLECTIONS);
+ boolean collectionsConfigured = exposedCollections != null && exposedCollections.length > 0;
+
+ if (!filterConfigured && !collectionsConfigured) {
+ if (NO_FILTER_CONFIGURED_WARNING_LOGGED.compareAndSet(false, true)) {
+ log.warn("Neither '{}'/'{}' nor '{}' is configured; the HuggingFace facade will not expose "
+ + "any item.", PROP_FILTER_METADATA_FIELD, PROP_FILTER_VALUE,
+ PROP_EXPOSED_COLLECTIONS);
+ }
+ return false;
+ }
+
+ if (filterConfigured) {
+ List values = itemService.getMetadataByMetadataString(item, filterMetadataField);
+ for (MetadataValue value : values) {
+ if (StringUtils.equals(filterValue, value.getValue())) {
+ return true;
+ }
+ }
+ }
+
+ if (collectionsConfigured) {
+ Collection owningCollection = item.getOwningCollection();
+ String ownerHandle = owningCollection != null ? owningCollection.getHandle() : null;
+ if (ownerHandle != null) {
+ for (String exposedHandle : exposedCollections) {
+ if (StringUtils.equals(ownerHandle, exposedHandle)) {
+ return true;
+ }
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Resolve a handle to an Item that is allowed to be exposed through the HuggingFace facade.
+ *
+ * @param context the DSpace context
+ * @param handle the handle to resolve (e.g. {@code "11234/1-5814"})
+ * @return the exposed Item, or {@code null} if the handle does not resolve to an Item, or the resolved
+ * Item is not exposed
+ * @throws SQLException if a database error occurs while resolving the handle
+ */
+ public Item resolveExposedItem(Context context, String handle) throws SQLException {
+ DSpaceObject dso = handleService.resolveToObject(context, handle);
+ if (!(dso instanceof Item)) {
+ return null;
+ }
+ Item item = (Item) dso;
+ return isExposed(context, item) ? item : null;
+ }
+
+ /**
+ * Find items exposed through the HuggingFace facade, optionally restricted to items whose title
+ * contains {@code search} (case-insensitively), and capped at {@code limit} results.
+ *
+ * Candidates are collected -- deliberately without any Solr dependence -- from up to two sources,
+ * which are merged and deduplicated by item UUID:
+ *
+ * every item of every collection listed in {@code hf.api.exposed.collections}, resolved via
+ * {@link HandleService} and iterated with {@link ItemService#findByCollection(Context, Collection)};
+ * and
+ * the items whose configured filter metadata field ({@code hf.api.filter.metadata-field}) has
+ * the configured filter value ({@code hf.api.filter.value}), via
+ * {@link ItemService#findArchivedByMetadataField(Context, String, String, String, String)}.
+ *
+ *
+ * Every candidate is (re-)checked against {@link #isExposed(Context, Item)}, fail-closed, which also
+ * filters out withdrawn/non-archived items that may still be reachable through the collection path. Each
+ * exposed candidate is additionally checked for {@code READ} authorization for the current context user
+ * ({@link AuthorizeService#authorizeActionBoolean(Context, DSpaceObject, int)}) before it counts towards
+ * {@code limit}, so that embargoed/private items are filtered out before pagination rather than after,
+ * and are never returned to a user who could not otherwise read them.
+ *
+ * @param context the DSpace context
+ * @param search an optional, case-insensitive substring to match against the item title (or name, if
+ * the item has no title); {@code null} or blank to not filter by title
+ * @param limit the maximum number of items to return; a non-positive value defaults to 20, and any
+ * value is capped at 100
+ * @return the list of exposed, READ-authorized items matching {@code search}, capped at the effective
+ * limit; empty if neither exposure mechanism is configured, or no item is exposed, readable and
+ * matches
+ * @throws SQLException if a database error occurs
+ */
+ public List- findExposedItems(Context context, String search, int limit) throws SQLException {
+ int effectiveLimit = limit <= 0 ? DEFAULT_LIST_LIMIT : Math.min(limit, MAX_LIST_LIMIT);
+
+ LinkedHashMap
candidates = new LinkedHashMap<>();
+
+ String[] exposedCollections = configurationService.getArrayProperty(PROP_EXPOSED_COLLECTIONS);
+ if (exposedCollections != null) {
+ for (String handle : exposedCollections) {
+ DSpaceObject dso = handleService.resolveToObject(context, handle);
+ if (dso instanceof Collection) {
+ Iterator- items = itemService.findByCollection(context, (Collection) dso);
+ while (items.hasNext()) {
+ Item item = items.next();
+ candidates.putIfAbsent(item.getID(), item);
+ }
+ }
+ }
+ }
+
+ String filterMetadataField = configurationService.getProperty(PROP_FILTER_METADATA_FIELD,
+ DEFAULT_FILTER_METADATA_FIELD);
+ String filterValue = configurationService.getProperty(PROP_FILTER_VALUE);
+ if (StringUtils.isNotBlank(filterMetadataField) && StringUtils.isNotBlank(filterValue)) {
+ String[] fieldParts = filterMetadataField.split("\\.");
+ String schema = fieldParts.length > 0 ? fieldParts[0] : null;
+ String element = fieldParts.length > 1 ? fieldParts[1] : null;
+ String qualifier = fieldParts.length > 2 ? fieldParts[2] : null;
+ try {
+ Iterator
- items =
+ itemService.findArchivedByMetadataField(context, schema, element, qualifier, filterValue);
+ while (items.hasNext()) {
+ Item item = items.next();
+ candidates.putIfAbsent(item.getID(), item);
+ }
+ } catch (AuthorizeException e) {
+ log.warn("Unexpected AuthorizeException while listing HuggingFace-exposed items by metadata "
+ + "filter '{}' = '{}'.", filterMetadataField, filterValue, e);
+ }
+ }
+
+ String searchLower = StringUtils.isNotBlank(search) ? search.toLowerCase(Locale.ROOT) : null;
+
+ List
- result = new ArrayList<>();
+ for (Item item : candidates.values()) {
+ if (result.size() >= effectiveLimit) {
+ break;
+ }
+ if (!isExposed(context, item)) {
+ continue;
+ }
+ if (searchLower != null && !matchesSearch(item, searchLower)) {
+ continue;
+ }
+ if (!authorizeService.authorizeActionBoolean(context, item, Constants.READ)) {
+ continue;
+ }
+ result.add(item);
+ }
+
+ return result;
+ }
+
+ /**
+ * Check whether an Item's title (or name, if it has no title) contains the given (already
+ * lower-cased) search string.
+ *
+ * @param item the item
+ * @param searchLower the search string, already lower-cased
+ * @return {@code true} if the item's title/name contains {@code searchLower}, case-insensitively
+ */
+ private boolean matchesSearch(Item item, String searchLower) {
+ String title = itemService.getMetadataFirstValue(item, "dc", "title", null, Item.ANY);
+ if (StringUtils.isBlank(title)) {
+ title = item.getName();
+ }
+ return title != null && title.toLowerCase(Locale.ROOT).contains(searchLower);
+ }
+
+ /**
+ * Compute a deterministic sha (revision id) for an Item, given its last-modified timestamp and the
+ * filename-to-md5 map of its ORIGINAL bitstreams.
+ *
+ *
The result is a 40-character lowercase hex string (SHA-1), deterministic across JVM runs and
+ * insensitive to the iteration order of {@code filenameToMd5}.
+ *
+ * @param itemUuid the item's UUID, as a string
+ * @param lastModifiedMillis the item's last-modified timestamp, in epoch milliseconds
+ * @param filenameToMd5 map of ORIGINAL bitstream filename to its MD5 checksum
+ * @return the computed sha, a 40-character lowercase hex string
+ */
+ public static String computeSha(String itemUuid, long lastModifiedMillis, Map filenameToMd5) {
+ List pairs = new ArrayList<>();
+ for (Map.Entry entry : filenameToMd5.entrySet()) {
+ pairs.add(entry.getKey() + ":" + entry.getValue());
+ }
+ Collections.sort(pairs);
+ String joined = String.join("\n", pairs);
+ String input = itemUuid + "\n" + lastModifiedMillis + "\n" + joined;
+ return DigestUtils.sha1Hex(input);
+ }
+
+ /**
+ * Check whether a requested revision string is valid, given the current sha of an Item.
+ *
+ * @param revision the requested revision (path parameter of the facade endpoints)
+ * @param currentSha the current sha of the Item, as computed by {@link #computeSha(String, long, Map)}
+ * @return {@code true} if {@code revision} equals {@code "main"} or equals {@code currentSha}
+ * (case-insensitively); {@code false} if {@code revision} is null or blank
+ */
+ public static boolean isValidRevision(String revision, String currentSha) {
+ if (StringUtils.isBlank(revision)) {
+ return false;
+ }
+ return MAIN_REVISION.equals(revision) || revision.equalsIgnoreCase(currentSha);
+ }
+
+ /**
+ * Compute the current sha of an Item, based on its ORIGINAL bitstreams and last-modified timestamp.
+ *
+ * @param context the DSpace context
+ * @param item the item
+ * @return the computed sha, a 40-character lowercase hex string
+ */
+ public String computeShaForItem(Context context, Item item) {
+ Map originalFiles = getOriginalFiles(item);
+ Map filenameToMd5 = new LinkedHashMap<>();
+ for (Map.Entry entry : originalFiles.entrySet()) {
+ filenameToMd5.put(entry.getKey(), entry.getValue().getChecksum());
+ }
+ long lastModifiedMillis = item.getLastModified() != null ? item.getLastModified().getTime() : 0L;
+ String itemUuid = item.getID().toString();
+ return computeSha(itemUuid, lastModifiedMillis, filenameToMd5);
+ }
+
+ /**
+ * Collect the filename-to-bitstream map of all bitstreams in the Item's ORIGINAL bundles, iterating
+ * bundles then bitstreams in order. If several bitstreams share the same filename, the first one
+ * encountered wins.
+ *
+ * @param item the item
+ * @return a filename-to-bitstream map, preserving encounter order; empty if the item has no ORIGINAL
+ * bundle or the bundle(s) are empty
+ */
+ public LinkedHashMap getOriginalFiles(Item item) {
+ LinkedHashMap result = new LinkedHashMap<>();
+ List bundles = item.getBundles(Constants.CONTENT_BUNDLE_NAME);
+ for (Bundle bundle : bundles) {
+ for (Bitstream bitstream : bundle.getBitstreams()) {
+ result.putIfAbsent(bitstream.getName(), bitstream);
+ }
+ }
+ return result;
+ }
+
+ /**
+ * Build the HuggingFace-Hub-shaped model info for an Item.
+ *
+ * @param context the DSpace context
+ * @param item the item
+ * @param sha the item's current sha, as computed by {@link #computeShaForItem(Context, Item)}
+ * @return the model info
+ */
+ public HuggingFaceModelInfo toModelInfo(Context context, Item item, String sha) {
+ HuggingFaceModelInfo modelInfo = new HuggingFaceModelInfo();
+
+ String handle = item.getHandle();
+ modelInfo.setId(handle);
+ modelInfo.setModelId(handle);
+ modelInfo.setAuthor(handleAuthor(handle));
+ modelInfo.setSha(sha);
+
+ LinkedHashSet tags = new LinkedHashSet<>();
+ addMetadataValues(tags, itemService.getMetadataByMetadataString(item, "dc.subject"));
+ List languages = metadataValues(itemService.getMetadataByMetadataString(item, "dc.language.iso"));
+ tags.addAll(languages);
+ addMetadataValues(tags, itemService.getMetadataByMetadataString(item, "dc.type"));
+ modelInfo.setTags(new ArrayList<>(tags));
+
+ Map cardData = new LinkedHashMap<>();
+ List rights = itemService.getMetadataByMetadataString(item, "dc.rights");
+ if (!rights.isEmpty()) {
+ cardData.put("license", rights.get(0).getValue());
+ }
+ if (!languages.isEmpty()) {
+ cardData.put("language", languages);
+ }
+ cardData.put("tags", new ArrayList<>(tags));
+ modelInfo.setCardData(cardData);
+
+ long lastModifiedMillis = item.getLastModified() != null ? item.getLastModified().getTime() : 0L;
+ String lastModified = Instant.ofEpochMilli(lastModifiedMillis).toString();
+ modelInfo.setLastModified(lastModified);
+ modelInfo.setCreatedAt(resolveCreatedAt(item, lastModified));
+
+ List siblings = new ArrayList<>();
+ for (String filename : getOriginalFiles(item).keySet()) {
+ siblings.add(new HuggingFaceSibling(filename));
+ }
+ modelInfo.setSiblings(siblings);
+
+ modelInfo.setPrivate(false);
+ modelInfo.setGated(false);
+ modelInfo.setDisabled(false);
+ modelInfo.setDownloads(0L);
+ modelInfo.setLikes(0L);
+
+ return modelInfo;
+ }
+
+ /**
+ * Build the HuggingFace-Hub-shaped file tree for an Item, one entry per ORIGINAL bitstream.
+ *
+ * @param context the DSpace context
+ * @param item the item
+ * @return the list of tree entries; empty if the item has no ORIGINAL bitstreams
+ */
+ public List toTreeEntries(Context context, Item item) {
+ List entries = new ArrayList<>();
+ for (Map.Entry entry : getOriginalFiles(item).entrySet()) {
+ HuggingFaceTreeEntry treeEntry = new HuggingFaceTreeEntry();
+ treeEntry.setType("file");
+ treeEntry.setPath(entry.getKey());
+ treeEntry.setSize(entry.getValue().getSizeBytes());
+ treeEntry.setOid(entry.getValue().getChecksum());
+ entries.add(treeEntry);
+ }
+ return entries;
+ }
+
+ /**
+ * Extract the author (handle prefix) from a handle.
+ *
+ * @param handle the handle, e.g. {@code "11234/1-5814"}
+ * @return the handle prefix, e.g. {@code "11234"}, or the handle itself if it does not contain a slash
+ */
+ private String handleAuthor(String handle) {
+ if (handle == null) {
+ return null;
+ }
+ int slashIndex = handle.indexOf('/');
+ return slashIndex >= 0 ? handle.substring(0, slashIndex) : handle;
+ }
+
+ /**
+ * Resolve the "createdAt" timestamp of an Item from its {@code dc.date.accessioned} metadata, falling
+ * back to the given last-modified timestamp if the metadata is missing or unparseable.
+ *
+ * @param item the item
+ * @param lastModified the pre-computed, ISO-8601 last-modified timestamp to fall back on
+ * @return the ISO-8601 createdAt timestamp
+ */
+ private String resolveCreatedAt(Item item, String lastModified) {
+ List accessioned = itemService.getMetadataByMetadataString(item, "dc.date.accessioned");
+ if (accessioned.isEmpty()) {
+ return lastModified;
+ }
+ try {
+ return Instant.parse(accessioned.get(0).getValue()).toString();
+ } catch (RuntimeException e) {
+ return lastModified;
+ }
+ }
+
+ /**
+ * Extract the {@link MetadataValue#getValue()} of each entry into a new list.
+ *
+ * @param values the metadata values
+ * @return the extracted string values, in the same order
+ */
+ private List metadataValues(List values) {
+ List result = new ArrayList<>();
+ addMetadataValues(result, values);
+ return result;
+ }
+
+ /**
+ * Append the {@link MetadataValue#getValue()} of each entry to the given collection.
+ *
+ * @param target the collection to append to
+ * @param values the metadata values
+ */
+ private void addMetadataValues(java.util.Collection target, List values) {
+ for (MetadataValue value : values) {
+ target.add(value.getValue());
+ }
+ }
+}
diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/hf/HuggingFaceModelInfo.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/hf/HuggingFaceModelInfo.java
new file mode 100644
index 000000000000..f4b02c5e8ed2
--- /dev/null
+++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/hf/HuggingFaceModelInfo.java
@@ -0,0 +1,312 @@
+/**
+ * The contents of this file are subject to the license and copyright
+ * detailed in the LICENSE and NOTICE files at the root of the source
+ * tree and available online at
+ *
+ * http://www.dspace.org/license/
+ */
+package org.dspace.app.rest.hf;
+
+import java.util.List;
+import java.util.Map;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * Plain Jackson POJO modeling the JSON shape expected by huggingface_hub's
+ * {@code model_info} response (as consumed by the {@code huggingface_hub} Python
+ * client library). This class is intentionally decoupled from the DSpace REST
+ * HAL/RestRepository machinery: it is a simple data holder that is serialized
+ * directly to JSON by the (future) HuggingFace-facade controller.
+ *
+ * @author DSpace at UFAL
+ */
+public class HuggingFaceModelInfo {
+
+ private String id;
+
+ private String modelId;
+
+ private String author;
+
+ private String sha;
+
+ private String lastModified;
+
+ private String createdAt;
+
+ @JsonProperty("private")
+ private boolean isPrivate;
+
+ private boolean gated;
+
+ private boolean disabled;
+
+ private long downloads;
+
+ private long likes;
+
+ private List tags;
+
+ private Map cardData;
+
+ private List siblings;
+
+ /**
+ * Default no-arg constructor required for Jackson (de)serialization.
+ */
+ public HuggingFaceModelInfo() {
+ }
+
+ /**
+ * Get the repository id (in our case, the Item handle).
+ *
+ * @return the repository id
+ */
+ public String getId() {
+ return id;
+ }
+
+ /**
+ * Set the repository id.
+ *
+ * @param id the repository id
+ */
+ public void setId(String id) {
+ this.id = id;
+ }
+
+ /**
+ * Get the model id (in our case, the same value as {@link #getId()}).
+ *
+ * @return the model id
+ */
+ public String getModelId() {
+ return modelId;
+ }
+
+ /**
+ * Set the model id.
+ *
+ * @param modelId the model id
+ */
+ public void setModelId(String modelId) {
+ this.modelId = modelId;
+ }
+
+ /**
+ * Get the author (in our case, the handle prefix).
+ *
+ * @return the author
+ */
+ public String getAuthor() {
+ return author;
+ }
+
+ /**
+ * Set the author.
+ *
+ * @param author the author
+ */
+ public void setAuthor(String author) {
+ this.author = author;
+ }
+
+ /**
+ * Get the sha (revision) of the repository content.
+ *
+ * @return the sha
+ */
+ public String getSha() {
+ return sha;
+ }
+
+ /**
+ * Set the sha (revision) of the repository content.
+ *
+ * @param sha the sha
+ */
+ public void setSha(String sha) {
+ this.sha = sha;
+ }
+
+ /**
+ * Get the last modification timestamp, as an ISO-8601 string.
+ *
+ * @return the last modification timestamp
+ */
+ public String getLastModified() {
+ return lastModified;
+ }
+
+ /**
+ * Set the last modification timestamp.
+ *
+ * @param lastModified the last modification timestamp, as an ISO-8601 string
+ */
+ public void setLastModified(String lastModified) {
+ this.lastModified = lastModified;
+ }
+
+ /**
+ * Get the creation timestamp, as an ISO-8601 string.
+ *
+ * @return the creation timestamp
+ */
+ public String getCreatedAt() {
+ return createdAt;
+ }
+
+ /**
+ * Set the creation timestamp.
+ *
+ * @param createdAt the creation timestamp, as an ISO-8601 string
+ */
+ public void setCreatedAt(String createdAt) {
+ this.createdAt = createdAt;
+ }
+
+ /**
+ * Whether the repository is private. Always {@code false} for items exposed by this facade.
+ *
+ * @return whether the repository is private
+ */
+ public boolean isPrivate() {
+ return isPrivate;
+ }
+
+ /**
+ * Set whether the repository is private.
+ *
+ * @param isPrivate whether the repository is private
+ */
+ public void setPrivate(boolean isPrivate) {
+ this.isPrivate = isPrivate;
+ }
+
+ /**
+ * Whether the repository is gated. Always {@code false} for items exposed by this facade.
+ *
+ * @return whether the repository is gated
+ */
+ public boolean isGated() {
+ return gated;
+ }
+
+ /**
+ * Set whether the repository is gated.
+ *
+ * @param gated whether the repository is gated
+ */
+ public void setGated(boolean gated) {
+ this.gated = gated;
+ }
+
+ /**
+ * Whether the repository is disabled. Always {@code false} for items exposed by this facade.
+ *
+ * @return whether the repository is disabled
+ */
+ public boolean isDisabled() {
+ return disabled;
+ }
+
+ /**
+ * Set whether the repository is disabled.
+ *
+ * @param disabled whether the repository is disabled
+ */
+ public void setDisabled(boolean disabled) {
+ this.disabled = disabled;
+ }
+
+ /**
+ * Get the number of downloads. Always {@code 0} for items exposed by this facade.
+ *
+ * @return the number of downloads
+ */
+ public long getDownloads() {
+ return downloads;
+ }
+
+ /**
+ * Set the number of downloads.
+ *
+ * @param downloads the number of downloads
+ */
+ public void setDownloads(long downloads) {
+ this.downloads = downloads;
+ }
+
+ /**
+ * Get the number of likes. Always {@code 0} for items exposed by this facade.
+ *
+ * @return the number of likes
+ */
+ public long getLikes() {
+ return likes;
+ }
+
+ /**
+ * Set the number of likes.
+ *
+ * @param likes the number of likes
+ */
+ public void setLikes(long likes) {
+ this.likes = likes;
+ }
+
+ /**
+ * Get the tags associated with this repository.
+ *
+ * @return the tags
+ */
+ public List getTags() {
+ return tags;
+ }
+
+ /**
+ * Set the tags associated with this repository.
+ *
+ * @param tags the tags
+ */
+ public void setTags(List tags) {
+ this.tags = tags;
+ }
+
+ /**
+ * Get the card data (metadata block normally found in the model card's YAML front matter).
+ *
+ * @return the card data
+ */
+ public Map getCardData() {
+ return cardData;
+ }
+
+ /**
+ * Set the card data.
+ *
+ * @param cardData the card data
+ */
+ public void setCardData(Map cardData) {
+ this.cardData = cardData;
+ }
+
+ /**
+ * Get the list of files (siblings) belonging to this repository.
+ *
+ * @return the siblings
+ */
+ public List getSiblings() {
+ return siblings;
+ }
+
+ /**
+ * Set the list of files (siblings) belonging to this repository.
+ *
+ * @param siblings the siblings
+ */
+ public void setSiblings(List siblings) {
+ this.siblings = siblings;
+ }
+}
diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/hf/HuggingFaceSibling.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/hf/HuggingFaceSibling.java
new file mode 100644
index 000000000000..1ab614cad756
--- /dev/null
+++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/hf/HuggingFaceSibling.java
@@ -0,0 +1,54 @@
+/**
+ * The contents of this file are subject to the license and copyright
+ * detailed in the LICENSE and NOTICE files at the root of the source
+ * tree and available online at
+ *
+ * http://www.dspace.org/license/
+ */
+package org.dspace.app.rest.hf;
+
+/**
+ * Plain Jackson POJO representing a single entry of the {@code siblings} array
+ * returned by the HuggingFace Hub {@code model_info} API. Each sibling corresponds
+ * to a single file that belongs to the "model repository" (in our case, an ORIGINAL
+ * bitstream of a DSpace Item).
+ *
+ * @author DSpace at UFAL
+ */
+public class HuggingFaceSibling {
+
+ private String rfilename;
+
+ /**
+ * Default no-arg constructor required for Jackson (de)serialization.
+ */
+ public HuggingFaceSibling() {
+ }
+
+ /**
+ * Construct a sibling entry for the given filename.
+ *
+ * @param rfilename the repository-relative filename
+ */
+ public HuggingFaceSibling(String rfilename) {
+ this.rfilename = rfilename;
+ }
+
+ /**
+ * Get the repository-relative filename.
+ *
+ * @return the filename
+ */
+ public String getRfilename() {
+ return rfilename;
+ }
+
+ /**
+ * Set the repository-relative filename.
+ *
+ * @param rfilename the filename
+ */
+ public void setRfilename(String rfilename) {
+ this.rfilename = rfilename;
+ }
+}
diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/hf/HuggingFaceTreeEntry.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/hf/HuggingFaceTreeEntry.java
new file mode 100644
index 000000000000..e7e79d31ac50
--- /dev/null
+++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/hf/HuggingFaceTreeEntry.java
@@ -0,0 +1,105 @@
+/**
+ * The contents of this file are subject to the license and copyright
+ * detailed in the LICENSE and NOTICE files at the root of the source
+ * tree and available online at
+ *
+ * http://www.dspace.org/license/
+ */
+package org.dspace.app.rest.hf;
+
+/**
+ * Plain Jackson POJO representing a single entry returned by the HuggingFace Hub
+ * "tree" endpoint (e.g. {@code GET /api/models/{repo_id}/tree/{revision}}). Each entry
+ * describes one file present in the repository (in our case, an ORIGINAL bitstream of
+ * a DSpace Item).
+ *
+ * @author DSpace at UFAL
+ */
+public class HuggingFaceTreeEntry {
+
+ private String type;
+
+ private String path;
+
+ private long size;
+
+ private String oid;
+
+ /**
+ * Default no-arg constructor required for Jackson (de)serialization.
+ */
+ public HuggingFaceTreeEntry() {
+ }
+
+ /**
+ * Get the entry type, e.g. {@code "file"}.
+ *
+ * @return the entry type
+ */
+ public String getType() {
+ return type;
+ }
+
+ /**
+ * Set the entry type.
+ *
+ * @param type the entry type
+ */
+ public void setType(String type) {
+ this.type = type;
+ }
+
+ /**
+ * Get the repository-relative path of the file.
+ *
+ * @return the file path
+ */
+ public String getPath() {
+ return path;
+ }
+
+ /**
+ * Set the repository-relative path of the file.
+ *
+ * @param path the file path
+ */
+ public void setPath(String path) {
+ this.path = path;
+ }
+
+ /**
+ * Get the file size in bytes.
+ *
+ * @return the file size in bytes
+ */
+ public long getSize() {
+ return size;
+ }
+
+ /**
+ * Set the file size in bytes.
+ *
+ * @param size the file size in bytes
+ */
+ public void setSize(long size) {
+ this.size = size;
+ }
+
+ /**
+ * Get the object id (checksum) of the file, as reported by the git/LFS-like tree API.
+ *
+ * @return the object id
+ */
+ public String getOid() {
+ return oid;
+ }
+
+ /**
+ * Set the object id (checksum) of the file.
+ *
+ * @param oid the object id
+ */
+ public void setOid(String oid) {
+ this.oid = oid;
+ }
+}
diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/ClarinHuggingFaceControllerIT.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/ClarinHuggingFaceControllerIT.java
new file mode 100644
index 000000000000..87427d514954
--- /dev/null
+++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/ClarinHuggingFaceControllerIT.java
@@ -0,0 +1,434 @@
+/**
+ * The contents of this file are subject to the license and copyright
+ * detailed in the LICENSE and NOTICE files at the root of the source
+ * tree and available online at
+ *
+ * http://www.dspace.org/license/
+ */
+package org.dspace.app.rest;
+
+import static org.hamcrest.Matchers.everyItem;
+import static org.hamcrest.Matchers.hasItem;
+import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.matchesPattern;
+import static org.hamcrest.Matchers.not;
+import static org.junit.Assert.assertEquals;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.head;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import java.io.InputStream;
+import java.net.URI;
+import java.util.HashMap;
+import java.util.Map;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.commons.codec.CharEncoding;
+import org.apache.commons.codec.digest.DigestUtils;
+import org.apache.commons.io.IOUtils;
+import org.dspace.app.rest.test.AbstractControllerIntegrationTest;
+import org.dspace.authorize.service.AuthorizeService;
+import org.dspace.builder.BitstreamBuilder;
+import org.dspace.builder.CollectionBuilder;
+import org.dspace.builder.CommunityBuilder;
+import org.dspace.builder.ItemBuilder;
+import org.dspace.builder.ResourcePolicyBuilder;
+import org.dspace.content.Bitstream;
+import org.dspace.content.Collection;
+import org.dspace.content.Item;
+import org.dspace.content.service.ItemService;
+import org.dspace.core.Constants;
+import org.dspace.services.ConfigurationService;
+import org.junit.Before;
+import org.junit.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.test.web.servlet.MvcResult;
+
+/**
+ * Integration tests for {@link ClarinHuggingFaceController}.
+ */
+public class ClarinHuggingFaceControllerIT extends AbstractControllerIntegrationTest {
+
+ private static final String ENDPOINT_BASE = "/api/hf/api/models";
+
+ private static final String ZERO_REVISION = "0000000000000000000000000000000000000000";
+
+ private static final String MODEL_BIN_CONTENT = "hello model";
+
+ private static final String CONFIG_JSON_CONTENT = "config content";
+
+ @Autowired
+ private ConfigurationService configurationService;
+
+ @Autowired
+ private AuthorizeService authorizeService;
+
+ @Autowired
+ private ItemService itemService;
+
+ private Item exposedItem;
+
+ private Item nonExposedItem;
+
+ @Before
+ @Override
+ public void setUp() throws Exception {
+ super.setUp();
+
+ configurationService.setProperty("hf.api.enabled", true);
+ configurationService.setProperty("hf.api.filter.metadata-field", "dc.type");
+ configurationService.setProperty("hf.api.filter.value", "machineLearningModel");
+
+ context.turnOffAuthorisationSystem();
+
+ parentCommunity = CommunityBuilder.createCommunity(context)
+ .withName("Parent Community")
+ .build();
+ Collection collection = CollectionBuilder.createCollection(context, parentCommunity)
+ .withName("Collection")
+ .build();
+
+ exposedItem = ItemBuilder.createItem(context, collection)
+ .withTitle("A Machine Learning Model")
+ .withType("machineLearningModel")
+ .withSubject("nlp")
+ .withSubject("classification")
+ .build();
+ try (InputStream is = IOUtils.toInputStream(MODEL_BIN_CONTENT, CharEncoding.UTF_8)) {
+ BitstreamBuilder.createBitstream(context, exposedItem, is)
+ .withName("model.bin")
+ .withMimeType("application/octet-stream")
+ .build();
+ }
+ try (InputStream is = IOUtils.toInputStream(CONFIG_JSON_CONTENT, CharEncoding.UTF_8)) {
+ BitstreamBuilder.createBitstream(context, exposedItem, is)
+ .withName("config.json")
+ .withMimeType("application/json")
+ .build();
+ }
+
+ nonExposedItem = ItemBuilder.createItem(context, collection)
+ .withTitle("A Plain Item")
+ .build();
+
+ context.restoreAuthSystemState();
+ }
+
+ @Test
+ public void modelInfoReturnsExpectedShape() throws Exception {
+ String[] handleParts = exposedItem.getHandle().split("/");
+
+ getClient().perform(get(ENDPOINT_BASE + "/" + handleParts[0] + "/" + handleParts[1]))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.id", is(exposedItem.getHandle())))
+ .andExpect(jsonPath("$.sha", matchesPattern("^[0-9a-f]{40}$")))
+ .andExpect(jsonPath("$.private", is(false)))
+ .andExpect(jsonPath("$.gated", is(false)))
+ .andExpect(jsonPath("$.downloads", is(0)))
+ .andExpect(jsonPath("$.siblings", hasSize(2)))
+ .andExpect(jsonPath("$.siblings[*].rfilename", hasItem("model.bin")))
+ .andExpect(jsonPath("$.siblings[*].rfilename", hasItem("config.json")))
+ .andExpect(jsonPath("$.tags", hasItem("nlp")))
+ .andExpect(jsonPath("$.tags", hasItem("classification")))
+ .andExpect(jsonPath("$.tags", hasItem("machineLearningModel")));
+ }
+
+ @Test
+ public void revisionMainIsValidAndZeroRevisionIsNotFound() throws Exception {
+ String[] handleParts = exposedItem.getHandle().split("/");
+ String base = ENDPOINT_BASE + "/" + handleParts[0] + "/" + handleParts[1];
+
+ getClient().perform(get(base + "/revision/main"))
+ .andExpect(status().isOk());
+
+ getClient().perform(get(base + "/revision/" + ZERO_REVISION))
+ .andExpect(status().isNotFound())
+ .andExpect(header().string("X-Error-Code", "RevisionNotFound"));
+ }
+
+ @Test
+ public void treeMainReturnsFileEntriesWithCorrectSizeAndOid() throws Exception {
+ String[] handleParts = exposedItem.getHandle().split("/");
+
+ MvcResult result = getClient()
+ .perform(get(ENDPOINT_BASE + "/" + handleParts[0] + "/" + handleParts[1] + "/tree/main"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$", hasSize(2)))
+ .andExpect(jsonPath("$[*].type", everyItem(is("file"))))
+ .andReturn();
+
+ JsonNode entries = new ObjectMapper().readTree(result.getResponse().getContentAsString());
+ Map byPath = new HashMap<>();
+ for (JsonNode entry : entries) {
+ byPath.put(entry.get("path").asText(), entry);
+ }
+
+ assertEquals(MODEL_BIN_CONTENT.length(), byPath.get("model.bin").get("size").asLong());
+ assertEquals(DigestUtils.md5Hex(MODEL_BIN_CONTENT), byPath.get("model.bin").get("oid").asText());
+ assertEquals(CONFIG_JSON_CONTENT.length(), byPath.get("config.json").get("size").asLong());
+ assertEquals(DigestUtils.md5Hex(CONFIG_JSON_CONTENT), byPath.get("config.json").get("oid").asText());
+ }
+
+ @Test
+ public void treeAcceptsCurrentSha() throws Exception {
+ String[] handleParts = exposedItem.getHandle().split("/");
+ String base = ENDPOINT_BASE + "/" + handleParts[0] + "/" + handleParts[1];
+
+ MvcResult result = getClient().perform(get(base))
+ .andExpect(status().isOk())
+ .andReturn();
+ String content = result.getResponse().getContentAsString();
+ JsonNode json = new ObjectMapper().readTree(content);
+ String sha = json.get("sha").asText();
+
+ getClient().perform(get(base + "/tree/" + sha))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$", hasSize(2)));
+ }
+
+ @Test
+ public void nonExposedItemIsNotFound() throws Exception {
+ String[] handleParts = nonExposedItem.getHandle().split("/");
+
+ getClient().perform(get(ENDPOINT_BASE + "/" + handleParts[0] + "/" + handleParts[1]))
+ .andExpect(status().isNotFound())
+ .andExpect(header().string("X-Error-Code", "RepoNotFound"));
+ }
+
+ @Test
+ public void listModelsReturnsOnlyExposedItems() throws Exception {
+ context.turnOffAuthorisationSystem();
+ Collection collection = exposedItem.getOwningCollection();
+ Item secondExposedItem = ItemBuilder.createItem(context, collection)
+ .withTitle("Another Machine Learning Model")
+ .withType("machineLearningModel")
+ .build();
+ context.restoreAuthSystemState();
+
+ getClient().perform(get(ENDPOINT_BASE))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$[*].id", hasItem(exposedItem.getHandle())))
+ .andExpect(jsonPath("$[*].id", hasItem(secondExposedItem.getHandle())))
+ .andExpect(jsonPath("$[*].id", not(hasItem(nonExposedItem.getHandle()))));
+ }
+
+ @Test
+ public void listModelsExcludesItemsWithoutReadAccess() throws Exception {
+ context.turnOffAuthorisationSystem();
+ Collection collection = exposedItem.getOwningCollection();
+ Item restrictedExposedItem = ItemBuilder.createItem(context, collection)
+ .withTitle("Restricted Machine Learning Model")
+ .withType("machineLearningModel")
+ .build();
+ // Remove all read policies from the item and grant READ to admin only, so the item is still
+ // "exposed" (passes the HuggingFace filter) but not readable by an anonymous user.
+ authorizeService.removeAllPolicies(context, restrictedExposedItem);
+ ResourcePolicyBuilder.createResourcePolicy(context, admin, null)
+ .withDspaceObject(restrictedExposedItem)
+ .withAction(Constants.READ)
+ .build();
+ context.restoreAuthSystemState();
+
+ // Anonymous user cannot read the restricted item, so it must not appear in the listing.
+ getClient().perform(get(ENDPOINT_BASE))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$[*].id", hasItem(exposedItem.getHandle())))
+ .andExpect(jsonPath("$[*].id", not(hasItem(restrictedExposedItem.getHandle()))));
+
+ // The admin can read it, so it must appear in the listing.
+ String adminToken = getAuthToken(admin.getEmail(), password);
+ getClient(adminToken).perform(get(ENDPOINT_BASE))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$[*].id", hasItem(restrictedExposedItem.getHandle())));
+ }
+
+ @Test
+ public void listModelsSupportsSearchAndLimit() throws Exception {
+ context.turnOffAuthorisationSystem();
+ Collection collection = exposedItem.getOwningCollection();
+ ItemBuilder.createItem(context, collection)
+ .withTitle("Second Model")
+ .withType("machineLearningModel")
+ .build();
+ context.restoreAuthSystemState();
+
+ getClient().perform(get(ENDPOINT_BASE).param("search", "Machine Learning"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$", hasSize(1)))
+ .andExpect(jsonPath("$[0].id", is(exposedItem.getHandle())));
+
+ getClient().perform(get(ENDPOINT_BASE).param("limit", "1"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$", hasSize(1)));
+ }
+
+ @Test
+ public void listModelsDisabledFacadeReturnsNotFound() throws Exception {
+ configurationService.setProperty("hf.api.enabled", false);
+
+ getClient().perform(get(ENDPOINT_BASE))
+ .andExpect(status().isNotFound());
+ }
+
+ @Test
+ public void disabledFacadeReturnsNotFound() throws Exception {
+ configurationService.setProperty("hf.api.enabled", false);
+ String[] handleParts = exposedItem.getHandle().split("/");
+
+ getClient().perform(get(ENDPOINT_BASE + "/" + handleParts[0] + "/" + handleParts[1]))
+ .andExpect(status().isNotFound());
+ }
+
+ @Test
+ public void withdrawnItemIsNotFound() throws Exception {
+ String[] handleParts = exposedItem.getHandle().split("/");
+
+ context.turnOffAuthorisationSystem();
+ itemService.withdraw(context, exposedItem);
+ context.restoreAuthSystemState();
+
+ getClient().perform(get(ENDPOINT_BASE + "/" + handleParts[0] + "/" + handleParts[1]))
+ .andExpect(status().isNotFound())
+ .andExpect(header().string("X-Error-Code", "RepoNotFound"));
+ }
+
+ @Test
+ public void whoAmIAlwaysReturnsUnauthorized() throws Exception {
+ getClient().perform(get("/api/hf/api/whoami-v2"))
+ .andExpect(status().isUnauthorized());
+ }
+
+ @Test
+ public void resolveHeadReturnsMetadataHeaders() throws Exception {
+ String handle = exposedItem.getHandle();
+ String sha = fetchModelSha(handle);
+
+ getClient().perform(head("/api/hf/" + handle + "/resolve/main/model.bin"))
+ .andExpect(status().isOk())
+ .andExpect(header().string("X-Repo-Commit", sha))
+ .andExpect(header().string("ETag", "\"" + DigestUtils.md5Hex(MODEL_BIN_CONTENT) + "\""))
+ .andExpect(header().longValue("Content-Length", MODEL_BIN_CONTENT.length()))
+ .andExpect(header().string("Accept-Ranges", "bytes"))
+ .andExpect(header().exists("X-Linked-Etag"));
+ }
+
+ @Test
+ public void resolveGetStreamsFileContent() throws Exception {
+ String handle = exposedItem.getHandle();
+
+ getClient().perform(get("/api/hf/" + handle + "/resolve/main/model.bin"))
+ .andExpect(status().isOk())
+ .andExpect(header().exists("X-Repo-Commit"))
+ .andExpect(content().bytes(MODEL_BIN_CONTENT.getBytes()));
+ }
+
+ @Test
+ public void resolveGetSupportsRangeRequests() throws Exception {
+ String handle = exposedItem.getHandle();
+
+ getClient().perform(get("/api/hf/" + handle + "/resolve/main/model.bin")
+ .header("Range", "bytes=1-3"))
+ .andExpect(status().is(206))
+ // The Content-Length must match the requested range
+ .andExpect(header().longValue("Content-Length", 3))
+ // The server should indicate we support Range requests
+ .andExpect(header().string("Accept-Ranges", "bytes"))
+ // The ETag has to be based on the checksum
+ .andExpect(header().string("ETag", "\"" + DigestUtils.md5Hex(MODEL_BIN_CONTENT) + "\""))
+ // The response should give us details about the range
+ .andExpect(header().string("Content-Range", "bytes 1-3/" + MODEL_BIN_CONTENT.length()))
+ // We only expect the bytes 1, 2 and 3
+ .andExpect(content().bytes(MODEL_BIN_CONTENT.substring(1, 4).getBytes()));
+ }
+
+ @Test
+ public void resolveAcceptsCurrentShaAndRejectsUnknownRevision() throws Exception {
+ String handle = exposedItem.getHandle();
+ String sha = fetchModelSha(handle);
+
+ getClient().perform(get("/api/hf/" + handle + "/resolve/" + sha + "/model.bin"))
+ .andExpect(status().isOk())
+ .andExpect(content().bytes(MODEL_BIN_CONTENT.getBytes()));
+
+ getClient().perform(get("/api/hf/" + handle + "/resolve/" + ZERO_REVISION + "/model.bin"))
+ .andExpect(status().isNotFound())
+ .andExpect(header().string("X-Error-Code", "RevisionNotFound"));
+ }
+
+ @Test
+ public void resolveRestrictedBitstreamReturnsUnauthorizedOrForbidden() throws Exception {
+ String handle = exposedItem.getHandle();
+
+ context.turnOffAuthorisationSystem();
+ Bitstream restricted;
+ try (InputStream is = IOUtils.toInputStream("secret content", CharEncoding.UTF_8)) {
+ restricted = BitstreamBuilder.createBitstream(context, exposedItem, is)
+ .withName("secret.bin")
+ .withMimeType("application/octet-stream")
+ .build();
+ }
+ // Remove all read policies from the bitstream and add a read policy only for admin
+ authorizeService.removeAllPolicies(context, restricted);
+ ResourcePolicyBuilder.createResourcePolicy(context, admin, null)
+ .withDspaceObject(restricted)
+ .withAction(Constants.READ)
+ .build();
+ context.restoreAuthSystemState();
+
+ // Anonymous user should get 401
+ getClient().perform(get("/api/hf/" + handle + "/resolve/main/secret.bin"))
+ .andExpect(status().isUnauthorized());
+
+ // Authenticated non-admin user should get 403
+ String token = getAuthToken(eperson.getEmail(), password);
+ getClient(token).perform(get("/api/hf/" + handle + "/resolve/main/secret.bin"))
+ .andExpect(status().isForbidden());
+ }
+
+ @Test
+ public void resolveMissingFileReturnsEntryNotFound() throws Exception {
+ String handle = exposedItem.getHandle();
+
+ getClient().perform(get("/api/hf/" + handle + "/resolve/main/nope.bin"))
+ .andExpect(status().isNotFound())
+ .andExpect(header().string("X-Error-Code", "EntryNotFound"));
+ }
+
+ @Test
+ public void resolveSupportsUrlEncodedFilenames() throws Exception {
+ String handle = exposedItem.getHandle();
+ String encodedFileContent = "encoded filename content";
+
+ context.turnOffAuthorisationSystem();
+ try (InputStream is = IOUtils.toInputStream(encodedFileContent, CharEncoding.UTF_8)) {
+ BitstreamBuilder.createBitstream(context, exposedItem, is)
+ .withName("my model (v2).bin")
+ .withMimeType("application/octet-stream")
+ .build();
+ }
+ context.restoreAuthSystemState();
+
+ getClient().perform(get(URI.create("/api/hf/" + handle + "/resolve/main/my%20model%20%28v2%29.bin")))
+ .andExpect(status().isOk())
+ .andExpect(content().bytes(encodedFileContent.getBytes()));
+ }
+
+ /**
+ * Fetch the current sha of a model repository from its model-info JSON.
+ *
+ * @param handle the item handle
+ * @return the sha reported by the model-info endpoint
+ */
+ private String fetchModelSha(String handle) throws Exception {
+ MvcResult result = getClient().perform(get(ENDPOINT_BASE + "/" + handle))
+ .andExpect(status().isOk())
+ .andReturn();
+ JsonNode json = new ObjectMapper().readTree(result.getResponse().getContentAsString());
+ return json.get("sha").asText();
+ }
+}
diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/hf/ClarinHuggingFaceServiceTest.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/hf/ClarinHuggingFaceServiceTest.java
new file mode 100644
index 000000000000..3f59d33eaecd
--- /dev/null
+++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/hf/ClarinHuggingFaceServiceTest.java
@@ -0,0 +1,169 @@
+/**
+ * The contents of this file are subject to the license and copyright
+ * detailed in the LICENSE and NOTICE files at the root of the source
+ * tree and available online at
+ *
+ * http://www.dspace.org/license/
+ */
+package org.dspace.app.rest.hf;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertTrue;
+
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import org.junit.Test;
+
+/**
+ * Plain JUnit 4 unit tests (no Spring context) for the pure static helper methods of
+ * {@link ClarinHuggingFaceService}: {@link ClarinHuggingFaceService#computeSha(String, long, Map)} and
+ * {@link ClarinHuggingFaceService#isValidRevision(String, String)}.
+ *
+ * @author DSpace at UFAL
+ */
+public class ClarinHuggingFaceServiceTest {
+
+ private static final String UUID_1 = "11111111-1111-1111-1111-111111111111";
+
+ private static final String UUID_2 = "22222222-2222-2222-2222-222222222222";
+
+ private static final long LAST_MODIFIED_1 = 1_700_000_000_000L;
+
+ private static final long LAST_MODIFIED_2 = 1_800_000_000_000L;
+
+ /**
+ * Build a sample filename-to-md5 map.
+ *
+ * @return a map with two entries
+ */
+ private Map sampleFiles() {
+ Map files = new LinkedHashMap<>();
+ files.put("model.bin", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
+ files.put("config.json", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
+ return files;
+ }
+
+ @Test
+ public void testComputeShaReturns40CharLowercaseHex() {
+ String sha = ClarinHuggingFaceService.computeSha(UUID_1, LAST_MODIFIED_1, sampleFiles());
+ assertTrue("sha should match [0-9a-f]{40} but was: " + sha, sha.matches("[0-9a-f]{40}"));
+ }
+
+ @Test
+ public void testComputeShaIsDeterministic() {
+ String sha1 = ClarinHuggingFaceService.computeSha(UUID_1, LAST_MODIFIED_1, sampleFiles());
+ String sha2 = ClarinHuggingFaceService.computeSha(UUID_1, LAST_MODIFIED_1, sampleFiles());
+ assertEquals(sha1, sha2);
+ }
+
+ @Test
+ public void testComputeShaIsInsensitiveToMapInsertionOrder() {
+ Map hashMapOrder = new HashMap<>();
+ hashMapOrder.put("model.bin", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
+ hashMapOrder.put("config.json", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
+ hashMapOrder.put("tokenizer.json", "cccccccccccccccccccccccccccccccc");
+
+ Map reversedLinkedOrder = new LinkedHashMap<>();
+ reversedLinkedOrder.put("tokenizer.json", "cccccccccccccccccccccccccccccccc");
+ reversedLinkedOrder.put("config.json", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
+ reversedLinkedOrder.put("model.bin", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
+
+ String shaFromHashMap = ClarinHuggingFaceService.computeSha(UUID_1, LAST_MODIFIED_1, hashMapOrder);
+ String shaFromReversedLinkedMap =
+ ClarinHuggingFaceService.computeSha(UUID_1, LAST_MODIFIED_1, reversedLinkedOrder);
+
+ assertEquals(shaFromHashMap, shaFromReversedLinkedMap);
+ }
+
+ @Test
+ public void testComputeShaChangesWhenUuidChanges() {
+ String sha1 = ClarinHuggingFaceService.computeSha(UUID_1, LAST_MODIFIED_1, sampleFiles());
+ String sha2 = ClarinHuggingFaceService.computeSha(UUID_2, LAST_MODIFIED_1, sampleFiles());
+ assertNotEquals(sha1, sha2);
+ }
+
+ @Test
+ public void testComputeShaChangesWhenLastModifiedChanges() {
+ String sha1 = ClarinHuggingFaceService.computeSha(UUID_1, LAST_MODIFIED_1, sampleFiles());
+ String sha2 = ClarinHuggingFaceService.computeSha(UUID_1, LAST_MODIFIED_2, sampleFiles());
+ assertNotEquals(sha1, sha2);
+ }
+
+ @Test
+ public void testComputeShaChangesWhenFilenameChanges() {
+ Map files = sampleFiles();
+ String sha1 = ClarinHuggingFaceService.computeSha(UUID_1, LAST_MODIFIED_1, files);
+
+ Map renamed = new LinkedHashMap<>();
+ renamed.put("model-renamed.bin", files.get("model.bin"));
+ renamed.put("config.json", files.get("config.json"));
+ String sha2 = ClarinHuggingFaceService.computeSha(UUID_1, LAST_MODIFIED_1, renamed);
+
+ assertNotEquals(sha1, sha2);
+ }
+
+ @Test
+ public void testComputeShaChangesWhenMd5Changes() {
+ Map files = sampleFiles();
+ String sha1 = ClarinHuggingFaceService.computeSha(UUID_1, LAST_MODIFIED_1, files);
+
+ Map changed = new LinkedHashMap<>(files);
+ changed.put("model.bin", "ffffffffffffffffffffffffffffffff");
+ String sha2 = ClarinHuggingFaceService.computeSha(UUID_1, LAST_MODIFIED_1, changed);
+
+ assertNotEquals(sha1, sha2);
+ }
+
+ @Test
+ public void testComputeShaChangesWhenFileAdded() {
+ Map files = sampleFiles();
+ String sha1 = ClarinHuggingFaceService.computeSha(UUID_1, LAST_MODIFIED_1, files);
+
+ Map withExtra = new LinkedHashMap<>(files);
+ withExtra.put("extra.txt", "dddddddddddddddddddddddddddddddd");
+ String sha2 = ClarinHuggingFaceService.computeSha(UUID_1, LAST_MODIFIED_1, withExtra);
+
+ assertNotEquals(sha1, sha2);
+ }
+
+ @Test
+ public void testIsValidRevisionMainIsAlwaysValid() {
+ String sha = ClarinHuggingFaceService.computeSha(UUID_1, LAST_MODIFIED_1, sampleFiles());
+ assertTrue(ClarinHuggingFaceService.isValidRevision("main", sha));
+ }
+
+ @Test
+ public void testIsValidRevisionMatchingShaIsValid() {
+ String sha = ClarinHuggingFaceService.computeSha(UUID_1, LAST_MODIFIED_1, sampleFiles());
+ assertTrue(ClarinHuggingFaceService.isValidRevision(sha, sha));
+ }
+
+ @Test
+ public void testIsValidRevisionMatchingShaIsCaseInsensitive() {
+ String sha = ClarinHuggingFaceService.computeSha(UUID_1, LAST_MODIFIED_1, sampleFiles());
+ assertTrue(ClarinHuggingFaceService.isValidRevision(sha.toUpperCase(), sha));
+ }
+
+ @Test
+ public void testIsValidRevisionDifferentShaIsInvalid() {
+ String sha = ClarinHuggingFaceService.computeSha(UUID_1, LAST_MODIFIED_1, sampleFiles());
+ String otherSha = ClarinHuggingFaceService.computeSha(UUID_2, LAST_MODIFIED_2, sampleFiles());
+ assertFalse(ClarinHuggingFaceService.isValidRevision(otherSha, sha));
+ }
+
+ @Test
+ public void testIsValidRevisionNullIsInvalid() {
+ String sha = ClarinHuggingFaceService.computeSha(UUID_1, LAST_MODIFIED_1, sampleFiles());
+ assertFalse(ClarinHuggingFaceService.isValidRevision(null, sha));
+ }
+
+ @Test
+ public void testIsValidRevisionEmptyIsInvalid() {
+ String sha = ClarinHuggingFaceService.computeSha(UUID_1, LAST_MODIFIED_1, sampleFiles());
+ assertFalse(ClarinHuggingFaceService.isValidRevision("", sha));
+ }
+}
diff --git a/dspace/config/clarin-dspace.cfg b/dspace/config/clarin-dspace.cfg
index a481beb856aa..312f2e06f3bb 100644
--- a/dspace/config/clarin-dspace.cfg
+++ b/dspace/config/clarin-dspace.cfg
@@ -344,3 +344,25 @@ user.registration = false
# This option allows submitter to skip file upload step in the submission process
webui.submit.upload.required = false
+
+#------------------------------------------------------------------#
+#-------------------------HUGGINGFACE FACADE------------------------#
+#------------------------------------------------------------------#
+# HuggingFace-Hub-compatible read-only API facade, allowing tools built on the
+# huggingface_hub Python client (e.g. `from_pretrained`) to resolve DSpace items as
+# "models". An item is exposed through the facade only if it passes the exposure
+# filter below (metadata field/value match) or belongs to one of the listed
+# collections; if neither is configured, no items are exposed.
+
+# Master switch for the HuggingFace-Hub-compatible facade.
+hf.api.enabled = false
+
+# Metadata field used to select items exposed through the HuggingFace facade.
+hf.api.filter.metadata-field = dc.type
+
+# Value that `hf.api.filter.metadata-field` must equal for an item to be exposed.
+hf.api.filter.value = machineLearningModel
+
+# Comma-separated list of collection handles whose items are exposed through the
+# HuggingFace facade, regardless of the metadata filter above.
+hf.api.exposed.collections =