parameters) {
if (parameters.isEmpty()) {
return Optional.empty();
diff --git a/folio-spring-base/src/main/java/org/folio/tenant/domain/dto/Error.java b/folio-spring-base/src/main/java/org/folio/tenant/domain/dto/Error.java
new file mode 100644
index 00000000..a38de324
--- /dev/null
+++ b/folio-spring-base/src/main/java/org/folio/tenant/domain/dto/Error.java
@@ -0,0 +1,62 @@
+package org.folio.tenant.domain.dto;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonTypeName;
+import jakarta.validation.Valid;
+import java.util.ArrayList;
+import java.util.List;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import org.jspecify.annotations.Nullable;
+
+@Data
+@NoArgsConstructor
+@JsonTypeName("error")
+public final class Error {
+
+ @JsonProperty("message")
+ private String message;
+
+ @JsonProperty("type")
+ private @Nullable String type;
+
+ @JsonProperty("code")
+ private @Nullable String code;
+
+ @Valid
+ @JsonProperty("parameters")
+ private List<@Valid Parameter> parameters = new ArrayList<>();
+
+ public Error(String message) {
+ this.message = message;
+ }
+
+ public Error message(String message) {
+ this.message = message;
+ return this;
+ }
+
+ public Error type(@Nullable String type) {
+ this.type = type;
+ return this;
+ }
+
+ public Error code(@Nullable String code) {
+ this.code = code;
+ return this;
+ }
+
+ public Error parameters(List<@Valid Parameter> parameters) {
+ this.parameters = parameters;
+ return this;
+ }
+
+ public Error addParametersItem(Parameter parametersItem) {
+ if (this.parameters == null) {
+ this.parameters = new ArrayList<>();
+ }
+ this.parameters.add(parametersItem);
+ return this;
+ }
+}
+
diff --git a/folio-spring-base/src/main/java/org/folio/tenant/domain/dto/Errors.java b/folio-spring-base/src/main/java/org/folio/tenant/domain/dto/Errors.java
new file mode 100644
index 00000000..987bcde5
--- /dev/null
+++ b/folio-spring-base/src/main/java/org/folio/tenant/domain/dto/Errors.java
@@ -0,0 +1,42 @@
+package org.folio.tenant.domain.dto;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonTypeName;
+import jakarta.validation.Valid;
+import java.util.ArrayList;
+import java.util.List;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import org.jspecify.annotations.Nullable;
+
+@Data
+@NoArgsConstructor
+@JsonTypeName("errors")
+public final class Errors {
+
+ @Valid
+ @JsonProperty("errors")
+ private List<@Valid Error> errors = new ArrayList<>();
+
+ @JsonProperty("total_records")
+ private @Nullable Integer totalRecords;
+
+ public Errors errors(List<@Valid Error> errors) {
+ this.errors = errors;
+ return this;
+ }
+
+ public Errors addErrorsItem(Error errorsItem) {
+ if (this.errors == null) {
+ this.errors = new ArrayList<>();
+ }
+ this.errors.add(errorsItem);
+ return this;
+ }
+
+ public Errors totalRecords(@Nullable Integer totalRecords) {
+ this.totalRecords = totalRecords;
+ return this;
+ }
+}
+
diff --git a/folio-spring-base/src/main/java/org/folio/tenant/domain/dto/Parameter.java b/folio-spring-base/src/main/java/org/folio/tenant/domain/dto/Parameter.java
new file mode 100644
index 00000000..2cf7fbab
--- /dev/null
+++ b/folio-spring-base/src/main/java/org/folio/tenant/domain/dto/Parameter.java
@@ -0,0 +1,34 @@
+package org.folio.tenant.domain.dto;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonTypeName;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import org.jspecify.annotations.Nullable;
+
+@Data
+@NoArgsConstructor
+@JsonTypeName("parameter")
+public final class Parameter {
+
+ @JsonProperty("key")
+ private String key;
+
+ @JsonProperty("value")
+ private @Nullable String value;
+
+ public Parameter(String key) {
+ this.key = key;
+ }
+
+ public Parameter key(String key) {
+ this.key = key;
+ return this;
+ }
+
+ public Parameter value(@Nullable String value) {
+ this.value = value;
+ return this;
+ }
+}
+
diff --git a/folio-spring-base/src/main/java/org/folio/tenant/domain/dto/TenantAttributes.java b/folio-spring-base/src/main/java/org/folio/tenant/domain/dto/TenantAttributes.java
new file mode 100644
index 00000000..388d64fa
--- /dev/null
+++ b/folio-spring-base/src/main/java/org/folio/tenant/domain/dto/TenantAttributes.java
@@ -0,0 +1,58 @@
+package org.folio.tenant.domain.dto;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonTypeName;
+import jakarta.validation.Valid;
+import java.util.ArrayList;
+import java.util.List;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import org.jspecify.annotations.Nullable;
+
+@Data
+@NoArgsConstructor
+@JsonTypeName("tenantAttributes")
+public final class TenantAttributes {
+
+ @JsonProperty("module_from")
+ private @Nullable String moduleFrom;
+
+ @JsonProperty("module_to")
+ private @Nullable String moduleTo;
+
+ @JsonProperty("purge")
+ private Boolean purge = true;
+
+ @Valid
+ @JsonProperty("parameters")
+ private List<@Valid Parameter> parameters = new ArrayList<>();
+
+ public TenantAttributes moduleFrom(@Nullable String moduleFrom) {
+ this.moduleFrom = moduleFrom;
+ return this;
+ }
+
+ public TenantAttributes moduleTo(@Nullable String moduleTo) {
+ this.moduleTo = moduleTo;
+ return this;
+ }
+
+ public TenantAttributes purge(Boolean purge) {
+ this.purge = purge;
+ return this;
+ }
+
+ public TenantAttributes parameters(List<@Valid Parameter> parameters) {
+ this.parameters = parameters;
+ return this;
+ }
+
+ public TenantAttributes addParametersItem(Parameter parametersItem) {
+ if (this.parameters == null) {
+ this.parameters = new ArrayList<>();
+ }
+ this.parameters.add(parametersItem);
+ return this;
+ }
+}
+
diff --git a/folio-spring-base/src/main/java/org/folio/tenant/rest/resource/TenantApi.java b/folio-spring-base/src/main/java/org/folio/tenant/rest/resource/TenantApi.java
new file mode 100644
index 00000000..4430d1e0
--- /dev/null
+++ b/folio-spring-base/src/main/java/org/folio/tenant/rest/resource/TenantApi.java
@@ -0,0 +1,72 @@
+package org.folio.tenant.rest.resource;
+
+import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE;
+import static org.springframework.http.MediaType.TEXT_PLAIN_VALUE;
+
+import io.swagger.v3.oas.annotations.Hidden;
+import jakarta.validation.Valid;
+import org.folio.tenant.domain.dto.TenantAttributes;
+import org.springframework.http.ResponseEntity;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+
+/**
+ * REST API interface for FOLIO tenant lifecycle management.
+ *
+ * Exposes endpoints under the {@code /_/tenant} path to create, upgrade, purge, and query
+ * tenants in accordance with the FOLIO module descriptor contract.
+ */
+@Hidden
+@Validated
+public interface TenantApi {
+
+ String PATH_DELETE_TENANT = "/_/tenant/{operationId}";
+ String PATH_GET_TENANT = "/_/tenant/{operationId}";
+ String PATH_POST_TENANT = "/_/tenant";
+
+ /**
+ * Submits a tenant job (create, upgrade, or delete) with the supplied attributes.
+ *
+ * Corresponds to {@code POST /_/tenant}.
+ *
+ * @param tenantAttributes attributes describing the tenant job to perform; must not be null
+ * @return {@code 204 No Content} when the job completes synchronously,
+ * {@code 400 Bad Request} if the request body is malformed,
+ * {@code 422 Unprocessable Entity} on validation errors,
+ * {@code 500 Internal Server Error} on unexpected failure
+ */
+ @PostMapping(value = TenantApi.PATH_POST_TENANT,
+ produces = {APPLICATION_JSON_VALUE, TEXT_PLAIN_VALUE},
+ consumes = APPLICATION_JSON_VALUE)
+ ResponseEntity postTenant(@Valid @RequestBody TenantAttributes tenantAttributes);
+
+ /**
+ * Checks whether the tenant job identified by the given ID has completed.
+ *
+ * Corresponds to {@code GET /_/tenant/{operationId}}.
+ *
+ * @param operationId the ID of the tenant job to query
+ * @return {@code 200 OK} with {@code "true"} if the operation exists and is complete,
+ * {@code "false"} otherwise;
+ * {@code 500 Internal Server Error} on unexpected failure
+ */
+ @GetMapping(value = TenantApi.PATH_GET_TENANT, produces = TEXT_PLAIN_VALUE)
+ ResponseEntity getTenant(@PathVariable String operationId);
+
+ /**
+ * Deletes the tenant job identified by the given operation ID.
+ *
+ * Corresponds to {@code DELETE /_/tenant/{operationId}}.
+ *
+ * @param operationId the ID of the tenant job to delete
+ * @return {@code 204 No Content} on success,
+ * {@code 400 Bad Request} if the operation ID is invalid,
+ * {@code 500 Internal Server Error} on unexpected failure
+ */
+ @DeleteMapping(value = TenantApi.PATH_DELETE_TENANT, produces = TEXT_PLAIN_VALUE)
+ ResponseEntity deleteTenant(@PathVariable String operationId);
+}
diff --git a/folio-spring-base/src/main/resources/swagger.api/tenant.yaml b/folio-spring-base/src/main/resources/swagger.api/tenant.yaml
deleted file mode 100644
index c44badcd..00000000
--- a/folio-spring-base/src/main/resources/swagger.api/tenant.yaml
+++ /dev/null
@@ -1,209 +0,0 @@
-openapi: 3.0.0
-info:
- title: Tenant API
- version: v1
-
-paths:
- /_/tenant:
- post:
- description: Create tenant job (create, upgrade, delete)
- operationId: postTenant
- tags:
- - tenant
- requestBody:
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/tenantAttributes'
- examples:
- tenantAttributesForUpgrade:
- $ref: '#/components/examples/tenantAttributesForUpgrade'
- tenantAttributesForEnable:
- $ref: '#/components/examples/tenantAttributesForEnable'
- tenantAttributesForDisable:
- $ref: '#/components/examples/tenantAttributesForDisable'
- required: true
- responses:
- "204":
- description: Job completed
- "400":
- description: Bad request
- content:
- application/json:
- schema:
- type: string
- text/plain:
- schema:
- type: string
- "422":
- $ref: '#/components/responses/trait_validate_422'
- "500":
- description: Internal server error
- content:
- application/json:
- schema:
- type: string
- example: Internal server error, contact administrator
- text/plain:
- schema:
- type: string
- example: Internal server error, contact administrator
- /_/tenant/{operationId}:
- parameters:
- - name: operationId
- in: path
- required: true
- schema:
- type: string
- description: Operation ID
- get:
- description: Does tenant id already exist
- operationId: getTenant
- tags:
- - tenant
- responses:
- "200":
- description: true or false indicator
- content:
- text/plain:
- schema:
- type: string
- example: true
- "500":
- description: Internal server error
- content:
- text/plain:
- schema:
- type: string
- example: Internal server error, contact administrator
- delete:
- description: drop tenant id
- operationId: deleteTenant
- tags:
- - tenant
- responses:
- "204":
- description: Delete succeeded
- "400":
- description: Bad request
- "500":
- description: Internal server error
- content:
- text/plain:
- schema:
- type: string
- example: Internal server error, contact administrator
-
-components:
- responses:
- trait_validate_422:
- description: Validation errors
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/errors'
- examples:
- response:
- value:
- errors:
- - message: may not be null
- type: 1
- code: -1
- parameters:
- - key: moduleTo
- value: null
- schemas:
- parameter:
- type: object
- description: Key/value parameter
- properties:
- key:
- type: string
- description: The key for this parameter
- value:
- type: string
- description: The value of this parameter
- required:
- - key
-
- parameters:
- type: object
- description: List of key/value parameters
- items:
- $ref: '#/components/schemas/parameter'
-
- tenantAttributes:
- type: object
- title: Tenant job attributes
- description: Configuration how to install, upgrade or delete a module for a tenant
- properties:
- module_from:
- type: string
- description: Existing module ID. If omitted, the module is not enabled already
- module_to:
- type: string
- description: Target module ID. If omitted, the existing module is disabled
- purge:
- type: boolean
- description: On disable should data also be purged
- default: true
- parameters:
- $ref: '#/components/schemas/parameters'
- description: Parameters that controls what tenant init (eg reference data)
-
- error:
- type: object
- description: An error message
- properties:
- message:
- type: string
- description: Error message text
- type:
- type: string
- description: Error message type
- code:
- type: string
- description: Error message code
- parameters:
- $ref: '#/components/schemas/parameters'
- description: Error message parameters
- required:
- - message
-
- errors:
- type: object
- description: A set of error messages
- properties:
- errors:
- type: array
- description: List of error messages
- items:
- $ref: '#/components/schemas/error'
- total_records:
- type: integer
- description: Total number of errors
-
- examples:
- tenantAttributesForUpgrade:
- summary: Upgrading module for tenant
- value:
- module_to: mod-sample-1.3.1
- module_from: mod-sample-1.0.2
- parameters:
- - key: loadReference
- value: true
- - key: loadSample
- value: true
- tenantAttributesForEnable:
- summary: Enabling module for tenant
- value:
- module_to: mod-sample-1.3.1
- parameters:
- - key: loadReference
- value: true
- tenantAttributesForDisable:
- summary: Disabling module for tenant
- value:
- module_from: mod-sample-1.0.2
- purge: false
-
diff --git a/folio-spring-base/src/test/java/org/folio/tenant/domain/dto/ErrorTest.java b/folio-spring-base/src/test/java/org/folio/tenant/domain/dto/ErrorTest.java
new file mode 100644
index 00000000..5e81b1fe
--- /dev/null
+++ b/folio-spring-base/src/test/java/org/folio/tenant/domain/dto/ErrorTest.java
@@ -0,0 +1,198 @@
+package org.folio.tenant.domain.dto;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.List;
+import org.folio.spring.testing.type.UnitTest;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import tools.jackson.databind.ObjectMapper;
+
+@UnitTest
+class ErrorTest {
+
+ private ObjectMapper mapper;
+
+ @BeforeEach
+ void setUp() {
+ mapper = new ObjectMapper();
+ }
+
+ @Test
+ void defaultValuesOnNoArgConstruction() {
+ var error = new Error();
+
+ assertThat(error.getMessage()).isNull();
+ assertThat(error.getType()).isNull();
+ assertThat(error.getCode()).isNull();
+ assertThat(error.getParameters()).isEmpty();
+ }
+
+ @Test
+ void constructorWithMessageSetsMessage() {
+ var error = new Error("something went wrong");
+
+ assertThat(error.getMessage()).isEqualTo("something went wrong");
+ assertThat(error.getType()).isNull();
+ assertThat(error.getCode()).isNull();
+ assertThat(error.getParameters()).isEmpty();
+ }
+
+ @Test
+ void fluentSettersReturnSameInstance() {
+ var error = new Error();
+
+ assertThat(error.message("msg")).isSameAs(error);
+ assertThat(error.type("t")).isSameAs(error);
+ assertThat(error.code("c")).isSameAs(error);
+ assertThat(error.parameters(List.of())).isSameAs(error);
+ }
+
+ @Test
+ void fluentSettersStoreValues() {
+ var param = new Parameter().key("k").value("v");
+ var error = new Error()
+ .message("msg")
+ .type("ERROR")
+ .code("ERR-001")
+ .parameters(List.of(param));
+
+ assertThat(error.getMessage()).isEqualTo("msg");
+ assertThat(error.getType()).isEqualTo("ERROR");
+ assertThat(error.getCode()).isEqualTo("ERR-001");
+ assertThat(error.getParameters()).containsExactly(param);
+ }
+
+ @Test
+ void addParametersItemAppendsToList() {
+ var first = new Parameter().key("a");
+ var second = new Parameter().key("b");
+ var error = new Error()
+ .addParametersItem(first)
+ .addParametersItem(second);
+
+ assertThat(error.getParameters()).containsExactly(first, second);
+ }
+
+ @Test
+ void addParametersItemInitializesListWhenNull() {
+ var error = new Error();
+ error.setParameters(null);
+
+ var param = new Parameter().key("x");
+ error.addParametersItem(param);
+
+ assertThat(error.getParameters()).containsExactly(param);
+ }
+
+ @Test
+ void nullTypeAndCodeAreAccepted() {
+ var error = new Error().type(null).code(null);
+
+ assertThat(error.getType()).isNull();
+ assertThat(error.getCode()).isNull();
+ }
+
+ @Test
+ void equalObjectsHaveSameHashCode() {
+ var a = new Error().message("msg").type("t").code("c");
+ var b = new Error().message("msg").type("t").code("c");
+
+ assertThat(a).isEqualTo(b).hasSameHashCodeAs(b);
+ }
+
+ @Test
+ void toStringContainsFieldValues() {
+ var error = new Error().message("msg").type("t").code("c");
+
+ assertThat(error.toString()).contains("msg", "t", "c");
+ }
+
+ @Test
+ void serializesFieldNamesCorrectly() {
+ var error = new Error().message("msg").type("ERROR").code("ERR-001");
+
+ var json = mapper.writeValueAsString(error);
+
+ assertThat(json)
+ .isEqualTo("""
+ {"message":"msg","code":"ERR-001","parameters":[],"type":"ERROR"}""");
+ }
+
+ @Test
+ void serializesNullTypeAndCodeAsNullNodes() {
+ var error = new Error().message("msg").type(null).code(null);
+
+ var json = mapper.writeValueAsString(error);
+
+ assertThat(json).contains("\"type\":null", "\"code\":null");
+ }
+
+ @Test
+ void serializesParametersList() {
+ var error = new Error().message("msg")
+ .addParametersItem(new Parameter().key("k").value("v"));
+
+ var json = mapper.writeValueAsString(error);
+
+ assertThat(json)
+ .isEqualTo("""
+ {"message":"msg","code":null,"parameters":[{"key":"k","value":"v"}],"type":null}""");
+ }
+
+ @Test
+ void deserializesFromJson() {
+ var json = """
+ {
+ "message": "msg",
+ "type": "ERROR",
+ "code": "ERR-001",
+ "parameters": [
+ {
+ "key": "k",
+ "value": "v"
+ }
+ ]
+ }
+ """;
+
+ var error = mapper.readValue(json, Error.class);
+
+ assertThat(error.getMessage()).isEqualTo("msg");
+ assertThat(error.getType()).isEqualTo("ERROR");
+ assertThat(error.getCode()).isEqualTo("ERR-001");
+ assertThat(error.getParameters()).hasSize(1);
+ assertThat(error.getParameters().getFirst())
+ .extracting(Parameter::getKey, Parameter::getValue)
+ .containsExactly("k", "v");
+ }
+
+ @Test
+ void deserializesNullTypeAndCode() {
+ var json = """
+ {
+ "message": "msg",
+ "type": null,
+ "code": null
+ }
+ """;
+
+ var error = mapper.readValue(json, Error.class);
+
+ assertThat(error.getType()).isNull();
+ assertThat(error.getCode()).isNull();
+ }
+
+ @Test
+ void roundTripPreservesValues() {
+ var original = new Error()
+ .message("msg")
+ .type("ERROR")
+ .code("ERR-001")
+ .addParametersItem(new Parameter().key("k").value("v"));
+
+ var error = mapper.readValue(mapper.writeValueAsString(original), Error.class);
+
+ assertThat(error).isEqualTo(original);
+ }
+}
diff --git a/folio-spring-base/src/test/java/org/folio/tenant/domain/dto/ErrorsTest.java b/folio-spring-base/src/test/java/org/folio/tenant/domain/dto/ErrorsTest.java
new file mode 100644
index 00000000..ace8aade
--- /dev/null
+++ b/folio-spring-base/src/test/java/org/folio/tenant/domain/dto/ErrorsTest.java
@@ -0,0 +1,170 @@
+package org.folio.tenant.domain.dto;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.List;
+import org.folio.spring.testing.type.UnitTest;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import tools.jackson.databind.ObjectMapper;
+
+@UnitTest
+class ErrorsTest {
+
+ private ObjectMapper mapper;
+
+ @BeforeEach
+ void setUp() {
+ mapper = new ObjectMapper();
+ }
+
+ @Test
+ void defaultValuesOnNoArgConstruction() {
+ var errors = new Errors();
+
+ assertThat(errors.getErrors()).isEmpty();
+ assertThat(errors.getTotalRecords()).isNull();
+ }
+
+ @Test
+ void fluentSettersReturnSameInstance() {
+ var errors = new Errors();
+
+ assertThat(errors.errors(List.of())).isSameAs(errors);
+ assertThat(errors.totalRecords(0)).isSameAs(errors);
+ }
+
+ @Test
+ void fluentSettersStoreValues() {
+ var error = new Error("msg");
+ var errors = new Errors()
+ .errors(List.of(error))
+ .totalRecords(1);
+
+ assertThat(errors.getErrors()).containsExactly(error);
+ assertThat(errors.getTotalRecords()).isEqualTo(1);
+ }
+
+ @Test
+ void addErrorsItemAppendsToList() {
+ var first = new Error("first");
+ var second = new Error("second");
+ var errors = new Errors()
+ .addErrorsItem(first)
+ .addErrorsItem(second);
+
+ assertThat(errors.getErrors()).containsExactly(first, second);
+ }
+
+ @Test
+ void addErrorsItemInitializesListWhenNull() {
+ var errors = new Errors();
+ errors.setErrors(null);
+
+ var error = new Error("msg");
+ errors.addErrorsItem(error);
+
+ assertThat(errors.getErrors()).containsExactly(error);
+ }
+
+ @Test
+ void nullTotalRecordsIsAccepted() {
+ var errors = new Errors().totalRecords(null);
+
+ assertThat(errors.getTotalRecords()).isNull();
+ }
+
+ @Test
+ void equalObjectsHaveSameHashCode() {
+ var a = new Errors().addErrorsItem(new Error("msg")).totalRecords(1);
+ var b = new Errors().addErrorsItem(new Error("msg")).totalRecords(1);
+
+ assertThat(a).isEqualTo(b).hasSameHashCodeAs(b);
+ }
+
+ @Test
+ void toStringContainsFieldValues() {
+ var errors = new Errors().totalRecords(5);
+
+ assertThat(errors.toString()).contains("5");
+ }
+
+ @Test
+ void serializesSnakeCaseTotalRecords() {
+ var errors = new Errors().totalRecords(3);
+
+ var json = mapper.writeValueAsString(errors);
+
+ assertThat(json).contains("\"total_records\":3");
+ }
+
+ @Test
+ void serializesErrorsList() {
+ var errors = new Errors().addErrorsItem(new Error("msg").type("t").code("c"));
+
+ var json = mapper.writeValueAsString(errors);
+
+ assertThat(json)
+ .isEqualTo("""
+ {"errors":[{"message":"msg","code":"c","parameters":[],"type":"t"}],"total_records":null}""");
+ }
+
+ @Test
+ void serializesNullTotalRecordsAsNullNode() {
+ var errors = new Errors().totalRecords(null);
+
+ var json = mapper.writeValueAsString(errors);
+
+ assertThat(json).contains("\"total_records\":null");
+ }
+
+ @Test
+ void deserializesFromJson() {
+ var json = """
+ {
+ "errors": [
+ {
+ "message": "msg",
+ "type": "t",
+ "code": "c",
+ "parameters": [ ]
+ }
+ ],
+ "total_records": 1
+ }
+ """;
+
+ var errors = mapper.readValue(json, Errors.class);
+
+ assertThat(errors.getTotalRecords()).isEqualTo(1);
+ assertThat(errors.getErrors()).hasSize(1);
+ assertThat(errors.getErrors().getFirst())
+ .extracting(Error::getMessage, Error::getType, Error::getCode)
+ .containsExactly("msg", "t", "c");
+ }
+
+ @Test
+ void deserializesNullTotalRecords() {
+ var json = """
+ {
+ "errors": [ ],
+ "total_records": null
+ }
+ """;
+
+ var errors = mapper.readValue(json, Errors.class);
+
+ assertThat(errors.getTotalRecords()).isNull();
+ }
+
+ @Test
+ void roundTripPreservesValues() {
+ var original = new Errors()
+ .addErrorsItem(new Error("msg").type("t").code("c"))
+ .totalRecords(1);
+
+ var errors = mapper.readValue(mapper.writeValueAsString(original), Errors.class);
+
+ assertThat(errors).isEqualTo(original);
+ }
+}
diff --git a/folio-spring-base/src/test/java/org/folio/tenant/domain/dto/ParameterTest.java b/folio-spring-base/src/test/java/org/folio/tenant/domain/dto/ParameterTest.java
new file mode 100644
index 00000000..8153f1a4
--- /dev/null
+++ b/folio-spring-base/src/test/java/org/folio/tenant/domain/dto/ParameterTest.java
@@ -0,0 +1,132 @@
+package org.folio.tenant.domain.dto;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import org.folio.spring.testing.type.UnitTest;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import tools.jackson.databind.ObjectMapper;
+
+@UnitTest
+class ParameterTest {
+
+ private ObjectMapper mapper;
+
+ @BeforeEach
+ void setUp() {
+ mapper = new ObjectMapper();
+ }
+
+ @Test
+ void defaultValuesOnNoArgConstruction() {
+ var param = new Parameter();
+
+ assertThat(param.getKey()).isNull();
+ assertThat(param.getValue()).isNull();
+ }
+
+ @Test
+ void constructorWithKeySetsKey() {
+ var param = new Parameter("myKey");
+
+ assertThat(param.getKey()).isEqualTo("myKey");
+ assertThat(param.getValue()).isNull();
+ }
+
+ @Test
+ void fluentSettersReturnSameInstance() {
+ var param = new Parameter();
+
+ assertThat(param.key("k")).isSameAs(param);
+ assertThat(param.value("v")).isSameAs(param);
+ }
+
+ @Test
+ void fluentSettersStoreValues() {
+ var param = new Parameter().key("myKey").value("myValue");
+
+ assertThat(param.getKey()).isEqualTo("myKey");
+ assertThat(param.getValue()).isEqualTo("myValue");
+ }
+
+ @Test
+ void nullValueIsAccepted() {
+ var param = new Parameter().key("k").value(null);
+
+ assertThat(param.getValue()).isNull();
+ }
+
+ @Test
+ void equalObjectsHaveSameHashCode() {
+ var a = new Parameter().key("k").value("v");
+ var b = new Parameter().key("k").value("v");
+
+ assertThat(a).isEqualTo(b).hasSameHashCodeAs(b);
+ }
+
+ @Test
+ void toStringContainsFieldValues() {
+ var param = new Parameter().key("k").value("v");
+
+ assertThat(param.toString()).contains("k", "v");
+ }
+
+ @Test
+ void serializesFieldNamesCorrectly() {
+ var param = new Parameter().key("myKey").value("myValue");
+
+ var json = mapper.writeValueAsString(param);
+
+ assertThat(json).isEqualTo("""
+ {"key":"myKey","value":"myValue"}""");
+ }
+
+ @Test
+ void serializesNullValueAsNullNode() {
+ var param = new Parameter().key("k").value(null);
+
+ var json = mapper.writeValueAsString(param);
+
+ assertThat(json).contains("""
+ {"key":"k","value":null}""");
+ }
+
+ @Test
+ void deserializesFromJson() {
+ var json = """
+ {
+ "key": "myKey",
+ "value": "myValue"
+ }
+ """;
+
+ var param = mapper.readValue(json, Parameter.class);
+
+ assertThat(param.getKey()).isEqualTo("myKey");
+ assertThat(param.getValue()).isEqualTo("myValue");
+ }
+
+ @Test
+ void deserializesNullValueField() {
+ var json = """
+ {
+ "key": "k",
+ "value": null
+ }
+ """;
+
+ var param = mapper.readValue(json, Parameter.class);
+
+ assertThat(param.getKey()).isEqualTo("k");
+ assertThat(param.getValue()).isNull();
+ }
+
+ @Test
+ void roundTripPreservesValues() {
+ var original = new Parameter().key("k").value("v");
+
+ var param = mapper.readValue(mapper.writeValueAsString(original), Parameter.class);
+
+ assertThat(param).isEqualTo(original);
+ }
+}
diff --git a/folio-spring-base/src/test/java/org/folio/tenant/domain/dto/TenantAttributesTest.java b/folio-spring-base/src/test/java/org/folio/tenant/domain/dto/TenantAttributesTest.java
new file mode 100644
index 00000000..46db36fa
--- /dev/null
+++ b/folio-spring-base/src/test/java/org/folio/tenant/domain/dto/TenantAttributesTest.java
@@ -0,0 +1,176 @@
+package org.folio.tenant.domain.dto;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.List;
+import org.folio.spring.testing.type.UnitTest;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import tools.jackson.databind.ObjectMapper;
+
+@UnitTest
+class TenantAttributesTest {
+
+ private ObjectMapper mapper;
+
+ @BeforeEach
+ void setUp() {
+ mapper = new ObjectMapper();
+ }
+
+ @Test
+ void defaultValuesOnNoArgConstruction() {
+ var attrs = new TenantAttributes();
+
+ assertThat(attrs.getModuleFrom()).isNull();
+ assertThat(attrs.getModuleTo()).isNull();
+ assertThat(attrs.getPurge()).isTrue();
+ assertThat(attrs.getParameters()).isEmpty();
+ }
+
+ @Test
+ void fluentSettersReturnSameInstance() {
+ var param = new Parameter().key("k").value("v");
+ var attrs = new TenantAttributes();
+
+ assertThat(attrs.moduleFrom("mod-1.0.0")).isSameAs(attrs);
+ assertThat(attrs.moduleTo("mod-2.0.0")).isSameAs(attrs);
+ assertThat(attrs.purge(false)).isSameAs(attrs);
+ assertThat(attrs.parameters(List.of(param))).isSameAs(attrs);
+ }
+
+ @Test
+ void fluentSettersStoreValues() {
+ var param = new Parameter().key("k").value("v");
+ var attrs = new TenantAttributes()
+ .moduleFrom("mod-1.0.0")
+ .moduleTo("mod-2.0.0")
+ .purge(false)
+ .parameters(List.of(param));
+
+ assertThat(attrs.getModuleFrom()).isEqualTo("mod-1.0.0");
+ assertThat(attrs.getModuleTo()).isEqualTo("mod-2.0.0");
+ assertThat(attrs.getPurge()).isFalse();
+ assertThat(attrs.getParameters()).containsExactly(param);
+ }
+
+ @Test
+ void addParametersItemAppendsToList() {
+ var first = new Parameter().key("a");
+ var second = new Parameter().key("b");
+ var attrs = new TenantAttributes()
+ .addParametersItem(first)
+ .addParametersItem(second);
+
+ assertThat(attrs.getParameters()).containsExactly(first, second);
+ }
+
+ @Test
+ void addParametersItemInitializesListWhenNull() {
+ var attrs = new TenantAttributes();
+ attrs.setParameters(null);
+
+ var param = new Parameter().key("x");
+ attrs.addParametersItem(param);
+
+ assertThat(attrs.getParameters()).containsExactly(param);
+ }
+
+ @Test
+ void equalObjectsHaveSameHashCode() {
+ var a = new TenantAttributes().moduleFrom("m").moduleTo("n").purge(true);
+ var b = new TenantAttributes().moduleFrom("m").moduleTo("n").purge(true);
+
+ assertThat(a).isEqualTo(b).hasSameHashCodeAs(b);
+ }
+
+ @Test
+ void toStringContainsFieldValues() {
+ var attrs = new TenantAttributes().moduleFrom("mod-1.0.0").moduleTo("mod-2.0.0");
+
+ assertThat(attrs.toString()).contains("mod-1.0.0", "mod-2.0.0");
+ }
+
+ @Test
+ void nullModuleFromAndModuleToAreAccepted() {
+ var attrs = new TenantAttributes().moduleFrom(null).moduleTo(null);
+
+ assertThat(attrs.getModuleFrom()).isNull();
+ assertThat(attrs.getModuleTo()).isNull();
+ }
+
+ @Test
+ void serializesSnakeCaseFieldNames() {
+ var attrs = new TenantAttributes().moduleFrom("mod-1.0.0").moduleTo("mod-2.0.0").purge(false);
+
+ var json = mapper.writeValueAsString(attrs);
+
+ assertThat(json)
+ .isEqualTo("""
+ {"module_from":"mod-1.0.0","module_to":"mod-2.0.0","parameters":[],"purge":false}""");
+ }
+
+ @Test
+ void serializesParametersList() {
+ var attrs = new TenantAttributes()
+ .addParametersItem(new Parameter().key("k").value("v"));
+
+ var json = mapper.writeValueAsString(attrs);
+
+ assertThat(json).isEqualTo("""
+ {"module_from":null,"module_to":null,"parameters":[{"key":"k","value":"v"}],"purge":true}""");
+ }
+
+ @Test
+ void deserializesFromJson() {
+ var json = """
+ {
+ "module_from": "mod-1.0.0",
+ "module_to": "mod-2.0.0",
+ "purge": false,
+ "parameters": [
+ {
+ "key": "k",
+ "value": "v"
+ }
+ ]
+ }
+ """;
+
+ var attrs = mapper.readValue(json, TenantAttributes.class);
+
+ assertThat(attrs.getModuleFrom()).isEqualTo("mod-1.0.0");
+ assertThat(attrs.getModuleTo()).isEqualTo("mod-2.0.0");
+ assertThat(attrs.getPurge()).isFalse();
+ assertThat(attrs.getParameters()).hasSize(1);
+ assertThat(attrs.getParameters().getFirst().getKey()).isEqualTo("k");
+ }
+
+ @Test
+ void deserializesNullModuleFields() {
+ var json = """
+ {
+ "module_from": null,
+ "module_to": null
+ }
+ """;
+
+ var attrs = mapper.readValue(json, TenantAttributes.class);
+
+ assertThat(attrs.getModuleFrom()).isNull();
+ assertThat(attrs.getModuleTo()).isNull();
+ }
+
+ @Test
+ void roundTripPreservesValues() {
+ var original = new TenantAttributes()
+ .moduleFrom("mod-1.0.0")
+ .moduleTo("mod-2.0.0")
+ .purge(false)
+ .addParametersItem(new Parameter().key("k").value("v"));
+
+ var attrs = mapper.readValue(mapper.writeValueAsString(original), TenantAttributes.class);
+
+ assertThat(attrs).isEqualTo(original);
+ }
+}
diff --git a/folio-spring-system-user/src/main/java/org/folio/spring/context/ExecutionContextBuilder.java b/folio-spring-system-user/src/main/java/org/folio/spring/context/ExecutionContextBuilder.java
index 6c780f1f..d86ff79a 100644
--- a/folio-spring-system-user/src/main/java/org/folio/spring/context/ExecutionContextBuilder.java
+++ b/folio-spring-system-user/src/main/java/org/folio/spring/context/ExecutionContextBuilder.java
@@ -3,12 +3,10 @@
import static java.util.Collections.emptyMap;
import static java.util.Collections.singleton;
-import com.google.common.collect.Maps;
import java.util.Collection;
-import java.util.HashMap;
import java.util.Map;
import java.util.function.Supplier;
-import javax.annotation.CheckForNull;
+import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.collections4.MapUtils;
@@ -32,10 +30,10 @@ public class ExecutionContextBuilder {
* Creates an execution context for sending requests to FOLIO on behalf of a system user.
*
* @param systemUser the user to send requests as
- * @param refresher a supplier which should, upon the {@code systemUser}'s expiration, return a new
- * {@link SystemUser} with a fresh access token
+ * @param refresher a supplier which should, upon the {@code systemUser}'s expiration, return a new
+ * {@link SystemUser} with a fresh access token
*/
- public FolioExecutionContext forSystemUser(SystemUser systemUser, @CheckForNull Supplier refresher) {
+ public FolioExecutionContext forSystemUser(SystemUser systemUser, Supplier refresher) {
return new SystemUserExecutionContext(moduleMetadata, systemUser, refresher);
}
@@ -45,7 +43,11 @@ public FolioExecutionContext buildContext(String tenantId) {
public FolioExecutionContext buildContext(String tenantId, Map> headers) {
var okapiUrl = folioEnvironment.getOkapiUrl();
- var contextHeaders = new HashMap<>(Maps.filterValues(MapUtils.emptyIfNull(headers), CollectionUtils::isNotEmpty));
+ var contextHeaders = MapUtils.emptyIfNull(headers).entrySet()
+ .stream()
+ .filter(entry -> CollectionUtils.isNotEmpty(entry.getValue()))
+ .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
+
contextHeaders.put(XOkapiHeaders.URL, singleton(okapiUrl));
contextHeaders.put(XOkapiHeaders.TENANT, singleton(tenantId));
return new DefaultFolioExecutionContext(
diff --git a/folio-spring-system-user/src/main/java/org/folio/spring/context/SystemUserExecutionContext.java b/folio-spring-system-user/src/main/java/org/folio/spring/context/SystemUserExecutionContext.java
index 2245603f..ed313882 100644
--- a/folio-spring-system-user/src/main/java/org/folio/spring/context/SystemUserExecutionContext.java
+++ b/folio-spring-system-user/src/main/java/org/folio/spring/context/SystemUserExecutionContext.java
@@ -8,7 +8,6 @@
import java.util.Optional;
import java.util.UUID;
import java.util.function.Supplier;
-import javax.annotation.CheckForNull;
import lombok.extern.log4j.Log4j2;
import org.apache.commons.lang3.StringUtils;
import org.folio.spring.FolioExecutionContext;
@@ -28,7 +27,6 @@ public class SystemUserExecutionContext implements FolioExecutionContext {
private final FolioModuleMetadata moduleMetadata;
- @CheckForNull
private final Supplier refresher;
private SystemUser systemUser;
diff --git a/folio-spring-system-user/src/test/java/org/folio/spring/client/UsersClientTest.java b/folio-spring-system-user/src/test/java/org/folio/spring/client/UsersClientTest.java
index 696e2c75..da57d805 100644
--- a/folio-spring-system-user/src/test/java/org/folio/spring/client/UsersClientTest.java
+++ b/folio-spring-system-user/src/test/java/org/folio/spring/client/UsersClientTest.java
@@ -3,13 +3,12 @@
import static org.assertj.core.api.Assertions.assertThat;
import static org.folio.spring.client.UsersClient.User;
-import com.fasterxml.jackson.core.JsonProcessingException;
-import com.fasterxml.jackson.core.type.TypeReference;
-import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.HashMap;
import java.util.Map;
import org.folio.spring.testing.type.UnitTest;
import org.junit.jupiter.api.Test;
+import tools.jackson.core.type.TypeReference;
+import tools.jackson.databind.ObjectMapper;
@UnitTest
class UsersClientTest {
@@ -17,7 +16,7 @@ class UsersClientTest {
private final ObjectMapper objectMapper = new ObjectMapper();
@Test
- void testDeserialization() throws JsonProcessingException {
+ void testDeserialization() {
User user = objectMapper.readValue(
"""
{
@@ -38,7 +37,7 @@ void testDeserialization() throws JsonProcessingException {
}
@Test
- void testSerialization() throws JsonProcessingException {
+ void testSerialization() {
Map user = objectMapper.readValue(
objectMapper.writeValueAsString(
new User("id", "username", null, true, null,
diff --git a/folio-spring-tenant-settings/pom.xml b/folio-spring-tenant-settings/pom.xml
index d1a3e359..2b78c99d 100644
--- a/folio-spring-tenant-settings/pom.xml
+++ b/folio-spring-tenant-settings/pom.xml
@@ -33,6 +33,12 @@
spring-boot-starter-webmvc-test
+
+ org.projectlombok
+ lombok
+ true
+
+
org.hibernate.validator
hibernate-validator
diff --git a/pom.xml b/pom.xml
index 1c50b9e0..947af31f 100644
--- a/pom.xml
+++ b/pom.xml
@@ -39,6 +39,7 @@
78.3
1.6.0
3.20.0
+ 3.2.4
5.0.0