From cef52f655f7aa621d6d988e0c22dbcf9493a4484 Mon Sep 17 00:00:00 2001 From: Serguei Gorokhov Date: Wed, 2 Sep 2026 21:27:14 +0300 Subject: [PATCH] feat: parse inert resourceDependencies section in application definitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the ResourceDependency link-record POJO (kind, linkId, target.path, access, required) and the Application.resourceDependencies field. The section parses from both snake_case and camelCase, round-trips, is carried by the copy constructor, and is omitted from serialization when absent — existing definitions are byte-identical. Deliberately inert: nothing consumes the section yet. Write-time validation, admin consent, and request-start resolution land in the following PRs of the series. The existing Deployment.dependencies field (deployment-name list) is untouched. Spec: documentation repo, offline-access-delegation/implementation-specs/pr1-definition-model.md Co-Authored-By: Claude Code --- .../epam/aidial/core/config/Application.java | 8 + .../core/config/ResourceDependency.java | 48 ++++++ .../core/config/ResourceDependencyTest.java | 146 ++++++++++++++++++ docs/open_api_core.yaml | 24 +++ 4 files changed, 226 insertions(+) create mode 100644 config/src/main/java/com/epam/aidial/core/config/ResourceDependency.java create mode 100644 config/src/test/java/com/epam/aidial/core/config/ResourceDependencyTest.java diff --git a/config/src/main/java/com/epam/aidial/core/config/Application.java b/config/src/main/java/com/epam/aidial/core/config/Application.java index 07423e9ab..0269a9859 100644 --- a/config/src/main/java/com/epam/aidial/core/config/Application.java +++ b/config/src/main/java/com/epam/aidial/core/config/Application.java @@ -54,6 +54,13 @@ public class Application extends Deployment { @JsonInclude(JsonInclude.Include.NON_DEFAULT) private boolean allowUserExternalServices; + // Declared resource dependencies — pointer links, not grants: the declaration itself carries + // no access. Inert until write-time validation and request-start resolution land; until then + // the section parses, persists and round-trips, and nothing evaluates it. + @JsonAlias({"resourceDependencies", "resource_dependencies"}) + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private List resourceDependencies = List.of(); + // maintain the order of routes defined in the app config private LinkedHashMap routes = new LinkedHashMap<>(); @@ -211,5 +218,6 @@ public Application(Application source) { this.setExternalServices(source.getExternalServices()); this.setAppIdentity(source.getAppIdentity()); this.setAllowUserExternalServices(source.isAllowUserExternalServices()); + this.setResourceDependencies(source.getResourceDependencies()); } } \ No newline at end of file diff --git a/config/src/main/java/com/epam/aidial/core/config/ResourceDependency.java b/config/src/main/java/com/epam/aidial/core/config/ResourceDependency.java new file mode 100644 index 000000000..b2aa7e052 --- /dev/null +++ b/config/src/main/java/com/epam/aidial/core/config/ResourceDependency.java @@ -0,0 +1,48 @@ +package com.epam.aidial.core.config; + +import com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.Data; +import lombok.experimental.Accessors; + +import java.util.Set; + +/** + * A declared resource dependency: a request, not a grant. The record carries no access of its + * own — at request start Core verifies the originating user's reach and the required consent, + * and only then bakes the grant into the per-request key the app already holds. + */ +@Data +@Accessors(chain = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class ResourceDependency { + + /** The single descriptor kind for every DIAL target; the target type is clear from the path. */ + public static final String KIND = "dial.resourceLink"; + + private String kind; + + @JsonAlias({"linkId", "link_id"}) + private String linkId; + + private Target target; + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private Set access = Set.of(); + + @JsonInclude(JsonInclude.Include.NON_DEFAULT) + private boolean required; + + @Data + @Accessors(chain = true) + @JsonInclude(JsonInclude.Include.NON_NULL) + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + public static class Target { + + // Two forms only, nothing else: a concrete global-view path, or a current-user placeholder rooted path. + private String path; + } +} diff --git a/config/src/test/java/com/epam/aidial/core/config/ResourceDependencyTest.java b/config/src/test/java/com/epam/aidial/core/config/ResourceDependencyTest.java new file mode 100644 index 000000000..acf3e6a66 --- /dev/null +++ b/config/src/test/java/com/epam/aidial/core/config/ResourceDependencyTest.java @@ -0,0 +1,146 @@ +package com.epam.aidial.core.config; + +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Parsing and serialization state of a declared resource dependency, and of the + * {@code resourceDependencies} section on {@link Application}. Target-language validation is + * write-time validation and is covered by the server-side validator's tests. + */ +public class ResourceDependencyTest { + + // Definition bodies reach the POJO through ProxyUtil's mapper, which accepts enum names + // case-insensitively — so the design's lowercase input ("read", "write") parses, while + // serialization emits the enum names ("READ", "WRITE"); the normalization is expected. + private static final ObjectMapper MAPPER = new ObjectMapper() + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS); + + @Test + void parsesTheSnakeCaseFormWithDefaults() throws Exception { + String json = """ + { + "kind": "dial.resourceLink", + "link_id": "lnk_1", + "target": {"path": "current-user/skills/"}, + "access": ["read", "write"], + "required": true + } + """; + ResourceDependency dependency = MAPPER.readValue(json, ResourceDependency.class); + + assertEquals(ResourceDependency.KIND, dependency.getKind()); + assertEquals("lnk_1", dependency.getLinkId()); + assertEquals("current-user/skills/", dependency.getTarget().getPath()); + assertEquals(Set.of(ResourceAccessType.READ, ResourceAccessType.WRITE), dependency.getAccess()); + assertTrue(dependency.isRequired()); + } + + @Test + void parsesTheCamelCaseForm() throws Exception { + ResourceDependency dependency = MAPPER.readValue( + "{\"linkId\": \"lnk_2\", \"target\": {\"path\": \"files/public/policies/\"}}", + ResourceDependency.class); + + assertEquals("lnk_2", dependency.getLinkId()); + assertEquals("files/public/policies/", dependency.getTarget().getPath()); + } + + @Test + void defaultsWhenOptionalFieldsAreAbsent() throws Exception { + ResourceDependency dependency = MAPPER.readValue( + "{\"kind\": \"dial.resourceLink\", \"target\": {\"path\": \"current-user/\"}}", + ResourceDependency.class); + + assertNull(dependency.getLinkId()); + assertTrue(dependency.getAccess().isEmpty()); + assertFalse(dependency.isRequired()); + } + + @Test + void roundTripsThroughSerialization() throws Exception { + String json = """ + { + "kind": "dial.resourceLink", + "linkId": "lnk_1", + "target": {"path": "current-user/skills/"}, + "access": ["write"], + "required": true + } + """; + ResourceDependency dependency = MAPPER.readValue(json, ResourceDependency.class); + + assertEquals(dependency, MAPPER.readValue(MAPPER.writeValueAsString(dependency), ResourceDependency.class)); + } + + @Test + void omitsDefaultValuesFromSerialization() throws Exception { + ResourceDependency dependency = new ResourceDependency() + .setKind(ResourceDependency.KIND) + .setTarget(new ResourceDependency.Target().setPath("current-user/")); + + String json = MAPPER.writeValueAsString(dependency); + + assertFalse(json.contains("required")); + assertFalse(json.contains("access")); + assertEquals("{\"kind\":\"dial.resourceLink\",\"target\":{\"path\":\"current-user/\"}}", json); + } + + @Test + void serializesAccessAsEnumNamesWhileAcceptingLowercaseInput() throws Exception { + ResourceDependency dependency = MAPPER.readValue(""" + {"kind": "dial.resourceLink", "target": {"path": "files/public/f/"}, "access": ["read", "write"]} + """, ResourceDependency.class); + + String json = MAPPER.writeValueAsString(dependency); + + assertTrue(json.contains("\"READ\"")); + assertTrue(json.contains("\"WRITE\"")); + } + + @Test + void applicationSectionParsesBothForms() throws Exception { + String snake = """ + {"name": "app", "resource_dependencies": [{"link_id": "lnk_1", "target": {"path": "public/folder/"}}]} + """; + String camel = """ + {"name": "app", "resourceDependencies": [{"linkId": "lnk_1", "target": {"path": "public/folder/"}}]} + """; + + assertEquals(MAPPER.readValue(snake, Application.class).getResourceDependencies(), + MAPPER.readValue(camel, Application.class).getResourceDependencies()); + } + + @Test + void absentSectionYieldsEmptyListAndIsOmittedFromSerialization() throws Exception { + Application withoutSection = MAPPER.readValue("{\"name\": \"app\"}", Application.class); + + assertTrue(withoutSection.getResourceDependencies().isEmpty()); + assertFalse(MAPPER.writeValueAsString(withoutSection).contains("resource_dependencies")); + } + + @Test + void copyConstructorCarriesTheSection() { + Application source = new Application() + .setResourceDependencies(List.of(new ResourceDependency() + .setKind(ResourceDependency.KIND) + .setLinkId("lnk_1") + .setTarget(new ResourceDependency.Target().setPath("current-user/skills/")) + .setAccess(Set.of(ResourceAccessType.WRITE)) + .setRequired(true))); + + Application copy = new Application(source); + + assertSame(source.getResourceDependencies(), copy.getResourceDependencies()); + } +} diff --git a/docs/open_api_core.yaml b/docs/open_api_core.yaml index b7cbd987f..b92b68d5d 100644 --- a/docs/open_api_core.yaml +++ b/docs/open_api_core.yaml @@ -13683,6 +13683,10 @@ components: $ref: "#/components/schemas/MapStringString" baseUrl: type: string + resource_dependencies: + type: array + items: + $ref: "#/components/schemas/ResourceDependency" ApplicationData: type: object properties: @@ -16474,6 +16478,26 @@ components: - SIGNED_IN - SIGNED_OUT - FAILED + ResourceDependency: + type: object + properties: + access: + type: array + items: + $ref: "#/components/schemas/ResourceAccessType" + kind: + type: string + link_id: + type: string + required: + type: boolean + target: + $ref: "#/components/schemas/ResourceDependencyTarget" + ResourceDependencyTarget: + type: object + properties: + path: + type: string ResourceEvent: type: object properties: