From e3d4a9052cd2c836b917f5e976abc248e0ff279e Mon Sep 17 00:00:00 2001 From: Brutus5000 Date: Sat, 27 Jun 2026 23:24:28 +0200 Subject: [PATCH 1/3] Add relationshipLink to ElideNavigator for JSON:API relationship endpoints navigateRelationship addresses the related resource (/data/{type}/{id}/{name}), but JSON:API also defines a relationship-linkage endpoint (/data/{type}/{id}/relationships/{name}) for reading/modifying the linkage itself (PATCH/POST/DELETE of members). Add relationshipLink(name) on ElideNavigatorOnId to build that path so callers don't hand-build it. Co-Authored-By: Claude Opus 4.8 --- .../commons/api/elide/ElideNavigator.java | 9 +++++++++ .../commons/api/elide/ElideNavigatorOnId.java | 8 ++++++++ .../commons/api/elide/ElideNavigatorTest.java | 18 ++++++++++++++++++ 3 files changed, 35 insertions(+) diff --git a/api/src/main/java/com/faforever/commons/api/elide/ElideNavigator.java b/api/src/main/java/com/faforever/commons/api/elide/ElideNavigator.java index 01f9013f..bdbb726e 100644 --- a/api/src/main/java/com/faforever/commons/api/elide/ElideNavigator.java +++ b/api/src/main/java/com/faforever/commons/api/elide/ElideNavigator.java @@ -27,6 +27,7 @@ public class ElideNavigator implements ElideNavigatorSele private final Optional> parentNavigator; private Optional id = Optional.empty(); private Optional relationship = Optional.empty(); + private Optional relationshipLink = Optional.empty(); private Optional> filterCondition = Optional.empty(); private Optional pageSize = Optional.empty(); private Optional pageNumber = Optional.empty(); @@ -114,6 +115,13 @@ public ElideNavigatorSelector navigateRelationship(@N return new ElideNavigator<>(dtoClass, this); } + @Override + public ElideNavigatorOnId relationshipLink(@NotNull String name) { + log.trace("relationship link added: {}", name); + this.relationshipLink = Optional.of(name); + return this; + } + /** * Add a sorting rule to the navigator * Important: You need to give the full qualified route, there is NO referencing of parent relationships. @@ -189,6 +197,7 @@ public String build() { String route = parentNavigator.map(ElideNavigator::build).orElse("/data/" + dtoPath) + id.map(i -> "/" + i).orElse("") + relationship.map(r -> "/" + r).orElse("") + + relationshipLink.map(r -> "/relationships/" + r).orElse("") + queryArgs; log.trace("Route built: {}", route); return route; diff --git a/api/src/main/java/com/faforever/commons/api/elide/ElideNavigatorOnId.java b/api/src/main/java/com/faforever/commons/api/elide/ElideNavigatorOnId.java index cc929182..7ec87ebe 100644 --- a/api/src/main/java/com/faforever/commons/api/elide/ElideNavigatorOnId.java +++ b/api/src/main/java/com/faforever/commons/api/elide/ElideNavigatorOnId.java @@ -6,4 +6,12 @@ public interface ElideNavigatorOnId extends ElideEndpoint ElideNavigatorSelector navigateRelationship(Class entityClass, String name); + /** + * Points the navigator at the JSON:API relationship endpoint + * ({@code /data/{type}/{id}/relationships/{name}}), used to read or modify the relationship linkage itself + * (e.g. PATCH/POST/DELETE to add, replace or remove members). This differs from + * {@link #navigateRelationship(Class, String)}, which addresses the related resource(s). + */ + ElideNavigatorOnId relationshipLink(String name); + } diff --git a/api/src/test/java/com/faforever/commons/api/elide/ElideNavigatorTest.java b/api/src/test/java/com/faforever/commons/api/elide/ElideNavigatorTest.java index ca742c08..e5c7e8d9 100644 --- a/api/src/test/java/com/faforever/commons/api/elide/ElideNavigatorTest.java +++ b/api/src/test/java/com/faforever/commons/api/elide/ElideNavigatorTest.java @@ -87,6 +87,24 @@ void testNavigateFromIdToId() { .build(), is("/data/mapPool/5/mapVersion/1234?include=author")); } + @Test + void testRelationshipLink() { + assertThat(ElideNavigator.of(MapPool.class) + .id("5") + .relationshipLink("mapVersion") + .build(), is("/data/mapPool/5/relationships/mapVersion")); + } + + @Test + void testRelationshipLinkAfterNavigateRelationship() { + assertThat(ElideNavigator.of(MapPool.class) + .id("5") + .navigateRelationship(MapVersion.class, "mapVersion") + .id("1234") + .relationshipLink("map") + .build(), is("/data/mapPool/5/mapVersion/1234/relationships/map")); + } + @Test void testGetListPages() { assertThat(ElideNavigator.of(MapPoolAssignment.class) From 79b210e44aff4ccdff5e87c268bcb88b73325cef Mon Sep 17 00:00:00 2001 From: Brutus5000 Date: Sun, 28 Jun 2026 18:34:46 +0200 Subject: [PATCH 2/3] Make relationshipLink a terminal navigator Previously relationshipLink set a field on the navigator and returned itself, so it could coexist with a navigated relationship and be mutated further, allowing nonsensical paths. It now returns a dedicated terminal type (ElideNavigatorOnRelationshipLink) that only exposes build(), builds the path eagerly, and rejects includes on the parent (mirroring navigateRelationship). Co-Authored-By: Claude Opus 4.8 --- .../faforever/commons/api/elide/ElideNavigator.java | 11 ++++++----- .../commons/api/elide/ElideNavigatorOnId.java | 5 ++++- .../api/elide/ElideNavigatorOnRelationshipLink.java | 11 +++++++++++ .../commons/api/elide/ElideNavigatorTest.java | 8 ++++++++ 4 files changed, 29 insertions(+), 6 deletions(-) create mode 100644 api/src/main/java/com/faforever/commons/api/elide/ElideNavigatorOnRelationshipLink.java diff --git a/api/src/main/java/com/faforever/commons/api/elide/ElideNavigator.java b/api/src/main/java/com/faforever/commons/api/elide/ElideNavigator.java index bdbb726e..36c602a9 100644 --- a/api/src/main/java/com/faforever/commons/api/elide/ElideNavigator.java +++ b/api/src/main/java/com/faforever/commons/api/elide/ElideNavigator.java @@ -27,7 +27,6 @@ public class ElideNavigator implements ElideNavigatorSele private final Optional> parentNavigator; private Optional id = Optional.empty(); private Optional relationship = Optional.empty(); - private Optional relationshipLink = Optional.empty(); private Optional> filterCondition = Optional.empty(); private Optional pageSize = Optional.empty(); private Optional pageNumber = Optional.empty(); @@ -116,10 +115,13 @@ public ElideNavigatorSelector navigateRelationship(@N } @Override - public ElideNavigatorOnId relationshipLink(@NotNull String name) { + public ElideNavigatorOnRelationshipLink relationshipLink(@NotNull String name) { + if (!includes.isEmpty()) { + throw new IllegalStateException("Cannot navigate to a relationship link with includes on parent"); + } log.trace("relationship link added: {}", name); - this.relationshipLink = Optional.of(name); - return this; + String route = build() + "/relationships/" + name; + return () -> route; } /** @@ -197,7 +199,6 @@ public String build() { String route = parentNavigator.map(ElideNavigator::build).orElse("/data/" + dtoPath) + id.map(i -> "/" + i).orElse("") + relationship.map(r -> "/" + r).orElse("") + - relationshipLink.map(r -> "/relationships/" + r).orElse("") + queryArgs; log.trace("Route built: {}", route); return route; diff --git a/api/src/main/java/com/faforever/commons/api/elide/ElideNavigatorOnId.java b/api/src/main/java/com/faforever/commons/api/elide/ElideNavigatorOnId.java index 7ec87ebe..f739c8f5 100644 --- a/api/src/main/java/com/faforever/commons/api/elide/ElideNavigatorOnId.java +++ b/api/src/main/java/com/faforever/commons/api/elide/ElideNavigatorOnId.java @@ -11,7 +11,10 @@ public interface ElideNavigatorOnId extends ElideEndpoint * ({@code /data/{type}/{id}/relationships/{name}}), used to read or modify the relationship linkage itself * (e.g. PATCH/POST/DELETE to add, replace or remove members). This differs from * {@link #navigateRelationship(Class, String)}, which addresses the related resource(s). + * + *

This is a terminal operation: the returned navigator only allows {@link + * ElideNavigatorOnRelationshipLink#build()}. Includes are not allowed on the parent. */ - ElideNavigatorOnId relationshipLink(String name); + ElideNavigatorOnRelationshipLink relationshipLink(String name); } diff --git a/api/src/main/java/com/faforever/commons/api/elide/ElideNavigatorOnRelationshipLink.java b/api/src/main/java/com/faforever/commons/api/elide/ElideNavigatorOnRelationshipLink.java new file mode 100644 index 00000000..518c49ec --- /dev/null +++ b/api/src/main/java/com/faforever/commons/api/elide/ElideNavigatorOnRelationshipLink.java @@ -0,0 +1,11 @@ +package com.faforever.commons.api.elide; + +/** + * Terminal navigator pointing at a JSON:API relationship endpoint + * ({@code /data/{type}/{id}/relationships/{name}}). It only exposes {@link #build()} because a relationship + * link addresses the linkage itself (read or modify via PATCH/POST/DELETE) and cannot be navigated further. + */ +@FunctionalInterface +public interface ElideNavigatorOnRelationshipLink { + String build(); +} diff --git a/api/src/test/java/com/faforever/commons/api/elide/ElideNavigatorTest.java b/api/src/test/java/com/faforever/commons/api/elide/ElideNavigatorTest.java index e5c7e8d9..91cc44ac 100644 --- a/api/src/test/java/com/faforever/commons/api/elide/ElideNavigatorTest.java +++ b/api/src/test/java/com/faforever/commons/api/elide/ElideNavigatorTest.java @@ -105,6 +105,14 @@ void testRelationshipLinkAfterNavigateRelationship() { .build(), is("/data/mapPool/5/mapVersion/1234/relationships/map")); } + @Test + void testCannotRelationshipLinkAfterIncludes() { + assertThrows(IllegalStateException.class, () -> ElideNavigator.of(MapPool.class) + .id("5") + .addInclude("mapVersion") + .relationshipLink("mapVersion")); + } + @Test void testGetListPages() { assertThat(ElideNavigator.of(MapPoolAssignment.class) From 05c82a3a113f97cd514078ba9427a6549d53d13e Mon Sep 17 00:00:00 2001 From: Brutus5000 Date: Sun, 28 Jun 2026 20:33:39 +0200 Subject: [PATCH 3/3] Carry the related entity type through relationshipLink Mirror navigateRelationship by taking the related entity class: relationshipLink(Class, name) now returns ElideNavigatorOnRelationshipLink, which exposes getDtoClass() so callers can derive the related type (e.g. to build the linkage body) instead of passing it separately. Co-Authored-By: Claude Opus 4.8 --- .../commons/api/elide/ElideNavigator.java | 15 +++++++++++++-- .../commons/api/elide/ElideNavigatorOnId.java | 4 +++- .../elide/ElideNavigatorOnRelationshipLink.java | 10 ++++++---- .../commons/api/elide/ElideNavigatorTest.java | 13 +++++++------ 4 files changed, 29 insertions(+), 13 deletions(-) diff --git a/api/src/main/java/com/faforever/commons/api/elide/ElideNavigator.java b/api/src/main/java/com/faforever/commons/api/elide/ElideNavigator.java index 36c602a9..47127b7e 100644 --- a/api/src/main/java/com/faforever/commons/api/elide/ElideNavigator.java +++ b/api/src/main/java/com/faforever/commons/api/elide/ElideNavigator.java @@ -115,13 +115,24 @@ public ElideNavigatorSelector navigateRelationship(@N } @Override - public ElideNavigatorOnRelationshipLink relationshipLink(@NotNull String name) { + public ElideNavigatorOnRelationshipLink relationshipLink(@NotNull Class entityClass, + @NotNull String name) { if (!includes.isEmpty()) { throw new IllegalStateException("Cannot navigate to a relationship link with includes on parent"); } log.trace("relationship link added: {}", name); String route = build() + "/relationships/" + name; - return () -> route; + return new ElideNavigatorOnRelationshipLink<>() { + @Override + public String build() { + return route; + } + + @Override + public Class getDtoClass() { + return entityClass; + } + }; } /** diff --git a/api/src/main/java/com/faforever/commons/api/elide/ElideNavigatorOnId.java b/api/src/main/java/com/faforever/commons/api/elide/ElideNavigatorOnId.java index f739c8f5..c218e6b0 100644 --- a/api/src/main/java/com/faforever/commons/api/elide/ElideNavigatorOnId.java +++ b/api/src/main/java/com/faforever/commons/api/elide/ElideNavigatorOnId.java @@ -14,7 +14,9 @@ public interface ElideNavigatorOnId extends ElideEndpoint * *

This is a terminal operation: the returned navigator only allows {@link * ElideNavigatorOnRelationshipLink#build()}. Includes are not allowed on the parent. + * + * @param entityClass the type of the related resource(s) the linkage points at */ - ElideNavigatorOnRelationshipLink relationshipLink(String name); + ElideNavigatorOnRelationshipLink relationshipLink(Class entityClass, String name); } diff --git a/api/src/main/java/com/faforever/commons/api/elide/ElideNavigatorOnRelationshipLink.java b/api/src/main/java/com/faforever/commons/api/elide/ElideNavigatorOnRelationshipLink.java index 518c49ec..4e528f04 100644 --- a/api/src/main/java/com/faforever/commons/api/elide/ElideNavigatorOnRelationshipLink.java +++ b/api/src/main/java/com/faforever/commons/api/elide/ElideNavigatorOnRelationshipLink.java @@ -2,10 +2,12 @@ /** * Terminal navigator pointing at a JSON:API relationship endpoint - * ({@code /data/{type}/{id}/relationships/{name}}). It only exposes {@link #build()} because a relationship - * link addresses the linkage itself (read or modify via PATCH/POST/DELETE) and cannot be navigated further. + * ({@code /data/{type}/{id}/relationships/{name}}). It only exposes {@link #build()} and the related entity + * type, because a relationship link addresses the linkage itself (read or modify via PATCH/POST/DELETE) and + * cannot be navigated further. {@code T} is the type of the related resource(s) the linkage points at. */ -@FunctionalInterface -public interface ElideNavigatorOnRelationshipLink { +public interface ElideNavigatorOnRelationshipLink { String build(); + + Class getDtoClass(); } diff --git a/api/src/test/java/com/faforever/commons/api/elide/ElideNavigatorTest.java b/api/src/test/java/com/faforever/commons/api/elide/ElideNavigatorTest.java index 91cc44ac..e5942f7f 100644 --- a/api/src/test/java/com/faforever/commons/api/elide/ElideNavigatorTest.java +++ b/api/src/test/java/com/faforever/commons/api/elide/ElideNavigatorTest.java @@ -89,10 +89,11 @@ void testNavigateFromIdToId() { @Test void testRelationshipLink() { - assertThat(ElideNavigator.of(MapPool.class) - .id("5") - .relationshipLink("mapVersion") - .build(), is("/data/mapPool/5/relationships/mapVersion")); + ElideNavigatorOnRelationshipLink navigator = ElideNavigator.of(MapPool.class) + .id("5") + .relationshipLink(MapVersion.class, "mapVersion"); + assertThat(navigator.build(), is("/data/mapPool/5/relationships/mapVersion")); + assertThat(navigator.getDtoClass(), is(MapVersion.class)); } @Test @@ -101,7 +102,7 @@ void testRelationshipLinkAfterNavigateRelationship() { .id("5") .navigateRelationship(MapVersion.class, "mapVersion") .id("1234") - .relationshipLink("map") + .relationshipLink(MapVersion.class, "map") .build(), is("/data/mapPool/5/mapVersion/1234/relationships/map")); } @@ -110,7 +111,7 @@ void testCannotRelationshipLinkAfterIncludes() { assertThrows(IllegalStateException.class, () -> ElideNavigator.of(MapPool.class) .id("5") .addInclude("mapVersion") - .relationshipLink("mapVersion")); + .relationshipLink(MapVersion.class, "mapVersion")); } @Test