Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
650f514
rename /api/partner_config/get to /api/partner_config/list
swibi-ttd Feb 10, 2026
06add62
implement /api/partner_config/:partner_name
swibi-ttd Feb 11, 2026
e524a95
use JsonArray
swibi-ttd Feb 11, 2026
a104182
ignore case when comparing Partner names
swibi-ttd Feb 11, 2026
628a4f3
change endpoint path to /get/:partner_name
swibi-ttd Feb 11, 2026
84268a9
implement /api/partner_config/add
swibi-ttd Feb 11, 2026
2ee8d0a
implement update
swibi-ttd Feb 11, 2026
7e9d557
implement delete
swibi-ttd Feb 12, 2026
f89f61a
reinstate bulk replace functionality at /bulk_replace
swibi-ttd Feb 12, 2026
6ce4c29
add validation added/updated configs
swibi-ttd Feb 12, 2026
ea38ac6
add unit tests
swibi-ttd Feb 12, 2026
ba04922
update response code
swibi-ttd Feb 12, 2026
eca0b05
update operations on Partner Management page
swibi-ttd Feb 12, 2026
28ccf16
allow bulk update to include an empty array
swibi-ttd Feb 12, 2026
e715a00
simplify unit tests
swibi-ttd Feb 13, 2026
8a9e517
remove unused imports
swibi-ttd Feb 13, 2026
83bc46c
check that no duplicate partner names are added during bulk replace
swibi-ttd Feb 13, 2026
5e7d0f2
check for empty partner name
swibi-ttd Feb 13, 2026
7786c5d
add findPartnerIndex helper function
swibi-ttd Feb 13, 2026
d6bc7cf
manual refresh in every handler
swibi-ttd Feb 13, 2026
673f0ec
return with updated config instead of just success
swibi-ttd Feb 13, 2026
4e99a1f
add null check to validatePartnerConfig
swibi-ttd Feb 13, 2026
2c85e2d
[CI Pipeline] Released Snapshot version: 6.12.12-alpha-221-SNAPSHOT
Feb 13, 2026
5cb9e97
return deleted config
swibi-ttd Feb 13, 2026
6d6f6f1
return bulk replaced config
swibi-ttd Feb 13, 2026
bd0f3f6
update roles and move /delete to danger zoen
swibi-ttd Feb 13, 2026
bac9f94
update tests for auth changes
swibi-ttd Feb 13, 2026
ed05be6
use input fields rather than raw JSON
swibi-ttd Feb 16, 2026
feaf3f5
[CI Pipeline] Released Snapshot version: 6.12.13-alpha-222-SNAPSHOT
Feb 16, 2026
634c745
display error msg from response
swibi-ttd Feb 16, 2026
8994ac9
[CI Pipeline] Released Snapshot version: 6.12.14-alpha-223-SNAPSHOT
Feb 16, 2026
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
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

<groupId>com.uid2</groupId>
<artifactId>uid2-admin</artifactId>
<version>6.12.11</version>
<version>6.12.14-alpha-223-SNAPSHOT</version>

<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
Expand Down
6 changes: 5 additions & 1 deletion src/main/java/com/uid2/admin/vertx/Endpoints.java
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,12 @@ public enum Endpoints {
API_OPERATOR_UPDATE("/api/operator/update"),
API_OPERATOR_ROLES("/api/operator/roles"),

API_PARTNER_CONFIG_GET("/api/partner_config/get"),
API_PARTNER_CONFIG_LIST("/api/partner_config/list"),
API_PARTNER_CONFIG_GET("/api/partner_config/get/:partner_name"),
API_PARTNER_CONFIG_ADD("/api/partner_config/add"),
API_PARTNER_CONFIG_UPDATE("/api/partner_config/update"),
API_PARTNER_CONFIG_DELETE("/api/partner_config/delete"),
API_PARTNER_CONFIG_BULK_REPLACE("/api/partner_config/bulk_replace"),

API_PRIVATE_SITES_REFRESH("/api/private-sites/refresh"),
API_PRIVATE_SITES_REFRESH_NOW("/api/private-sites/refreshNow"),
Expand Down
319 changes: 314 additions & 5 deletions src/main/java/com/uid2/admin/vertx/service/PartnerConfigService.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,21 @@
import com.uid2.shared.auth.Role;
import io.vertx.core.http.HttpHeaders;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.Router;
import io.vertx.ext.web.RoutingContext;

import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

import static com.uid2.admin.vertx.Endpoints.API_PARTNER_CONFIG_LIST;
import static com.uid2.admin.vertx.Endpoints.API_PARTNER_CONFIG_GET;
import static com.uid2.admin.vertx.Endpoints.API_PARTNER_CONFIG_ADD;
import static com.uid2.admin.vertx.Endpoints.API_PARTNER_CONFIG_UPDATE;
import static com.uid2.admin.vertx.Endpoints.API_PARTNER_CONFIG_DELETE;
import static com.uid2.admin.vertx.Endpoints.API_PARTNER_CONFIG_BULK_REPLACE;

public class PartnerConfigService implements IService {
private final AdminAuthMiddleware auth;
Expand All @@ -36,17 +43,37 @@ public PartnerConfigService(AdminAuthMiddleware auth,

@Override
public void setupRoutes(Router router) {
router.get(API_PARTNER_CONFIG_LIST.toString()).handler(
auth.handle(this::handlePartnerConfigList, Role.MAINTAINER));
router.get(API_PARTNER_CONFIG_GET.toString()).handler(
auth.handle(this::handlePartnerConfigGet, Role.MAINTAINER));
router.post(API_PARTNER_CONFIG_UPDATE.toString()).blockingHandler(auth.handle((ctx) -> {

router.post(API_PARTNER_CONFIG_ADD.toString()).blockingHandler(auth.handle((ctx) -> {
synchronized (writeLock) {
this.handlePartnerConfigAdd(ctx);
}
}, new AuditParams(Collections.emptyList(), List.of("name")), Role.MAINTAINER));
router.put(API_PARTNER_CONFIG_UPDATE.toString()).blockingHandler(auth.handle((ctx) -> {
synchronized (writeLock) {
this.handlePartnerConfigUpdate(ctx);
}
}, new AuditParams(Collections.emptyList(), List.of("partner_id", "config")), Role.PRIVILEGED));
}, new AuditParams(Collections.emptyList(), List.of("name")), Role.MAINTAINER));

router.delete(API_PARTNER_CONFIG_DELETE.toString()).blockingHandler(auth.handle((ctx) -> {
synchronized (writeLock) {
this.handlePartnerConfigDelete(ctx);
}
}, new AuditParams(List.of("partner_name"), Collections.emptyList()), Role.PRIVILEGED));
router.post(API_PARTNER_CONFIG_BULK_REPLACE.toString()).blockingHandler(auth.handle((ctx) -> {
synchronized (writeLock) {
this.handlePartnerConfigBulkReplace(ctx);
}
}, new AuditParams(Collections.emptyList(), List.of("name")), Role.SUPER_USER));
}

private void handlePartnerConfigGet(RoutingContext rc) {
private void handlePartnerConfigList(RoutingContext rc) {
try {
this.partnerConfigProvider.loadContent();
String config = this.partnerConfigProvider.getConfig();
rc.response()
.putHeader(HttpHeaders.CONTENT_TYPE, "application/json")
Expand All @@ -56,23 +83,305 @@ private void handlePartnerConfigGet(RoutingContext rc) {
}
}

private void handlePartnerConfigGet(RoutingContext rc) {
try {
final String partnerName = rc.pathParam("partner_name");
if (partnerName == null || partnerName.isEmpty()) {
ResponseUtil.error(rc, 400, "Partner name is required");
return;
}

this.partnerConfigProvider.loadContent();
JsonArray allPartnerConfigs = new JsonArray(this.partnerConfigProvider.getConfig());
int index = findPartnerIndex(allPartnerConfigs, partnerName);

if (index == -1) {
ResponseUtil.error(rc, 404, "Partner '" + partnerName + "' not found");
return;
}

rc.response()
.putHeader(HttpHeaders.CONTENT_TYPE, "application/json")
.end(allPartnerConfigs.getJsonObject(index).encode());
} catch (Exception e) {
rc.fail(500, e);
}
}

private void handlePartnerConfigAdd(RoutingContext rc) {
try {
JsonObject newConfig = rc.body().asJsonObject();
if (newConfig == null) {
ResponseUtil.error(rc, 400, "Body must include Partner config");
return;
}

// Validate required fields
if (!validatePartnerConfig(rc, newConfig)) {
return;
}

String newPartnerName = newConfig.getString("name");
this.partnerConfigProvider.loadContent();
JsonArray allPartnerConfigs = new JsonArray(this.partnerConfigProvider.getConfig());

// Validate partner doesn't exist
if (findPartnerIndex(allPartnerConfigs, newPartnerName) != -1) {
ResponseUtil.error(rc, 409, "Partner '" + newPartnerName + "' already exists");
return;
}

// Upload
allPartnerConfigs.add(newConfig);
storageManager.upload(allPartnerConfigs);

rc.response()
.putHeader(HttpHeaders.CONTENT_TYPE, "application/json")
.end(newConfig.encode());
} catch (Exception e) {
rc.fail(500, e);
}
}

private void handlePartnerConfigUpdate(RoutingContext rc) {
try {
JsonObject partialConfig = rc.body().asJsonObject();
if (partialConfig == null) {
ResponseUtil.error(rc, 400, "Body must include Partner config");
return;
}

String partnerName = partialConfig.getString("name");
if (partnerName == null || partnerName.trim().isEmpty()) {
ResponseUtil.error(rc, 400, "Partner config 'name' is required");
return;
}

this.partnerConfigProvider.loadContent();
JsonArray allPartnerConfigs = new JsonArray(this.partnerConfigProvider.getConfig());

// Find existing partner config
int existingPartnerIdx = findPartnerIndex(allPartnerConfigs, partnerName);
if (existingPartnerIdx == -1) {
ResponseUtil.error(rc, 404, "Partner '" + partnerName + "' not found");
return;
}

JsonObject existingConfig = allPartnerConfigs.getJsonObject(existingPartnerIdx);

// Validate partial config
if (!validatePartnerConfigForUpdate(rc, partialConfig)) {
return;
}

// Merge: start with existing config, overlay with new fields
JsonObject mergedConfig = existingConfig.copy();
partialConfig.forEach(entry -> {
mergedConfig.put(entry.getKey(), entry.getValue());
});

// Replace with merged config
allPartnerConfigs.set(existingPartnerIdx, mergedConfig);
storageManager.upload(allPartnerConfigs);

rc.response()
.putHeader(HttpHeaders.CONTENT_TYPE, "application/json")
.end(mergedConfig.encode());
} catch (Exception e) {
rc.fail(500, e);
}
}

private void handlePartnerConfigDelete(RoutingContext rc) {
try {
final List<String> partnerNames = rc.queryParam("partner_name");
if (partnerNames.isEmpty()) {
ResponseUtil.error(rc, 400, "Partner name is required");
return;
}
final String partnerName = partnerNames.getFirst();

this.partnerConfigProvider.loadContent();
JsonArray allPartnerConfigs = new JsonArray(this.partnerConfigProvider.getConfig());

// Find partner config
int existingPartnerIdx = findPartnerIndex(allPartnerConfigs, partnerName);
if (existingPartnerIdx == -1) {
ResponseUtil.error(rc, 404, "Partner '" + partnerName + "' not found");
return;
}

// Remove and return the deleted config
JsonObject deletedConfig = allPartnerConfigs.getJsonObject(existingPartnerIdx);
allPartnerConfigs.remove(existingPartnerIdx);
storageManager.upload(allPartnerConfigs);
rc.response()
.putHeader(HttpHeaders.CONTENT_TYPE, "application/json")
.end(deletedConfig.encode());
} catch (Exception e) {
rc.fail(500, e);
}
}

private void handlePartnerConfigBulkReplace(RoutingContext rc) {
try {
// refresh manually
this.partnerConfigProvider.loadContent();
JsonArray partners = rc.body().asJsonArray();

if (partners == null) {
ResponseUtil.error(rc, 400, "Body must be none empty");
ResponseUtil.error(rc, 400, "Body must be non-empty");
return;
}

// Keep track of names to check for duplicates
Set<String> partnerNames = new HashSet<>();

// Validate each config
for (int i = 0; i < partners.size(); i++) {
JsonObject config = partners.getJsonObject(i);
if (config == null) {
ResponseUtil.error(rc, 400, "Could not parse config at index " + i);
return;
}

if (!validatePartnerConfig(rc, config)) {
return;
}

String name = partners.getJsonObject(i).getString("name");
if (name != null && !partnerNames.add(name.toLowerCase())) {
ResponseUtil.error(rc, 400, "Duplicate partner name: " + name);
return;
}
}

storageManager.upload(partners);

rc.response()
.putHeader(HttpHeaders.CONTENT_TYPE, "application/json")
.end("\"success\"");
.end(partners.encode());
} catch (Exception e) {
rc.fail(500, e);
}
}

private boolean validatePartnerConfig(RoutingContext rc, JsonObject config) {
if (config == null) {
ResponseUtil.error(rc, 400, "Partner config is required");
return false;
}

String name = config.getString("name");
String url = config.getString("url");
String method = config.getString("method");
Integer retryCount = config.getInteger("retry_count");
Integer retryBackoffMs = config.getInteger("retry_backoff_ms");

if (name == null || name.trim().isEmpty()) {
ResponseUtil.error(rc, 400, "Partner config 'name' is required");
return false;
}
if (url == null || url.trim().isEmpty()) {
ResponseUtil.error(rc, 400, "Partner config 'url' is required");
return false;
}
if (method == null || method.trim().isEmpty()) {
ResponseUtil.error(rc, 400, "Partner config 'method' is required");
return false;
}
if (retryCount == null || retryCount < 0) {
ResponseUtil.error(rc, 400, "Partner config 'retry_count' is required and must be >= 0");
return false;
}
if (retryBackoffMs == null || retryBackoffMs < 0) {
ResponseUtil.error(rc, 400, "Partner config 'retry_backoff_ms' is required and must be >= 0");
return false;
}

// Validate optional array fields
if (!validateArrayField(rc, config, "query_params")) {
return false;
}
if (!validateArrayField(rc, config, "additional_headers")) {
return false;
}

return true;
}

private boolean validatePartnerConfigForUpdate(RoutingContext rc, JsonObject config) {
if (config == null) {
ResponseUtil.error(rc, 400, "Partner config is required");
return false;
}

// Name is always required to identify the partner (already checked in handlePartnerConfigUpdate)
// Validate fields that are present (not null)
String url = config.getString("url");
if (url != null && url.trim().isEmpty()) {
ResponseUtil.error(rc, 400, "Partner config 'url' cannot be empty");
return false;
}

String method = config.getString("method");
if (method != null && method.trim().isEmpty()) {
ResponseUtil.error(rc, 400, "Partner config 'method' cannot be empty");
return false;
}

Integer retryCount = config.getInteger("retry_count");
if (retryCount != null && retryCount < 0) {
ResponseUtil.error(rc, 400, "Partner config 'retry_count' must be >= 0");
return false;
}

Integer retryBackoffMs = config.getInteger("retry_backoff_ms");
if (retryBackoffMs != null && retryBackoffMs < 0) {
ResponseUtil.error(rc, 400, "Partner config 'retry_backoff_ms' must be >= 0");
return false;
}

// Validate optional array fields
if (!validateArrayField(rc, config, "query_params")) {
return false;
}
if (!validateArrayField(rc, config, "additional_headers")) {
return false;
}

return true;
}

private boolean validateArrayField(RoutingContext rc, JsonObject config, String fieldName) {
// Field is optional, so null is acceptable
if (!config.containsKey(fieldName)) {
return true;
}

Object value = config.getValue(fieldName);
if (value == null) {
return true; // null is acceptable
}

// If present, must be a JsonArray
if (!(value instanceof JsonArray)) {
ResponseUtil.error(rc, 400, "Partner config '" + fieldName + "' must be an array");
return false;
}

return true;
}

private int findPartnerIndex(JsonArray configs, String partnerName) {
if (partnerName == null) return -1;
for (int i = 0; i < configs.size(); i++) {
JsonObject config = configs.getJsonObject(i);
String name = config.getString("name");
if (partnerName.equalsIgnoreCase(name)) {
return i;
}
}
return -1;
}
}
Loading