Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<ResourceDependency> resourceDependencies = List.of();

// maintain the order of routes defined in the app config
private LinkedHashMap<String, Route> routes = new LinkedHashMap<>();

Expand Down Expand Up @@ -211,5 +218,6 @@ public Application(Application source) {
this.setExternalServices(source.getExternalServices());
this.setAppIdentity(source.getAppIdentity());
this.setAllowUserExternalServices(source.isAllowUserExternalServices());
this.setResourceDependencies(source.getResourceDependencies());
}
}
Original file line number Diff line number Diff line change
@@ -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<ResourceAccessType> access = Set.of();

@JsonInclude(JsonInclude.Include.NON_DEFAULT)
private boolean required;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Add javadoc what the field means


@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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Should it be DIAL resource URL instead of generic name path?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The URL should be validated: DIAL Core must be able to deserialize a string to DIAL Resource URL

}
}
Original file line number Diff line number Diff line change
@@ -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());
}
}
24 changes: 24 additions & 0 deletions docs/open_api_core.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -16474,6 +16478,26 @@ components:
- SIGNED_IN
- SIGNED_OUT
- FAILED
ResourceDependency:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Add descriptions per new field

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:
Expand Down