diff --git a/server/src/main/java/com/epam/aidial/core/server/controller/ConfigResourceController.java b/server/src/main/java/com/epam/aidial/core/server/controller/ConfigResourceController.java index 53429902a..c4bfedd77 100644 --- a/server/src/main/java/com/epam/aidial/core/server/controller/ConfigResourceController.java +++ b/server/src/main/java/com/epam/aidial/core/server/controller/ConfigResourceController.java @@ -156,7 +156,7 @@ public Future handle() throws Exception { return respondMethodNotAllowed(); } - private Future handleGet() throws JsonProcessingException { + private Future handleGet() { Config config = context.getConfig(); boolean admin = authorizationService.isAdmin(context); // Per-entity GET is blob-only (slice U.1): only canonical-ID lookups resolve here. @@ -207,11 +207,12 @@ private Future handleSingleGet(Map source, // client-supplied tag matches. final T matchedItem = item; final String matched = canonicalId(); - return respondNotModifiedIfMatched(matched) - .onSuccess(notModified -> { - if (!notModified) { - context.respond(HttpStatus.OK, projector.apply(matched, matchedItem)); + return evaluateConditionalGet() + .onSuccess(etag -> { + if (etag != null) { + context.putHeader(HttpHeaders.ETAG, etag).exposeHeaders(); } + context.respond(HttpStatus.OK, projector.apply(matched, matchedItem)); }) .onFailure(this::handleWriteError); } @@ -225,38 +226,25 @@ private Future handleSingleGet(Map source, } /** - * Emits a {@code 304 Not Modified} if the matched entity's stored blob has an ETag - * that matches the client's {@code If-None-Match} header. The blob-metadata fetch - * runs on the blocking executor to keep the Vert.x event loop unblocked; the response is - * written back on the event loop via the future-chain completion. + * Fetches blob metadata and evaluates RFC 7232 conditional headers, returning the blob-backed + * ETag (or {@code null} when no blob exists) for the caller to include on a 200 response. + * Throws {@link HttpException} (304 or 412) when a conditional header fires — the caller's + * {@code .onFailure} handler dispatches it via {@link #handleWriteError}. + * The metadata fetch runs on the blocking executor; response dispatch stays on the event loop. */ - private Future respondNotModifiedIfMatched(String matchedKey) { + private Future evaluateConditionalGet() { ResourceDescriptor descriptor = singleEntityDescriptor(); if (descriptor == null) { - return Future.succeededFuture(false); + return Future.succeededFuture(null); } EtagHeader etag = ProxyUtil.etag(context.getRequest()); - return taskExecutor.submit(() -> { + return taskExecutor.submit(() -> { ResourceItemMetadata meta = resourceService.getResourceMetadata(descriptor); if (meta == null || meta.getEtag() == null) { return null; } - try { - etag.validate(meta.getEtag()); - } catch (HttpException e) { - if (e.getStatus() == HttpStatus.NOT_MODIFIED) { - return e; - } - // Other status codes (e.g. If-Match failure) are not surfaced here — GET ignores - // If-Match per RFC 7232; only If-None-Match drives 304/200 on GET. - } - return null; - }).map(notModified -> { - if (notModified != null) { - context.respond(notModified); - return true; - } - return false; + etag.validate(meta.getEtag()); + return meta.getEtag(); }); } @@ -299,7 +287,7 @@ private String canonicalId() { return entityType + "/" + bucket + "/" + path; } - private Future handleSchemaGet(Config config, boolean admin) throws JsonProcessingException { + private Future handleSchemaGet(Config config, boolean admin) { Map schemas = config.getApplicationTypeSchemas(); Map invalid = mergedConfigStore.getInvalidEntities() .getOrDefault(ResourceTypes.APP_TYPE_SCHEMA, Map.of()); @@ -313,14 +301,15 @@ private Future handleSchemaGet(Config config, boolean admin) throws JsonProce if (schemaJson != null) { final String matched = canonicalId(); final String json = schemaJson; - return respondNotModifiedIfMatched(matched) - .onSuccess(notModified -> { - if (!notModified) { - try { - context.respond(HttpStatus.OK, projectSchemaItem(matched, json)); - } catch (JsonProcessingException e) { - context.respond(HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage()); - } + return evaluateConditionalGet() + .onSuccess(etag -> { + if (etag != null) { + context.putHeader(HttpHeaders.ETAG, etag).exposeHeaders(); + } + try { + context.respond(HttpStatus.OK, projectSchemaItem(matched, json)); + } catch (JsonProcessingException e) { + context.respond(HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage()); } }) .onFailure(this::handleWriteError); diff --git a/server/src/test/java/com/epam/aidial/core/server/ConfigResourceConditionalHeaderTest.java b/server/src/test/java/com/epam/aidial/core/server/ConfigResourceConditionalHeaderTest.java index 6090afd97..c309df658 100644 --- a/server/src/test/java/com/epam/aidial/core/server/ConfigResourceConditionalHeaderTest.java +++ b/server/src/test/java/com/epam/aidial/core/server/ConfigResourceConditionalHeaderTest.java @@ -65,6 +65,44 @@ void testGet200OnStaleIfNoneMatch() { verify(stale, 200); assertTrue(stale.body().contains("\"name\":\"models/public/cond-stale\""), () -> "Expected full body on stale If-None-Match: " + stale.body()); + assertNotNull(stale.headers().get("etag"), + () -> "GET 200 must include ETag header even on stale If-None-Match: " + stale.headers()); + } + + @Test + void testGet200OnMatchingIfMatch() { + Response put = send(HttpMethod.PUT, "/v1/models/public/cond-get-ifmatch-ok", null, MODEL_BODY, + "authorization", "admin", "If-None-Match", "*"); + verify(put, 200); + String etag = put.headers().get("etag"); + assertNotNull(etag, () -> "PUT must emit an ETag header: " + put.headers()); + + Response get = send(HttpMethod.GET, "/v1/models/public/cond-get-ifmatch-ok", null, "", + "authorization", "admin", "If-Match", etag); + verify(get, 200); + assertNotNull(get.headers().get("etag"), "GET 200 must include ETag header"); + } + + @Test + void testGet412OnStaleIfMatch() { + verify(send(HttpMethod.PUT, "/v1/models/public/cond-get-ifmatch-fail", null, MODEL_BODY, + "authorization", "admin", "If-None-Match", "*"), 200); + + Response get = send(HttpMethod.GET, "/v1/models/public/cond-get-ifmatch-fail", null, "", + "authorization", "admin", "If-Match", "\"wrong-etag\""); + verify(get, 412); + } + + @Test + void testGet200IncludesEtagHeader() { + verify(send(HttpMethod.PUT, "/v1/models/public/cond-etag-get", null, MODEL_BODY, + "authorization", "admin", "If-None-Match", "*"), 200); + + Response get = send(HttpMethod.GET, "/v1/models/public/cond-etag-get", null, "", + "authorization", "admin"); + verify(get, 200); + assertNotNull(get.headers().get("etag"), + () -> "Plain GET 200 must include ETag header: " + get.headers()); } @Test