From eaa09ceacb069169c53b1c12b84491796afd5a3b Mon Sep 17 00:00:00 2001 From: Sun Cao Date: Mon, 14 Sep 2026 11:09:33 -0700 Subject: [PATCH 01/12] Return protocol error bodies for HTTP failures. --- .../io/opensharing/http/ApiException.java | 52 +++++++++ .../java/io/opensharing/http/ApiFailure.java | 107 ++++++++++++++++++ .../java/io/opensharing/http/ErrorCodes.java | 18 +++ .../io/opensharing/http/ErrorResponse.java | 4 + .../http/GlobalExceptionHandler.java | 17 +++ .../io/opensharing/http/ApiFailureTest.java | 62 ++++++++++ .../http/GlobalExceptionHandlerTest.java | 31 +++++ 7 files changed, 291 insertions(+) create mode 100644 server/opensharing-server-core/src/main/java/io/opensharing/http/ApiException.java create mode 100644 server/opensharing-server-core/src/main/java/io/opensharing/http/ApiFailure.java create mode 100644 server/opensharing-server-core/src/main/java/io/opensharing/http/ErrorCodes.java create mode 100644 server/opensharing-server-core/src/main/java/io/opensharing/http/ErrorResponse.java create mode 100644 server/opensharing-server-core/src/main/java/io/opensharing/http/GlobalExceptionHandler.java create mode 100644 server/opensharing-server-core/src/test/java/io/opensharing/http/ApiFailureTest.java create mode 100644 server/opensharing-server/src/test/java/io/opensharing/http/GlobalExceptionHandlerTest.java diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/http/ApiException.java b/server/opensharing-server-core/src/main/java/io/opensharing/http/ApiException.java new file mode 100644 index 000000000..71a7fb199 --- /dev/null +++ b/server/opensharing-server-core/src/main/java/io/opensharing/http/ApiException.java @@ -0,0 +1,52 @@ +package io.opensharing.http; + +import org.springframework.http.HttpStatus; + +/** An error with an explicit protocol error code and HTTP status. */ +public class ApiException extends RuntimeException { + + private final HttpStatus status; + private final String errorCode; + + public ApiException(HttpStatus status, String errorCode, String message) { + super(message); + this.status = status; + this.errorCode = errorCode; + } + + public HttpStatus getStatus() { + return status; + } + + public String getErrorCode() { + return errorCode; + } + + public static ApiException notFound(String message) { + return new ApiException(HttpStatus.NOT_FOUND, ErrorCodes.RESOURCE_DOES_NOT_EXIST, message); + } + + public static ApiException alreadyExists(String message) { + return new ApiException(HttpStatus.CONFLICT, ErrorCodes.RESOURCE_ALREADY_EXISTS, message); + } + + public static ApiException conflict(String message) { + return new ApiException(HttpStatus.CONFLICT, ErrorCodes.RESOURCE_CONFLICT, message); + } + + public static ApiException invalidParameter(String message) { + return new ApiException(HttpStatus.BAD_REQUEST, ErrorCodes.INVALID_PARAMETER_VALUE, message); + } + + public static ApiException permissionDenied(String message) { + return new ApiException(HttpStatus.FORBIDDEN, ErrorCodes.PERMISSION_DENIED, message); + } + + public static ApiException unauthenticated(String message) { + return new ApiException(HttpStatus.UNAUTHORIZED, ErrorCodes.UNAUTHENTICATED, message); + } + + public static ApiException notImplemented(String message) { + return new ApiException(HttpStatus.NOT_IMPLEMENTED, ErrorCodes.NOT_IMPLEMENTED, message); + } +} diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/http/ApiFailure.java b/server/opensharing-server-core/src/main/java/io/opensharing/http/ApiFailure.java new file mode 100644 index 000000000..5a86f61a8 --- /dev/null +++ b/server/opensharing-server-core/src/main/java/io/opensharing/http/ApiFailure.java @@ -0,0 +1,107 @@ +package io.opensharing.http; + +import io.opensharing.exception.AssetAccessDeniedException; +import io.opensharing.exception.AssetNotFoundException; +import io.opensharing.exception.CatalogAuthenticationException; +import io.opensharing.exception.CatalogException; +import io.opensharing.exception.UnsupportedAssetTypeException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.HttpStatus; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.method.annotation.HandlerMethodValidationException; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.servlet.resource.NoResourceFoundException; + +/** Maps a failure to an HTTP status, protocol error code, and caller-facing message. */ +public record ApiFailure(HttpStatus status, String errorCode, String message) { + + private static final Logger log = LoggerFactory.getLogger(ApiFailure.class); + + public static ApiFailure of(Exception e) { + return switch (e) { + case ApiException api -> + new ApiFailure(api.getStatus(), api.getErrorCode(), api.getMessage()); + case AssetNotFoundException missing -> + new ApiFailure( + HttpStatus.NOT_FOUND, ErrorCodes.RESOURCE_DOES_NOT_EXIST, missing.getMessage()); + case AssetAccessDeniedException denied -> + new ApiFailure(HttpStatus.FORBIDDEN, ErrorCodes.PERMISSION_DENIED, denied.getMessage()); + case UnsupportedAssetTypeException unsupported -> + new ApiFailure( + HttpStatus.BAD_REQUEST, + ErrorCodes.INVALID_PARAMETER_VALUE, + unsupported.getMessage()); + case CatalogAuthenticationException rejected -> { + log.error("The sharing server could not authenticate to the catalog", rejected); + yield new ApiFailure( + HttpStatus.BAD_GATEWAY, + ErrorCodes.CATALOG_ERROR, + "the sharing server could not authenticate to the catalog"); + } + case CatalogException failed -> { + log.error("Catalog request failed", failed); + yield new ApiFailure( + HttpStatus.BAD_GATEWAY, ErrorCodes.CATALOG_ERROR, failed.getMessage()); + } + case MethodArgumentNotValidException invalid -> + new ApiFailure( + HttpStatus.BAD_REQUEST, ErrorCodes.INVALID_PARAMETER_VALUE, describe(invalid)); + case HandlerMethodValidationException ignored -> validationFailed(); + case MethodArgumentTypeMismatchException mistyped -> + new ApiFailure( + HttpStatus.BAD_REQUEST, ErrorCodes.INVALID_PARAMETER_VALUE, describe(mistyped)); + case MissingServletRequestParameterException missing -> + new ApiFailure( + HttpStatus.BAD_REQUEST, + ErrorCodes.INVALID_PARAMETER_VALUE, + missing.getParameterName() + " is required"); + case HttpMessageNotReadableException ignored -> + new ApiFailure( + HttpStatus.BAD_REQUEST, ErrorCodes.MALFORMED_REQUEST, "request body is malformed"); + case DataIntegrityViolationException conflict -> { + log.debug("Rejected request that violated a uniqueness constraint", conflict); + yield new ApiFailure( + HttpStatus.CONFLICT, + ErrorCodes.RESOURCE_ALREADY_EXISTS, + "the object already exists or conflicts with an existing object"); + } + case NoResourceFoundException ignored -> + new ApiFailure( + HttpStatus.NOT_FOUND, + ErrorCodes.RESOURCE_DOES_NOT_EXIST, + "the endpoint does not exist"); + case IllegalArgumentException illegal -> + new ApiFailure( + HttpStatus.BAD_REQUEST, ErrorCodes.INVALID_PARAMETER_VALUE, illegal.getMessage()); + default -> { + log.error("Unhandled server error", e); + yield new ApiFailure( + HttpStatus.INTERNAL_SERVER_ERROR, ErrorCodes.INTERNAL_ERROR, "internal server error"); + } + }; + } + + private static ApiFailure validationFailed() { + return new ApiFailure( + HttpStatus.BAD_REQUEST, ErrorCodes.INVALID_PARAMETER_VALUE, "request validation failed"); + } + + private static String describe(MethodArgumentNotValidException invalid) { + return invalid.getBindingResult().getFieldErrors().stream() + .findFirst() + .map(error -> error.getField() + " " + error.getDefaultMessage()) + .orElse("request validation failed"); + } + + private static String describe(MethodArgumentTypeMismatchException mistyped) { + Class wanted = mistyped.getRequiredType(); + boolean numeric = wanted == Integer.class || wanted == Long.class; + return numeric + ? mistyped.getName() + " must be a number" + : mistyped.getName() + " is not a valid value"; + } +} diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/http/ErrorCodes.java b/server/opensharing-server-core/src/main/java/io/opensharing/http/ErrorCodes.java new file mode 100644 index 000000000..09614b1cd --- /dev/null +++ b/server/opensharing-server-core/src/main/java/io/opensharing/http/ErrorCodes.java @@ -0,0 +1,18 @@ +package io.opensharing.http; + +/** Error codes used in {@link ErrorResponse#errorCode()}. */ +public final class ErrorCodes { + + public static final String MALFORMED_REQUEST = "MALFORMED_REQUEST"; + public static final String INVALID_PARAMETER_VALUE = "INVALID_PARAMETER_VALUE"; + public static final String UNAUTHENTICATED = "UNAUTHENTICATED"; + public static final String PERMISSION_DENIED = "PERMISSION_DENIED"; + public static final String RESOURCE_DOES_NOT_EXIST = "RESOURCE_DOES_NOT_EXIST"; + public static final String RESOURCE_ALREADY_EXISTS = "RESOURCE_ALREADY_EXISTS"; + public static final String RESOURCE_CONFLICT = "RESOURCE_CONFLICT"; + public static final String CATALOG_ERROR = "CATALOG_ERROR"; + public static final String NOT_IMPLEMENTED = "NOT_IMPLEMENTED"; + public static final String INTERNAL_ERROR = "INTERNAL_ERROR"; + + private ErrorCodes() {} +} diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/http/ErrorResponse.java b/server/opensharing-server-core/src/main/java/io/opensharing/http/ErrorResponse.java new file mode 100644 index 000000000..aea36ae13 --- /dev/null +++ b/server/opensharing-server-core/src/main/java/io/opensharing/http/ErrorResponse.java @@ -0,0 +1,4 @@ +package io.opensharing.http; + +/** Error body returned by every non-2xx protocol response. */ +public record ErrorResponse(String errorCode, String message) {} diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/http/GlobalExceptionHandler.java b/server/opensharing-server-core/src/main/java/io/opensharing/http/GlobalExceptionHandler.java new file mode 100644 index 000000000..daf2590ae --- /dev/null +++ b/server/opensharing-server-core/src/main/java/io/opensharing/http/GlobalExceptionHandler.java @@ -0,0 +1,17 @@ +package io.opensharing.http; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/** Renders every failure as {@code {errorCode, message}}. */ +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(Exception.class) + public ResponseEntity handle(Exception e) { + ApiFailure failure = ApiFailure.of(e); + return ResponseEntity.status(failure.status()) + .body(new ErrorResponse(failure.errorCode(), failure.message())); + } +} diff --git a/server/opensharing-server-core/src/test/java/io/opensharing/http/ApiFailureTest.java b/server/opensharing-server-core/src/test/java/io/opensharing/http/ApiFailureTest.java new file mode 100644 index 000000000..d445fb5ca --- /dev/null +++ b/server/opensharing-server-core/src/test/java/io/opensharing/http/ApiFailureTest.java @@ -0,0 +1,62 @@ +package io.opensharing.http; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import io.opensharing.catalog.AssetLookup; +import io.opensharing.catalog.AssetType; +import io.opensharing.catalog.CatalogCaller; +import io.opensharing.exception.AssetAccessDeniedException; +import io.opensharing.exception.AssetNotFoundException; +import io.opensharing.exception.CatalogException; +import io.opensharing.exception.UnsupportedAssetTypeException; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.web.servlet.resource.NoResourceFoundException; + +class ApiFailureTest { + + @Test + void mapsCatalogMissesAndDenials() { + AssetLookup lookup = AssetLookup.of(AssetType.TABLE, "main.sales.missing"); + ApiFailure missing = ApiFailure.of(new AssetNotFoundException(lookup)); + assertEquals(HttpStatus.NOT_FOUND, missing.status()); + assertEquals(ErrorCodes.RESOURCE_DOES_NOT_EXIST, missing.errorCode()); + + CatalogCaller bob = CatalogCaller.withBearerToken("bob@example.com", "token"); + ApiFailure denied = ApiFailure.of(new AssetAccessDeniedException(lookup, bob)); + assertEquals(HttpStatus.FORBIDDEN, denied.status()); + assertEquals(ErrorCodes.PERMISSION_DENIED, denied.errorCode()); + } + + @Test + void mapsCatalogFailuresAsBadGateway() { + ApiFailure failure = ApiFailure.of(new CatalogException("catalog unreachable")); + assertEquals(HttpStatus.BAD_GATEWAY, failure.status()); + assertEquals(ErrorCodes.CATALOG_ERROR, failure.errorCode()); + assertEquals("catalog unreachable", failure.message()); + } + + @Test + void mapsUnsupportedAssetTypesAsBadRequest() { + ApiFailure failure = ApiFailure.of(new UnsupportedAssetTypeException("not a schema")); + assertEquals(HttpStatus.BAD_REQUEST, failure.status()); + assertEquals(ErrorCodes.INVALID_PARAMETER_VALUE, failure.errorCode()); + } + + @Test + void mapsUnknownRoutes() { + ApiFailure failure = ApiFailure.of(new NoResourceFoundException(HttpMethod.GET, "/missing")); + assertEquals(HttpStatus.NOT_FOUND, failure.status()); + assertEquals(ErrorCodes.RESOURCE_DOES_NOT_EXIST, failure.errorCode()); + assertEquals("the endpoint does not exist", failure.message()); + } + + @Test + void keepsAnExplicitApiException() { + ApiFailure failure = ApiFailure.of(ApiException.unauthenticated("token is missing")); + assertEquals(HttpStatus.UNAUTHORIZED, failure.status()); + assertEquals(ErrorCodes.UNAUTHENTICATED, failure.errorCode()); + assertEquals("token is missing", failure.message()); + } +} diff --git a/server/opensharing-server/src/test/java/io/opensharing/http/GlobalExceptionHandlerTest.java b/server/opensharing-server/src/test/java/io/opensharing/http/GlobalExceptionHandlerTest.java new file mode 100644 index 000000000..182b6b25e --- /dev/null +++ b/server/opensharing-server/src/test/java/io/opensharing/http/GlobalExceptionHandlerTest.java @@ -0,0 +1,31 @@ +package io.opensharing.http; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest( + properties = { + "spring.datasource.url=jdbc:h2:mem:http-errors;DB_CLOSE_DELAY=-1", + "opensharing.catalog.type=local", + "opensharing.catalog.local.file=classpath:local-catalog.yml" + }) +@AutoConfigureMockMvc +class GlobalExceptionHandlerTest { + + @Autowired private MockMvc mvc; + + @Test + void answersUnknownRoutesWithTheProtocolErrorBody() throws Exception { + mvc.perform(get("/no-such-route")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.errorCode").value(ErrorCodes.RESOURCE_DOES_NOT_EXIST)) + .andExpect(jsonPath("$.message").value("the endpoint does not exist")); + } +} From 0633efbf7ef3aec73facb9296672843b6726d47d Mon Sep 17 00:00:00 2001 From: Sun Cao Date: Wed, 16 Sep 2026 14:51:53 -0700 Subject: [PATCH 02/12] Use UserContext in HTTP error test --- .../src/test/java/io/opensharing/http/ApiFailureTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/opensharing-server-core/src/test/java/io/opensharing/http/ApiFailureTest.java b/server/opensharing-server-core/src/test/java/io/opensharing/http/ApiFailureTest.java index d445fb5ca..c59b75b1c 100644 --- a/server/opensharing-server-core/src/test/java/io/opensharing/http/ApiFailureTest.java +++ b/server/opensharing-server-core/src/test/java/io/opensharing/http/ApiFailureTest.java @@ -2,9 +2,9 @@ import static org.junit.jupiter.api.Assertions.assertEquals; +import io.opensharing.auth.UserContext; import io.opensharing.catalog.AssetLookup; import io.opensharing.catalog.AssetType; -import io.opensharing.catalog.CatalogCaller; import io.opensharing.exception.AssetAccessDeniedException; import io.opensharing.exception.AssetNotFoundException; import io.opensharing.exception.CatalogException; @@ -23,7 +23,7 @@ void mapsCatalogMissesAndDenials() { assertEquals(HttpStatus.NOT_FOUND, missing.status()); assertEquals(ErrorCodes.RESOURCE_DOES_NOT_EXIST, missing.errorCode()); - CatalogCaller bob = CatalogCaller.withBearerToken("bob@example.com", "token"); + UserContext bob = new UserContext("bob@example.com", "bob@example.com"); ApiFailure denied = ApiFailure.of(new AssetAccessDeniedException(lookup, bob)); assertEquals(HttpStatus.FORBIDDEN, denied.status()); assertEquals(ErrorCodes.PERMISSION_DENIED, denied.errorCode()); From 7bc2ee79a9525168e21795fb6f8c44c7cfda19ea Mon Sep 17 00:00:00 2001 From: Sun Cao Date: Fri, 2 Oct 2026 09:08:27 -0700 Subject: [PATCH 03/12] Adapt the API failure test to the revised catalog SPI. --- .../src/test/java/io/opensharing/http/ApiFailureTest.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/opensharing-server-core/src/test/java/io/opensharing/http/ApiFailureTest.java b/server/opensharing-server-core/src/test/java/io/opensharing/http/ApiFailureTest.java index c59b75b1c..736a4b4f6 100644 --- a/server/opensharing-server-core/src/test/java/io/opensharing/http/ApiFailureTest.java +++ b/server/opensharing-server-core/src/test/java/io/opensharing/http/ApiFailureTest.java @@ -3,7 +3,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import io.opensharing.auth.UserContext; -import io.opensharing.catalog.AssetLookup; +import io.opensharing.catalog.Asset; import io.opensharing.catalog.AssetType; import io.opensharing.exception.AssetAccessDeniedException; import io.opensharing.exception.AssetNotFoundException; @@ -18,12 +18,12 @@ class ApiFailureTest { @Test void mapsCatalogMissesAndDenials() { - AssetLookup lookup = AssetLookup.of(AssetType.TABLE, "main.sales.missing"); + Asset lookup = Asset.of(AssetType.TABLE, "main.sales.missing"); ApiFailure missing = ApiFailure.of(new AssetNotFoundException(lookup)); assertEquals(HttpStatus.NOT_FOUND, missing.status()); assertEquals(ErrorCodes.RESOURCE_DOES_NOT_EXIST, missing.errorCode()); - UserContext bob = new UserContext("bob@example.com", "bob@example.com"); + UserContext bob = UserContext.fromUserIdAndName("bob@example.com", "bob@example.com"); ApiFailure denied = ApiFailure.of(new AssetAccessDeniedException(lookup, bob)); assertEquals(HttpStatus.FORBIDDEN, denied.status()); assertEquals(ErrorCodes.PERMISSION_DENIED, denied.errorCode()); From b32a7bb571ed4d919e391186abca10f2681b8842 Mon Sep 17 00:00:00 2001 From: Sun Cao Date: Mon, 5 Oct 2026 14:23:38 -0700 Subject: [PATCH 04/12] Report unknown enum values in request bodies as invalid parameters. --- .../java/io/opensharing/http/ApiFailure.java | 35 ++++++++++++++++-- .../io/opensharing/http/ApiFailureTest.java | 36 +++++++++++++++++++ 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/http/ApiFailure.java b/server/opensharing-server-core/src/main/java/io/opensharing/http/ApiFailure.java index 5a86f61a8..c2a6f9b40 100644 --- a/server/opensharing-server-core/src/main/java/io/opensharing/http/ApiFailure.java +++ b/server/opensharing-server-core/src/main/java/io/opensharing/http/ApiFailure.java @@ -1,10 +1,13 @@ package io.opensharing.http; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.exc.InvalidFormatException; import io.opensharing.exception.AssetAccessDeniedException; import io.opensharing.exception.AssetNotFoundException; import io.opensharing.exception.CatalogAuthenticationException; import io.opensharing.exception.CatalogException; import io.opensharing.exception.UnsupportedAssetTypeException; +import java.util.Arrays; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.dao.DataIntegrityViolationException; @@ -59,9 +62,7 @@ yield new ApiFailure( HttpStatus.BAD_REQUEST, ErrorCodes.INVALID_PARAMETER_VALUE, missing.getParameterName() + " is required"); - case HttpMessageNotReadableException ignored -> - new ApiFailure( - HttpStatus.BAD_REQUEST, ErrorCodes.MALFORMED_REQUEST, "request body is malformed"); + case HttpMessageNotReadableException unreadable -> describe(unreadable); case DataIntegrityViolationException conflict -> { log.debug("Rejected request that violated a uniqueness constraint", conflict); yield new ApiFailure( @@ -97,6 +98,34 @@ private static String describe(MethodArgumentNotValidException invalid) { .orElse("request validation failed"); } + /** An unknown enum value is a bad parameter, not a malformed body. */ + private static ApiFailure describe(HttpMessageNotReadableException unreadable) { + if (unreadable.getCause() instanceof InvalidFormatException invalid + && invalid.getTargetType() != null + && invalid.getTargetType().isEnum()) { + return new ApiFailure( + HttpStatus.BAD_REQUEST, + ErrorCodes.INVALID_PARAMETER_VALUE, + fieldPath(invalid) + + " must be one of " + + Arrays.toString(invalid.getTargetType().getEnumConstants())); + } + return new ApiFailure( + HttpStatus.BAD_REQUEST, ErrorCodes.MALFORMED_REQUEST, "request body is malformed"); + } + + private static String fieldPath(InvalidFormatException invalid) { + StringBuilder path = new StringBuilder(); + for (JsonMappingException.Reference reference : invalid.getPath()) { + if (reference.getFieldName() != null) { + path.append(path.isEmpty() ? "" : ".").append(reference.getFieldName()); + } else { + path.append('[').append(reference.getIndex()).append(']'); + } + } + return path.isEmpty() ? "value" : path.toString(); + } + private static String describe(MethodArgumentTypeMismatchException mistyped) { Class wanted = mistyped.getRequiredType(); boolean numeric = wanted == Integer.class || wanted == Long.class; diff --git a/server/opensharing-server-core/src/test/java/io/opensharing/http/ApiFailureTest.java b/server/opensharing-server-core/src/test/java/io/opensharing/http/ApiFailureTest.java index 736a4b4f6..d618346b4 100644 --- a/server/opensharing-server-core/src/test/java/io/opensharing/http/ApiFailureTest.java +++ b/server/opensharing-server-core/src/test/java/io/opensharing/http/ApiFailureTest.java @@ -1,7 +1,10 @@ package io.opensharing.http; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.exc.InvalidFormatException; import io.opensharing.auth.UserContext; import io.opensharing.catalog.Asset; import io.opensharing.catalog.AssetType; @@ -9,9 +12,12 @@ import io.opensharing.exception.AssetNotFoundException; import io.opensharing.exception.CatalogException; import io.opensharing.exception.UnsupportedAssetTypeException; +import java.util.List; import org.junit.jupiter.api.Test; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.mock.http.MockHttpInputMessage; import org.springframework.web.servlet.resource.NoResourceFoundException; class ApiFailureTest { @@ -59,4 +65,34 @@ void keepsAnExplicitApiException() { assertEquals(ErrorCodes.UNAUTHENTICATED, failure.errorCode()); assertEquals("token is missing", failure.message()); } + + @Test + void mapsUnknownEnumValuesAsInvalidParameters() { + InvalidFormatException invalid = + assertThrows( + InvalidFormatException.class, + () -> + new ObjectMapper() + .readValue("{\"items\":[{\"type\":\"VIEW\"}]}", Body.class)); + ApiFailure failure = ApiFailure.of(unreadable(invalid)); + assertEquals(HttpStatus.BAD_REQUEST, failure.status()); + assertEquals(ErrorCodes.INVALID_PARAMETER_VALUE, failure.errorCode()); + assertEquals("items[0].type must be one of [TABLE, SCHEMA]", failure.message()); + } + + @Test + void mapsOtherUnreadableBodiesAsMalformed() { + ApiFailure failure = ApiFailure.of(unreadable(null)); + assertEquals(HttpStatus.BAD_REQUEST, failure.status()); + assertEquals(ErrorCodes.MALFORMED_REQUEST, failure.errorCode()); + } + + private static HttpMessageNotReadableException unreadable(Throwable cause) { + return new HttpMessageNotReadableException( + "unreadable", cause, new MockHttpInputMessage(new byte[0])); + } + + record Body(List items) {} + + record Item(AssetType type) {} } From 0b02672fe92bbd4d3edc1541b206ca25d5b99353 Mon Sep 17 00:00:00 2001 From: Sun Cao Date: Mon, 5 Oct 2026 15:57:07 -0700 Subject: [PATCH 05/12] Construct Asset directly now that Asset.of is gone. --- .../src/test/java/io/opensharing/http/ApiFailureTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/opensharing-server-core/src/test/java/io/opensharing/http/ApiFailureTest.java b/server/opensharing-server-core/src/test/java/io/opensharing/http/ApiFailureTest.java index d618346b4..b29d1fb30 100644 --- a/server/opensharing-server-core/src/test/java/io/opensharing/http/ApiFailureTest.java +++ b/server/opensharing-server-core/src/test/java/io/opensharing/http/ApiFailureTest.java @@ -24,7 +24,7 @@ class ApiFailureTest { @Test void mapsCatalogMissesAndDenials() { - Asset lookup = Asset.of(AssetType.TABLE, "main.sales.missing"); + Asset lookup = new Asset(AssetType.TABLE, "main.sales.missing"); ApiFailure missing = ApiFailure.of(new AssetNotFoundException(lookup)); assertEquals(HttpStatus.NOT_FOUND, missing.status()); assertEquals(ErrorCodes.RESOURCE_DOES_NOT_EXIST, missing.errorCode()); From 6fd83f226057412c173f272976991f98ad3979ad Mon Sep 17 00:00:00 2001 From: Sun Cao Date: Tue, 6 Oct 2026 11:49:09 -0700 Subject: [PATCH 06/12] Keep HTTP statuses as ints in core and map Spring MVC failures in the standalone server. --- .../io/opensharing/http/ApiException.java | 22 ++-- .../java/io/opensharing/http/ApiFailure.java | 109 ++++++------------ .../http/GlobalExceptionHandler.java | 17 --- .../io/opensharing/http/ApiFailureTest.java | 44 +++---- .../http/GlobalExceptionHandler.java | 61 ++++++++++ .../http/GlobalExceptionHandlerTest.java | 33 ++++++ 6 files changed, 160 insertions(+), 126 deletions(-) delete mode 100644 server/opensharing-server-core/src/main/java/io/opensharing/http/GlobalExceptionHandler.java create mode 100644 server/opensharing-server/src/main/java/io/opensharing/http/GlobalExceptionHandler.java diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/http/ApiException.java b/server/opensharing-server-core/src/main/java/io/opensharing/http/ApiException.java index 71a7fb199..f294bab62 100644 --- a/server/opensharing-server-core/src/main/java/io/opensharing/http/ApiException.java +++ b/server/opensharing-server-core/src/main/java/io/opensharing/http/ApiException.java @@ -1,20 +1,18 @@ package io.opensharing.http; -import org.springframework.http.HttpStatus; - /** An error with an explicit protocol error code and HTTP status. */ public class ApiException extends RuntimeException { - private final HttpStatus status; + private final int status; private final String errorCode; - public ApiException(HttpStatus status, String errorCode, String message) { + public ApiException(int status, String errorCode, String message) { super(message); this.status = status; this.errorCode = errorCode; } - public HttpStatus getStatus() { + public int getStatus() { return status; } @@ -23,30 +21,30 @@ public String getErrorCode() { } public static ApiException notFound(String message) { - return new ApiException(HttpStatus.NOT_FOUND, ErrorCodes.RESOURCE_DOES_NOT_EXIST, message); + return new ApiException(404, ErrorCodes.RESOURCE_DOES_NOT_EXIST, message); } public static ApiException alreadyExists(String message) { - return new ApiException(HttpStatus.CONFLICT, ErrorCodes.RESOURCE_ALREADY_EXISTS, message); + return new ApiException(409, ErrorCodes.RESOURCE_ALREADY_EXISTS, message); } public static ApiException conflict(String message) { - return new ApiException(HttpStatus.CONFLICT, ErrorCodes.RESOURCE_CONFLICT, message); + return new ApiException(409, ErrorCodes.RESOURCE_CONFLICT, message); } public static ApiException invalidParameter(String message) { - return new ApiException(HttpStatus.BAD_REQUEST, ErrorCodes.INVALID_PARAMETER_VALUE, message); + return new ApiException(400, ErrorCodes.INVALID_PARAMETER_VALUE, message); } public static ApiException permissionDenied(String message) { - return new ApiException(HttpStatus.FORBIDDEN, ErrorCodes.PERMISSION_DENIED, message); + return new ApiException(403, ErrorCodes.PERMISSION_DENIED, message); } public static ApiException unauthenticated(String message) { - return new ApiException(HttpStatus.UNAUTHORIZED, ErrorCodes.UNAUTHENTICATED, message); + return new ApiException(401, ErrorCodes.UNAUTHENTICATED, message); } public static ApiException notImplemented(String message) { - return new ApiException(HttpStatus.NOT_IMPLEMENTED, ErrorCodes.NOT_IMPLEMENTED, message); + return new ApiException(501, ErrorCodes.NOT_IMPLEMENTED, message); } } diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/http/ApiFailure.java b/server/opensharing-server-core/src/main/java/io/opensharing/http/ApiFailure.java index c2a6f9b40..c1fdcf36c 100644 --- a/server/opensharing-server-core/src/main/java/io/opensharing/http/ApiFailure.java +++ b/server/opensharing-server-core/src/main/java/io/opensharing/http/ApiFailure.java @@ -1,5 +1,6 @@ package io.opensharing.http; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.exc.InvalidFormatException; import io.opensharing.exception.AssetAccessDeniedException; @@ -7,20 +8,16 @@ import io.opensharing.exception.CatalogAuthenticationException; import io.opensharing.exception.CatalogException; import io.opensharing.exception.UnsupportedAssetTypeException; +import java.sql.SQLException; import java.util.Arrays; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.dao.DataIntegrityViolationException; -import org.springframework.http.HttpStatus; -import org.springframework.http.converter.HttpMessageNotReadableException; -import org.springframework.web.bind.MethodArgumentNotValidException; -import org.springframework.web.bind.MissingServletRequestParameterException; -import org.springframework.web.method.annotation.HandlerMethodValidationException; -import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; -import org.springframework.web.servlet.resource.NoResourceFoundException; -/** Maps a failure to an HTTP status, protocol error code, and caller-facing message. */ -public record ApiFailure(HttpStatus status, String errorCode, String message) { +/** + * Maps a failure to an HTTP status, protocol error code, and caller-facing message. Hosts map + * their own framework's failures first and pass everything else here. + */ +public record ApiFailure(int status, String errorCode, String message) { private static final Logger log = LoggerFactory.getLogger(ApiFailure.class); @@ -29,89 +26,52 @@ public static ApiFailure of(Exception e) { case ApiException api -> new ApiFailure(api.getStatus(), api.getErrorCode(), api.getMessage()); case AssetNotFoundException missing -> - new ApiFailure( - HttpStatus.NOT_FOUND, ErrorCodes.RESOURCE_DOES_NOT_EXIST, missing.getMessage()); + new ApiFailure(404, ErrorCodes.RESOURCE_DOES_NOT_EXIST, missing.getMessage()); case AssetAccessDeniedException denied -> - new ApiFailure(HttpStatus.FORBIDDEN, ErrorCodes.PERMISSION_DENIED, denied.getMessage()); + new ApiFailure(403, ErrorCodes.PERMISSION_DENIED, denied.getMessage()); case UnsupportedAssetTypeException unsupported -> - new ApiFailure( - HttpStatus.BAD_REQUEST, - ErrorCodes.INVALID_PARAMETER_VALUE, - unsupported.getMessage()); + new ApiFailure(400, ErrorCodes.INVALID_PARAMETER_VALUE, unsupported.getMessage()); case CatalogAuthenticationException rejected -> { log.error("The sharing server could not authenticate to the catalog", rejected); yield new ApiFailure( - HttpStatus.BAD_GATEWAY, + 502, ErrorCodes.CATALOG_ERROR, "the sharing server could not authenticate to the catalog"); } case CatalogException failed -> { log.error("Catalog request failed", failed); - yield new ApiFailure( - HttpStatus.BAD_GATEWAY, ErrorCodes.CATALOG_ERROR, failed.getMessage()); - } - case MethodArgumentNotValidException invalid -> - new ApiFailure( - HttpStatus.BAD_REQUEST, ErrorCodes.INVALID_PARAMETER_VALUE, describe(invalid)); - case HandlerMethodValidationException ignored -> validationFailed(); - case MethodArgumentTypeMismatchException mistyped -> - new ApiFailure( - HttpStatus.BAD_REQUEST, ErrorCodes.INVALID_PARAMETER_VALUE, describe(mistyped)); - case MissingServletRequestParameterException missing -> - new ApiFailure( - HttpStatus.BAD_REQUEST, - ErrorCodes.INVALID_PARAMETER_VALUE, - missing.getParameterName() + " is required"); - case HttpMessageNotReadableException unreadable -> describe(unreadable); - case DataIntegrityViolationException conflict -> { - log.debug("Rejected request that violated a uniqueness constraint", conflict); - yield new ApiFailure( - HttpStatus.CONFLICT, - ErrorCodes.RESOURCE_ALREADY_EXISTS, - "the object already exists or conflicts with an existing object"); + yield new ApiFailure(502, ErrorCodes.CATALOG_ERROR, failed.getMessage()); } - case NoResourceFoundException ignored -> - new ApiFailure( - HttpStatus.NOT_FOUND, - ErrorCodes.RESOURCE_DOES_NOT_EXIST, - "the endpoint does not exist"); + case JsonProcessingException unreadable -> describe(unreadable); case IllegalArgumentException illegal -> - new ApiFailure( - HttpStatus.BAD_REQUEST, ErrorCodes.INVALID_PARAMETER_VALUE, illegal.getMessage()); + new ApiFailure(400, ErrorCodes.INVALID_PARAMETER_VALUE, illegal.getMessage()); default -> { + if (violatesConstraint(e)) { + log.debug("Rejected request that violated a uniqueness constraint", e); + yield new ApiFailure( + 409, + ErrorCodes.RESOURCE_ALREADY_EXISTS, + "the object already exists or conflicts with an existing object"); + } log.error("Unhandled server error", e); - yield new ApiFailure( - HttpStatus.INTERNAL_SERVER_ERROR, ErrorCodes.INTERNAL_ERROR, "internal server error"); + yield new ApiFailure(500, ErrorCodes.INTERNAL_ERROR, "internal server error"); } }; } - private static ApiFailure validationFailed() { - return new ApiFailure( - HttpStatus.BAD_REQUEST, ErrorCodes.INVALID_PARAMETER_VALUE, "request validation failed"); - } - - private static String describe(MethodArgumentNotValidException invalid) { - return invalid.getBindingResult().getFieldErrors().stream() - .findFirst() - .map(error -> error.getField() + " " + error.getDefaultMessage()) - .orElse("request validation failed"); - } - /** An unknown enum value is a bad parameter, not a malformed body. */ - private static ApiFailure describe(HttpMessageNotReadableException unreadable) { - if (unreadable.getCause() instanceof InvalidFormatException invalid + private static ApiFailure describe(JsonProcessingException unreadable) { + if (unreadable instanceof InvalidFormatException invalid && invalid.getTargetType() != null && invalid.getTargetType().isEnum()) { return new ApiFailure( - HttpStatus.BAD_REQUEST, + 400, ErrorCodes.INVALID_PARAMETER_VALUE, fieldPath(invalid) + " must be one of " + Arrays.toString(invalid.getTargetType().getEnumConstants())); } - return new ApiFailure( - HttpStatus.BAD_REQUEST, ErrorCodes.MALFORMED_REQUEST, "request body is malformed"); + return new ApiFailure(400, ErrorCodes.MALFORMED_REQUEST, "request body is malformed"); } private static String fieldPath(InvalidFormatException invalid) { @@ -126,11 +86,16 @@ private static String fieldPath(InvalidFormatException invalid) { return path.isEmpty() ? "value" : path.toString(); } - private static String describe(MethodArgumentTypeMismatchException mistyped) { - Class wanted = mistyped.getRequiredType(); - boolean numeric = wanted == Integer.class || wanted == Long.class; - return numeric - ? mistyped.getName() + " must be a number" - : mistyped.getName() + " is not a valid value"; + // SQLSTATE class 23 is an integrity constraint violation in every database. Hosts wrap it in + // their own persistence exceptions, so look for it anywhere in the cause chain. + private static boolean violatesConstraint(Throwable e) { + for (Throwable cause = e; cause != null; cause = cause.getCause()) { + if (cause instanceof SQLException sql + && sql.getSQLState() != null + && sql.getSQLState().startsWith("23")) { + return true; + } + } + return false; } } diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/http/GlobalExceptionHandler.java b/server/opensharing-server-core/src/main/java/io/opensharing/http/GlobalExceptionHandler.java deleted file mode 100644 index daf2590ae..000000000 --- a/server/opensharing-server-core/src/main/java/io/opensharing/http/GlobalExceptionHandler.java +++ /dev/null @@ -1,17 +0,0 @@ -package io.opensharing.http; - -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.RestControllerAdvice; - -/** Renders every failure as {@code {errorCode, message}}. */ -@RestControllerAdvice -public class GlobalExceptionHandler { - - @ExceptionHandler(Exception.class) - public ResponseEntity handle(Exception e) { - ApiFailure failure = ApiFailure.of(e); - return ResponseEntity.status(failure.status()) - .body(new ErrorResponse(failure.errorCode(), failure.message())); - } -} diff --git a/server/opensharing-server-core/src/test/java/io/opensharing/http/ApiFailureTest.java b/server/opensharing-server-core/src/test/java/io/opensharing/http/ApiFailureTest.java index b29d1fb30..74dc74689 100644 --- a/server/opensharing-server-core/src/test/java/io/opensharing/http/ApiFailureTest.java +++ b/server/opensharing-server-core/src/test/java/io/opensharing/http/ApiFailureTest.java @@ -12,13 +12,10 @@ import io.opensharing.exception.AssetNotFoundException; import io.opensharing.exception.CatalogException; import io.opensharing.exception.UnsupportedAssetTypeException; +import com.fasterxml.jackson.core.JsonProcessingException; +import java.sql.SQLException; import java.util.List; import org.junit.jupiter.api.Test; -import org.springframework.http.HttpMethod; -import org.springframework.http.HttpStatus; -import org.springframework.http.converter.HttpMessageNotReadableException; -import org.springframework.mock.http.MockHttpInputMessage; -import org.springframework.web.servlet.resource.NoResourceFoundException; class ApiFailureTest { @@ -26,19 +23,19 @@ class ApiFailureTest { void mapsCatalogMissesAndDenials() { Asset lookup = new Asset(AssetType.TABLE, "main.sales.missing"); ApiFailure missing = ApiFailure.of(new AssetNotFoundException(lookup)); - assertEquals(HttpStatus.NOT_FOUND, missing.status()); + assertEquals(404, missing.status()); assertEquals(ErrorCodes.RESOURCE_DOES_NOT_EXIST, missing.errorCode()); UserContext bob = UserContext.fromUserIdAndName("bob@example.com", "bob@example.com"); ApiFailure denied = ApiFailure.of(new AssetAccessDeniedException(lookup, bob)); - assertEquals(HttpStatus.FORBIDDEN, denied.status()); + assertEquals(403, denied.status()); assertEquals(ErrorCodes.PERMISSION_DENIED, denied.errorCode()); } @Test void mapsCatalogFailuresAsBadGateway() { ApiFailure failure = ApiFailure.of(new CatalogException("catalog unreachable")); - assertEquals(HttpStatus.BAD_GATEWAY, failure.status()); + assertEquals(502, failure.status()); assertEquals(ErrorCodes.CATALOG_ERROR, failure.errorCode()); assertEquals("catalog unreachable", failure.message()); } @@ -46,22 +43,14 @@ void mapsCatalogFailuresAsBadGateway() { @Test void mapsUnsupportedAssetTypesAsBadRequest() { ApiFailure failure = ApiFailure.of(new UnsupportedAssetTypeException("not a schema")); - assertEquals(HttpStatus.BAD_REQUEST, failure.status()); + assertEquals(400, failure.status()); assertEquals(ErrorCodes.INVALID_PARAMETER_VALUE, failure.errorCode()); } - @Test - void mapsUnknownRoutes() { - ApiFailure failure = ApiFailure.of(new NoResourceFoundException(HttpMethod.GET, "/missing")); - assertEquals(HttpStatus.NOT_FOUND, failure.status()); - assertEquals(ErrorCodes.RESOURCE_DOES_NOT_EXIST, failure.errorCode()); - assertEquals("the endpoint does not exist", failure.message()); - } - @Test void keepsAnExplicitApiException() { ApiFailure failure = ApiFailure.of(ApiException.unauthenticated("token is missing")); - assertEquals(HttpStatus.UNAUTHORIZED, failure.status()); + assertEquals(401, failure.status()); assertEquals(ErrorCodes.UNAUTHENTICATED, failure.errorCode()); assertEquals("token is missing", failure.message()); } @@ -74,22 +63,27 @@ void mapsUnknownEnumValuesAsInvalidParameters() { () -> new ObjectMapper() .readValue("{\"items\":[{\"type\":\"VIEW\"}]}", Body.class)); - ApiFailure failure = ApiFailure.of(unreadable(invalid)); - assertEquals(HttpStatus.BAD_REQUEST, failure.status()); + ApiFailure failure = ApiFailure.of(invalid); + assertEquals(400, failure.status()); assertEquals(ErrorCodes.INVALID_PARAMETER_VALUE, failure.errorCode()); assertEquals("items[0].type must be one of [TABLE, SCHEMA]", failure.message()); } @Test void mapsOtherUnreadableBodiesAsMalformed() { - ApiFailure failure = ApiFailure.of(unreadable(null)); - assertEquals(HttpStatus.BAD_REQUEST, failure.status()); + JsonProcessingException malformed = + assertThrows(JsonProcessingException.class, () -> new ObjectMapper().readTree("{")); + ApiFailure failure = ApiFailure.of(malformed); + assertEquals(400, failure.status()); assertEquals(ErrorCodes.MALFORMED_REQUEST, failure.errorCode()); } - private static HttpMessageNotReadableException unreadable(Throwable cause) { - return new HttpMessageNotReadableException( - "unreadable", cause, new MockHttpInputMessage(new byte[0])); + @Test + void mapsConstraintViolationsAsAlreadyExists() { + SQLException duplicate = new SQLException("duplicate key", "23505"); + ApiFailure failure = ApiFailure.of(new RuntimeException("commit failed", duplicate)); + assertEquals(409, failure.status()); + assertEquals(ErrorCodes.RESOURCE_ALREADY_EXISTS, failure.errorCode()); } record Body(List items) {} diff --git a/server/opensharing-server/src/main/java/io/opensharing/http/GlobalExceptionHandler.java b/server/opensharing-server/src/main/java/io/opensharing/http/GlobalExceptionHandler.java new file mode 100644 index 000000000..b1b0c052e --- /dev/null +++ b/server/opensharing-server/src/main/java/io/opensharing/http/GlobalExceptionHandler.java @@ -0,0 +1,61 @@ +package io.opensharing.http; + +import com.fasterxml.jackson.core.JsonProcessingException; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.HandlerMethodValidationException; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.servlet.resource.NoResourceFoundException; + +/** Renders every failure as {@code {errorCode, message}}. */ +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(Exception.class) + public ResponseEntity handle(Exception e) { + ApiFailure failure = failureOf(e); + return ResponseEntity.status(failure.status()) + .body(new ErrorResponse(failure.errorCode(), failure.message())); + } + + /** Maps Spring MVC's own failures; everything else is mapped by {@link ApiFailure}. */ + static ApiFailure failureOf(Exception e) { + return switch (e) { + case MethodArgumentNotValidException invalid -> + new ApiFailure(400, ErrorCodes.INVALID_PARAMETER_VALUE, describe(invalid)); + case HandlerMethodValidationException ignored -> + new ApiFailure(400, ErrorCodes.INVALID_PARAMETER_VALUE, "request validation failed"); + case MethodArgumentTypeMismatchException mistyped -> + new ApiFailure(400, ErrorCodes.INVALID_PARAMETER_VALUE, describe(mistyped)); + case MissingServletRequestParameterException missing -> + new ApiFailure( + 400, ErrorCodes.INVALID_PARAMETER_VALUE, missing.getParameterName() + " is required"); + case HttpMessageNotReadableException unreadable -> + unreadable.getCause() instanceof JsonProcessingException json + ? ApiFailure.of(json) + : new ApiFailure(400, ErrorCodes.MALFORMED_REQUEST, "request body is malformed"); + case NoResourceFoundException ignored -> + new ApiFailure(404, ErrorCodes.RESOURCE_DOES_NOT_EXIST, "the endpoint does not exist"); + default -> ApiFailure.of(e); + }; + } + + private static String describe(MethodArgumentNotValidException invalid) { + return invalid.getBindingResult().getFieldErrors().stream() + .findFirst() + .map(error -> error.getField() + " " + error.getDefaultMessage()) + .orElse("request validation failed"); + } + + private static String describe(MethodArgumentTypeMismatchException mistyped) { + Class wanted = mistyped.getRequiredType(); + boolean numeric = wanted == Integer.class || wanted == Long.class; + return numeric + ? mistyped.getName() + " must be a number" + : mistyped.getName() + " is not a valid value"; + } +} diff --git a/server/opensharing-server/src/test/java/io/opensharing/http/GlobalExceptionHandlerTest.java b/server/opensharing-server/src/test/java/io/opensharing/http/GlobalExceptionHandlerTest.java index 182b6b25e..825b17a42 100644 --- a/server/opensharing-server/src/test/java/io/opensharing/http/GlobalExceptionHandlerTest.java +++ b/server/opensharing-server/src/test/java/io/opensharing/http/GlobalExceptionHandlerTest.java @@ -1,13 +1,20 @@ package io.opensharing.http; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.exc.InvalidFormatException; +import io.opensharing.catalog.AssetType; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.mock.http.MockHttpInputMessage; import org.springframework.test.web.servlet.MockMvc; @SpringBootTest( @@ -28,4 +35,30 @@ void answersUnknownRoutesWithTheProtocolErrorBody() throws Exception { .andExpect(jsonPath("$.errorCode").value(ErrorCodes.RESOURCE_DOES_NOT_EXIST)) .andExpect(jsonPath("$.message").value("the endpoint does not exist")); } + + @Test + void mapsUnknownEnumValuesInABodyAsInvalidParameters() { + InvalidFormatException invalid = + assertThrows( + InvalidFormatException.class, + () -> new ObjectMapper().readValue("{\"type\":\"VIEW\"}", Body.class)); + ApiFailure failure = GlobalExceptionHandler.failureOf(unreadable(invalid)); + assertEquals(400, failure.status()); + assertEquals(ErrorCodes.INVALID_PARAMETER_VALUE, failure.errorCode()); + assertEquals("type must be one of [TABLE, SCHEMA]", failure.message()); + } + + @Test + void mapsOtherUnreadableBodiesAsMalformed() { + ApiFailure failure = GlobalExceptionHandler.failureOf(unreadable(null)); + assertEquals(400, failure.status()); + assertEquals(ErrorCodes.MALFORMED_REQUEST, failure.errorCode()); + } + + private static HttpMessageNotReadableException unreadable(Throwable cause) { + return new HttpMessageNotReadableException( + "unreadable", cause, new MockHttpInputMessage(new byte[0])); + } + + record Body(AssetType type) {} } From 6774d69a26c6f08df83e77f3fb9a4b1eb987395e Mon Sep 17 00:00:00 2001 From: Sun Cao Date: Tue, 15 Sep 2026 12:31:37 -0700 Subject: [PATCH 07/12] Gate standalone catalog wiring on hosting mode so hosts can embed OpenSharing. OpenSharing.embedded() registers the host CatalogConnector; CatalogConfiguration loads only in standalone mode. --- .../runtime/ConditionalOnHostingMode.java | 18 ++++ .../runtime/DefaultSharingRuntime.java | 24 ++++++ .../runtime/EmbeddedStartupValidator.java | 28 +++++++ .../io/opensharing/runtime/HostingMode.java | 17 ++++ .../runtime/HostingRuntimeConfiguration.java | 16 ++++ .../runtime/OnHostingModeCondition.java | 23 +++++ .../io/opensharing/runtime/OpenSharing.java | 83 +++++++++++++++++++ .../opensharing/runtime/SharingRuntime.java | 11 +++ .../catalog/StubCatalogConnector.java | 27 ++++++ .../OpenSharingEmbeddedBuilderTest.java | 36 ++++++++ .../io/opensharing/OpenSharingServer.java | 4 +- .../config/CatalogConfiguration.java | 5 +- .../config/OpenSharingProperties.java | 19 +++++ .../CatalogConfigurationEmbeddedTest.java | 65 +++++++++++++++ .../opensharing/runtime/HostingModeTest.java | 28 +++++++ 15 files changed, 401 insertions(+), 3 deletions(-) create mode 100644 server/opensharing-server-core/src/main/java/io/opensharing/runtime/ConditionalOnHostingMode.java create mode 100644 server/opensharing-server-core/src/main/java/io/opensharing/runtime/DefaultSharingRuntime.java create mode 100644 server/opensharing-server-core/src/main/java/io/opensharing/runtime/EmbeddedStartupValidator.java create mode 100644 server/opensharing-server-core/src/main/java/io/opensharing/runtime/HostingMode.java create mode 100644 server/opensharing-server-core/src/main/java/io/opensharing/runtime/HostingRuntimeConfiguration.java create mode 100644 server/opensharing-server-core/src/main/java/io/opensharing/runtime/OnHostingModeCondition.java create mode 100644 server/opensharing-server-core/src/main/java/io/opensharing/runtime/OpenSharing.java create mode 100644 server/opensharing-server-core/src/main/java/io/opensharing/runtime/SharingRuntime.java create mode 100644 server/opensharing-server-core/src/test/java/io/opensharing/catalog/StubCatalogConnector.java create mode 100644 server/opensharing-server-core/src/test/java/io/opensharing/runtime/OpenSharingEmbeddedBuilderTest.java create mode 100644 server/opensharing-server/src/test/java/io/opensharing/config/CatalogConfigurationEmbeddedTest.java create mode 100644 server/opensharing-server/src/test/java/io/opensharing/runtime/HostingModeTest.java diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/runtime/ConditionalOnHostingMode.java b/server/opensharing-server-core/src/main/java/io/opensharing/runtime/ConditionalOnHostingMode.java new file mode 100644 index 000000000..fc3d33967 --- /dev/null +++ b/server/opensharing-server-core/src/main/java/io/opensharing/runtime/ConditionalOnHostingMode.java @@ -0,0 +1,18 @@ +package io.opensharing.runtime; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.springframework.context.annotation.Conditional; + +/** Loads a bean only when {@code opensharing.hosting.mode} matches. */ +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Conditional(OnHostingModeCondition.class) +public @interface ConditionalOnHostingMode { + + HostingMode value(); +} diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/runtime/DefaultSharingRuntime.java b/server/opensharing-server-core/src/main/java/io/opensharing/runtime/DefaultSharingRuntime.java new file mode 100644 index 000000000..bc956f43d --- /dev/null +++ b/server/opensharing-server-core/src/main/java/io/opensharing/runtime/DefaultSharingRuntime.java @@ -0,0 +1,24 @@ +package io.opensharing.runtime; + +import io.opensharing.catalog.CatalogConnector; + +final class DefaultSharingRuntime implements SharingRuntime { + + private final HostingMode hostingMode; + private final CatalogConnector catalogConnector; + + DefaultSharingRuntime(HostingMode hostingMode, CatalogConnector catalogConnector) { + this.hostingMode = hostingMode; + this.catalogConnector = catalogConnector; + } + + @Override + public HostingMode hostingMode() { + return hostingMode; + } + + @Override + public CatalogConnector catalogConnector() { + return catalogConnector; + } +} diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/runtime/EmbeddedStartupValidator.java b/server/opensharing-server-core/src/main/java/io/opensharing/runtime/EmbeddedStartupValidator.java new file mode 100644 index 000000000..d7c617b87 --- /dev/null +++ b/server/opensharing-server-core/src/main/java/io/opensharing/runtime/EmbeddedStartupValidator.java @@ -0,0 +1,28 @@ +package io.opensharing.runtime; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +/** Logs the host-supplied catalog when OpenSharing is embedded. */ +@Component +@ConditionalOnHostingMode(HostingMode.EMBEDDED) +class EmbeddedStartupValidator implements ApplicationRunner { + + private static final Logger log = LoggerFactory.getLogger(EmbeddedStartupValidator.class); + + private final SharingRuntime runtime; + + EmbeddedStartupValidator(SharingRuntime runtime) { + this.runtime = runtime; + } + + @Override + public void run(ApplicationArguments args) { + log.info( + "OpenSharing is embedded; using '{}' catalog connector from the host", + runtime.catalogConnector().name()); + } +} diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/runtime/HostingMode.java b/server/opensharing-server-core/src/main/java/io/opensharing/runtime/HostingMode.java new file mode 100644 index 000000000..0a3f4b616 --- /dev/null +++ b/server/opensharing-server-core/src/main/java/io/opensharing/runtime/HostingMode.java @@ -0,0 +1,17 @@ +package io.opensharing.runtime; + +import io.opensharing.catalog.CatalogConnector; + +/** Whether OpenSharing runs as its own process or inside a host catalog. */ +public enum HostingMode { + /** + * A standalone Spring Boot server. {@code opensharing.catalog.*} selects the catalog connector + * shipped with the reference server. + */ + STANDALONE, + /** + * Embedded in a host process. The host registers a {@link CatalogConnector} bean rather than + * using the standalone catalog configuration. + */ + EMBEDDED +} diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/runtime/HostingRuntimeConfiguration.java b/server/opensharing-server-core/src/main/java/io/opensharing/runtime/HostingRuntimeConfiguration.java new file mode 100644 index 000000000..874ddcce4 --- /dev/null +++ b/server/opensharing-server-core/src/main/java/io/opensharing/runtime/HostingRuntimeConfiguration.java @@ -0,0 +1,16 @@ +package io.opensharing.runtime; + +import io.opensharing.catalog.CatalogConnector; +import io.opensharing.config.OpenSharingProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +class HostingRuntimeConfiguration { + + @Bean + SharingRuntime sharingRuntime(OpenSharingProperties properties, CatalogConnector catalogConnector) { + HostingMode mode = properties.getHosting().getMode(); + return new DefaultSharingRuntime(mode == null ? HostingMode.STANDALONE : mode, catalogConnector); + } +} diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/runtime/OnHostingModeCondition.java b/server/opensharing-server-core/src/main/java/io/opensharing/runtime/OnHostingModeCondition.java new file mode 100644 index 000000000..22d045b58 --- /dev/null +++ b/server/opensharing-server-core/src/main/java/io/opensharing/runtime/OnHostingModeCondition.java @@ -0,0 +1,23 @@ +package io.opensharing.runtime; + +import java.util.Locale; +import java.util.Map; +import org.springframework.context.annotation.Condition; +import org.springframework.context.annotation.ConditionContext; +import org.springframework.core.type.AnnotatedTypeMetadata; + +final class OnHostingModeCondition implements Condition { + + @Override + public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) { + Map attributes = + metadata.getAnnotationAttributes(ConditionalOnHostingMode.class.getName()); + if (attributes == null) { + return false; + } + HostingMode required = (HostingMode) attributes.get("value"); + String raw = context.getEnvironment().getProperty("opensharing.hosting.mode", "standalone"); + HostingMode actual = HostingMode.valueOf(raw.trim().toUpperCase(Locale.ROOT)); + return required == actual; + } +} diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/runtime/OpenSharing.java b/server/opensharing-server-core/src/main/java/io/opensharing/runtime/OpenSharing.java new file mode 100644 index 000000000..8a5ab7cd1 --- /dev/null +++ b/server/opensharing-server-core/src/main/java/io/opensharing/runtime/OpenSharing.java @@ -0,0 +1,83 @@ +package io.opensharing.runtime; + +import io.opensharing.OpenSharingApplication; +import io.opensharing.catalog.CatalogConnector; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import org.springframework.beans.factory.support.DefaultListableBeanFactory; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.context.ApplicationContextInitializer; +import org.springframework.context.ConfigurableApplicationContext; + +/** Library entry point for running OpenSharing standalone or embedding it in a host process. */ +public final class OpenSharing { + + private OpenSharing() {} + + /** Starts the reference server in standalone mode (default deployment). */ + public static ConfigurableApplicationContext runStandalone(String... args) { + return SpringApplication.run(OpenSharingApplication.class, args); + } + + /** Configures an embedded assembly the host starts inside its own process. */ + public static EmbeddedBuilder embedded() { + return new EmbeddedBuilder(); + } + + public static final class EmbeddedBuilder { + + private CatalogConnector catalog; + private final Map properties = new LinkedHashMap<>(); + + private EmbeddedBuilder() {} + + /** In-process catalog integration supplied by the host (required). */ + public EmbeddedBuilder catalog(CatalogConnector catalog) { + this.catalog = catalog; + return this; + } + + public EmbeddedBuilder property(String key, Object value) { + properties.put(key, value); + return this; + } + + public ConfigurableApplicationContext run(String... args) { + if (catalog == null) { + throw new IllegalStateException( + "embedded OpenSharing requires a CatalogConnector from the host"); + } + Map merged = new LinkedHashMap<>(properties); + merged.put("opensharing.hosting.mode", "embedded"); + ArrayList allArgs = new ArrayList<>(); + for (Map.Entry entry : merged.entrySet()) { + allArgs.add("--" + entry.getKey() + "=" + entry.getValue()); + } + Collections.addAll(allArgs, args); + return new SpringApplicationBuilder(OpenSharingApplication.class) + .properties(merged) + .initializers(new EmbeddedInitializer(catalog)) + .run(allArgs.toArray(String[]::new)); + } + } + + private static final class EmbeddedInitializer + implements ApplicationContextInitializer { + + private final CatalogConnector catalog; + + private EmbeddedInitializer(CatalogConnector catalog) { + this.catalog = catalog; + } + + @Override + public void initialize(ConfigurableApplicationContext context) { + DefaultListableBeanFactory beanFactory = (DefaultListableBeanFactory) context.getBeanFactory(); + beanFactory.registerSingleton("catalogConnector", catalog); + beanFactory.registerResolvableDependency(CatalogConnector.class, catalog); + } + } +} diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/runtime/SharingRuntime.java b/server/opensharing-server-core/src/main/java/io/opensharing/runtime/SharingRuntime.java new file mode 100644 index 000000000..78a6a33ba --- /dev/null +++ b/server/opensharing-server-core/src/main/java/io/opensharing/runtime/SharingRuntime.java @@ -0,0 +1,11 @@ +package io.opensharing.runtime; + +import io.opensharing.catalog.CatalogConnector; + +/** Read-only view of how this OpenSharing assembly is hosted. */ +public interface SharingRuntime { + + HostingMode hostingMode(); + + CatalogConnector catalogConnector(); +} diff --git a/server/opensharing-server-core/src/test/java/io/opensharing/catalog/StubCatalogConnector.java b/server/opensharing-server-core/src/test/java/io/opensharing/catalog/StubCatalogConnector.java new file mode 100644 index 000000000..37152bc44 --- /dev/null +++ b/server/opensharing-server-core/src/test/java/io/opensharing/catalog/StubCatalogConnector.java @@ -0,0 +1,27 @@ +package io.opensharing.catalog; + +import java.util.List; + +/** Minimal catalog for embedded-mode tests in the core module. */ +public final class StubCatalogConnector implements CatalogConnector { + + public static final StubCatalogConnector INSTANCE = new StubCatalogConnector(); + + private StubCatalogConnector() {} + + @Override + public String name() { + return "stub"; + } + + @Override + public ResolvedAsset resolveAsset(AssetLookup lookup, CatalogCaller caller) { + throw new UnsupportedOperationException("stub catalog"); + } + + @Override + public List getStorageCredentials( + CredentialRequest request, CatalogCaller caller) { + return List.of(); + } +} diff --git a/server/opensharing-server-core/src/test/java/io/opensharing/runtime/OpenSharingEmbeddedBuilderTest.java b/server/opensharing-server-core/src/test/java/io/opensharing/runtime/OpenSharingEmbeddedBuilderTest.java new file mode 100644 index 000000000..b80201f71 --- /dev/null +++ b/server/opensharing-server-core/src/test/java/io/opensharing/runtime/OpenSharingEmbeddedBuilderTest.java @@ -0,0 +1,36 @@ +package io.opensharing.runtime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import io.opensharing.catalog.StubCatalogConnector; +import org.junit.jupiter.api.Test; +import org.springframework.context.ConfigurableApplicationContext; + +class OpenSharingEmbeddedBuilderTest { + + @Test + void refusesToStartWithoutACatalogConnector() { + assertThrows(IllegalStateException.class, () -> OpenSharing.embedded().run()); + } + + @Test + void startsEmbeddedContextWithHostCatalog() { + ConfigurableApplicationContext context = + OpenSharing.embedded() + .catalog(StubCatalogConnector.INSTANCE) + .property( + "spring.datasource.url", "jdbc:h2:mem:opensharing-embedded-builder;DB_CLOSE_DELAY=-1") + .property("spring.jpa.hibernate.ddl-auto", "create-drop") + .property("server.port", "0") + .property("spring.main.web-application-type", "none") + .run(); + try { + SharingRuntime runtime = context.getBean(SharingRuntime.class); + assertEquals(HostingMode.EMBEDDED, runtime.hostingMode()); + assertEquals("stub", runtime.catalogConnector().name()); + } finally { + context.close(); + } + } +} diff --git a/server/opensharing-server/src/main/java/io/opensharing/OpenSharingServer.java b/server/opensharing-server/src/main/java/io/opensharing/OpenSharingServer.java index bb8c29aac..4d619c229 100644 --- a/server/opensharing-server/src/main/java/io/opensharing/OpenSharingServer.java +++ b/server/opensharing-server/src/main/java/io/opensharing/OpenSharingServer.java @@ -1,6 +1,6 @@ package io.opensharing; -import org.springframework.boot.SpringApplication; +import io.opensharing.runtime.OpenSharing; /** Runnable standalone OpenSharing server. */ public final class OpenSharingServer { @@ -8,6 +8,6 @@ public final class OpenSharingServer { private OpenSharingServer() {} public static void main(String[] args) { - SpringApplication.run(OpenSharingApplication.class, args); + OpenSharing.runStandalone(args); } } diff --git a/server/opensharing-server/src/main/java/io/opensharing/config/CatalogConfiguration.java b/server/opensharing-server/src/main/java/io/opensharing/config/CatalogConfiguration.java index c79fd56e9..4b91e85ad 100644 --- a/server/opensharing-server/src/main/java/io/opensharing/config/CatalogConfiguration.java +++ b/server/opensharing-server/src/main/java/io/opensharing/config/CatalogConfiguration.java @@ -5,6 +5,8 @@ import io.opensharing.catalog.local.LocalCatalogFile; import io.opensharing.catalog.local.LocalCatalogLoader; import io.opensharing.exception.CatalogException; +import io.opensharing.runtime.ConditionalOnHostingMode; +import io.opensharing.runtime.HostingMode; import java.io.IOException; import java.io.InputStream; import java.util.Locale; @@ -14,8 +16,9 @@ import org.springframework.core.io.Resource; import org.springframework.core.io.ResourceLoader; -/** Configures the catalog used by the standalone server. */ +/** Configures the catalog used by the standalone server. Embedded hosts supply their own bean. */ @Configuration +@ConditionalOnHostingMode(HostingMode.STANDALONE) public class CatalogConfiguration { @Bean diff --git a/server/opensharing-server/src/main/java/io/opensharing/config/OpenSharingProperties.java b/server/opensharing-server/src/main/java/io/opensharing/config/OpenSharingProperties.java index ed888de62..fdb30d046 100644 --- a/server/opensharing-server/src/main/java/io/opensharing/config/OpenSharingProperties.java +++ b/server/opensharing-server/src/main/java/io/opensharing/config/OpenSharingProperties.java @@ -1,17 +1,36 @@ package io.opensharing.config; +import io.opensharing.runtime.HostingMode; import org.springframework.boot.context.properties.ConfigurationProperties; /** OpenSharing server configuration. */ @ConfigurationProperties(prefix = "opensharing") public class OpenSharingProperties { + private final Hosting hosting = new Hosting(); private final Catalog catalog = new Catalog(); + public Hosting getHosting() { + return hosting; + } + public Catalog getCatalog() { return catalog; } + public static class Hosting { + + private HostingMode mode = HostingMode.STANDALONE; + + public HostingMode getMode() { + return mode; + } + + public void setMode(HostingMode mode) { + this.mode = mode; + } + } + public static class Catalog { private String type; diff --git a/server/opensharing-server/src/test/java/io/opensharing/config/CatalogConfigurationEmbeddedTest.java b/server/opensharing-server/src/test/java/io/opensharing/config/CatalogConfigurationEmbeddedTest.java new file mode 100644 index 000000000..554f18c91 --- /dev/null +++ b/server/opensharing-server/src/test/java/io/opensharing/config/CatalogConfigurationEmbeddedTest.java @@ -0,0 +1,65 @@ +package io.opensharing.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +import io.opensharing.catalog.AssetLookup; +import io.opensharing.catalog.CatalogCaller; +import io.opensharing.catalog.CatalogConnector; +import io.opensharing.catalog.CredentialRequest; +import io.opensharing.catalog.ResolvedAsset; +import io.opensharing.catalog.StorageCredentials; +import io.opensharing.runtime.HostingMode; +import io.opensharing.runtime.SharingRuntime; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; + +@SpringBootTest( + properties = { + "spring.datasource.url=jdbc:h2:mem:opensharing-embedded-catalog;DB_CLOSE_DELAY=-1", + "opensharing.hosting.mode=embedded", + "opensharing.catalog.type=local", + "opensharing.catalog.local.file=classpath:local-catalog.yml" + }) +class CatalogConfigurationEmbeddedTest { + + @Autowired private SharingRuntime runtime; + @Autowired private ApplicationContext context; + + @Test + void skipsStandaloneCatalogWiringWhenEmbedded() { + assertEquals(HostingMode.EMBEDDED, runtime.hostingMode()); + assertEquals("host", runtime.catalogConnector().name()); + assertFalse(context.containsBean("catalogConfiguration")); + } + + @TestConfiguration + static class HostCatalog { + + @Bean + CatalogConnector catalogConnector() { + return new CatalogConnector() { + @Override + public String name() { + return "host"; + } + + @Override + public ResolvedAsset resolveAsset(AssetLookup lookup, CatalogCaller caller) { + throw new UnsupportedOperationException(); + } + + @Override + public List getStorageCredentials( + CredentialRequest request, CatalogCaller caller) { + return List.of(); + } + }; + } + } +} diff --git a/server/opensharing-server/src/test/java/io/opensharing/runtime/HostingModeTest.java b/server/opensharing-server/src/test/java/io/opensharing/runtime/HostingModeTest.java new file mode 100644 index 000000000..fdfa11a7d --- /dev/null +++ b/server/opensharing-server/src/test/java/io/opensharing/runtime/HostingModeTest.java @@ -0,0 +1,28 @@ +package io.opensharing.runtime; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import io.opensharing.config.OpenSharingProperties; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest( + properties = { + "spring.datasource.url=jdbc:h2:mem:opensharing-hosting;DB_CLOSE_DELAY=-1", + "opensharing.hosting.mode=standalone", + "opensharing.catalog.type=local", + "opensharing.catalog.local.file=classpath:local-catalog.yml" + }) +class HostingModeTest { + + @Autowired private OpenSharingProperties properties; + @Autowired private SharingRuntime runtime; + + @Test + void defaultsToStandaloneHosting() { + assertEquals(HostingMode.STANDALONE, properties.getHosting().getMode()); + assertEquals(HostingMode.STANDALONE, runtime.hostingMode()); + assertEquals("local", runtime.catalogConnector().name()); + } +} From 229368abeec71f9fb55c100a933ce2c075ca4267 Mon Sep 17 00:00:00 2001 From: Sun Cao Date: Tue, 15 Sep 2026 13:48:05 -0700 Subject: [PATCH 08/12] Use LocalCatalogConnector in embedded hosting tests. Drop the unused stub catalog; local testing already has a file-backed connector. --- .../catalog/StubCatalogConnector.java | 27 -------------- .../CatalogConfigurationEmbeddedTest.java | 36 +++++++------------ .../OpenSharingEmbeddedBuilderTest.java | 20 ++++++++--- 3 files changed, 28 insertions(+), 55 deletions(-) delete mode 100644 server/opensharing-server-core/src/test/java/io/opensharing/catalog/StubCatalogConnector.java rename server/{opensharing-server-core => opensharing-server}/src/test/java/io/opensharing/runtime/OpenSharingEmbeddedBuilderTest.java (59%) diff --git a/server/opensharing-server-core/src/test/java/io/opensharing/catalog/StubCatalogConnector.java b/server/opensharing-server-core/src/test/java/io/opensharing/catalog/StubCatalogConnector.java deleted file mode 100644 index 37152bc44..000000000 --- a/server/opensharing-server-core/src/test/java/io/opensharing/catalog/StubCatalogConnector.java +++ /dev/null @@ -1,27 +0,0 @@ -package io.opensharing.catalog; - -import java.util.List; - -/** Minimal catalog for embedded-mode tests in the core module. */ -public final class StubCatalogConnector implements CatalogConnector { - - public static final StubCatalogConnector INSTANCE = new StubCatalogConnector(); - - private StubCatalogConnector() {} - - @Override - public String name() { - return "stub"; - } - - @Override - public ResolvedAsset resolveAsset(AssetLookup lookup, CatalogCaller caller) { - throw new UnsupportedOperationException("stub catalog"); - } - - @Override - public List getStorageCredentials( - CredentialRequest request, CatalogCaller caller) { - return List.of(); - } -} diff --git a/server/opensharing-server/src/test/java/io/opensharing/config/CatalogConfigurationEmbeddedTest.java b/server/opensharing-server/src/test/java/io/opensharing/config/CatalogConfigurationEmbeddedTest.java index 554f18c91..eb66d4cad 100644 --- a/server/opensharing-server/src/test/java/io/opensharing/config/CatalogConfigurationEmbeddedTest.java +++ b/server/opensharing-server/src/test/java/io/opensharing/config/CatalogConfigurationEmbeddedTest.java @@ -3,15 +3,14 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; -import io.opensharing.catalog.AssetLookup; -import io.opensharing.catalog.CatalogCaller; import io.opensharing.catalog.CatalogConnector; -import io.opensharing.catalog.CredentialRequest; -import io.opensharing.catalog.ResolvedAsset; -import io.opensharing.catalog.StorageCredentials; +import io.opensharing.catalog.local.LocalCatalogConnector; +import io.opensharing.catalog.local.LocalCatalogLoader; import io.opensharing.runtime.HostingMode; import io.opensharing.runtime.SharingRuntime; -import java.util.List; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @@ -34,7 +33,7 @@ class CatalogConfigurationEmbeddedTest { @Test void skipsStandaloneCatalogWiringWhenEmbedded() { assertEquals(HostingMode.EMBEDDED, runtime.hostingMode()); - assertEquals("host", runtime.catalogConnector().name()); + assertEquals(LocalCatalogConnector.NAME, runtime.catalogConnector().name()); assertFalse(context.containsBean("catalogConfiguration")); } @@ -43,23 +42,12 @@ static class HostCatalog { @Bean CatalogConnector catalogConnector() { - return new CatalogConnector() { - @Override - public String name() { - return "host"; - } - - @Override - public ResolvedAsset resolveAsset(AssetLookup lookup, CatalogCaller caller) { - throw new UnsupportedOperationException(); - } - - @Override - public List getStorageCredentials( - CredentialRequest request, CatalogCaller caller) { - return List.of(); - } - }; + try (InputStream in = HostCatalog.class.getResourceAsStream("/local-catalog.yml")) { + return new LocalCatalogConnector( + LocalCatalogLoader.load(in, "classpath:local-catalog.yml")); + } catch (IOException e) { + throw new UncheckedIOException(e); + } } } } diff --git a/server/opensharing-server-core/src/test/java/io/opensharing/runtime/OpenSharingEmbeddedBuilderTest.java b/server/opensharing-server/src/test/java/io/opensharing/runtime/OpenSharingEmbeddedBuilderTest.java similarity index 59% rename from server/opensharing-server-core/src/test/java/io/opensharing/runtime/OpenSharingEmbeddedBuilderTest.java rename to server/opensharing-server/src/test/java/io/opensharing/runtime/OpenSharingEmbeddedBuilderTest.java index b80201f71..b3793ef6d 100644 --- a/server/opensharing-server-core/src/test/java/io/opensharing/runtime/OpenSharingEmbeddedBuilderTest.java +++ b/server/opensharing-server/src/test/java/io/opensharing/runtime/OpenSharingEmbeddedBuilderTest.java @@ -3,7 +3,11 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; -import io.opensharing.catalog.StubCatalogConnector; +import io.opensharing.catalog.CatalogConnector; +import io.opensharing.catalog.local.LocalCatalogConnector; +import io.opensharing.catalog.local.LocalCatalogLoader; +import java.io.IOException; +import java.io.InputStream; import org.junit.jupiter.api.Test; import org.springframework.context.ConfigurableApplicationContext; @@ -15,10 +19,10 @@ void refusesToStartWithoutACatalogConnector() { } @Test - void startsEmbeddedContextWithHostCatalog() { + void startsEmbeddedContextWithHostCatalog() throws IOException { ConfigurableApplicationContext context = OpenSharing.embedded() - .catalog(StubCatalogConnector.INSTANCE) + .catalog(localCatalog()) .property( "spring.datasource.url", "jdbc:h2:mem:opensharing-embedded-builder;DB_CLOSE_DELAY=-1") .property("spring.jpa.hibernate.ddl-auto", "create-drop") @@ -28,9 +32,17 @@ void startsEmbeddedContextWithHostCatalog() { try { SharingRuntime runtime = context.getBean(SharingRuntime.class); assertEquals(HostingMode.EMBEDDED, runtime.hostingMode()); - assertEquals("stub", runtime.catalogConnector().name()); + assertEquals(LocalCatalogConnector.NAME, runtime.catalogConnector().name()); } finally { context.close(); } } + + private static CatalogConnector localCatalog() throws IOException { + try (InputStream in = + OpenSharingEmbeddedBuilderTest.class.getResourceAsStream("/local-catalog.yml")) { + return new LocalCatalogConnector( + LocalCatalogLoader.load(in, "classpath:local-catalog.yml")); + } + } } From cf826c4605c858e4070cdc0310c71ac91f21345f Mon Sep 17 00:00:00 2001 From: Sun Cao Date: Tue, 15 Sep 2026 14:14:09 -0700 Subject: [PATCH 09/12] Drop SharingRuntime; read hosting mode and catalog from existing beans. Mode lives on OpenSharingProperties and the catalog is already a CatalogConnector bean. --- .../runtime/DefaultSharingRuntime.java | 24 ------------------- .../runtime/EmbeddedStartupValidator.java | 10 ++++---- .../runtime/HostingRuntimeConfiguration.java | 16 ------------- .../opensharing/runtime/SharingRuntime.java | 11 --------- .../CatalogConfigurationEmbeddedTest.java | 8 +++---- .../opensharing/runtime/HostingModeTest.java | 7 +++--- .../OpenSharingEmbeddedBuilderTest.java | 8 ++++--- 7 files changed, 18 insertions(+), 66 deletions(-) delete mode 100644 server/opensharing-server-core/src/main/java/io/opensharing/runtime/DefaultSharingRuntime.java delete mode 100644 server/opensharing-server-core/src/main/java/io/opensharing/runtime/HostingRuntimeConfiguration.java delete mode 100644 server/opensharing-server-core/src/main/java/io/opensharing/runtime/SharingRuntime.java diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/runtime/DefaultSharingRuntime.java b/server/opensharing-server-core/src/main/java/io/opensharing/runtime/DefaultSharingRuntime.java deleted file mode 100644 index bc956f43d..000000000 --- a/server/opensharing-server-core/src/main/java/io/opensharing/runtime/DefaultSharingRuntime.java +++ /dev/null @@ -1,24 +0,0 @@ -package io.opensharing.runtime; - -import io.opensharing.catalog.CatalogConnector; - -final class DefaultSharingRuntime implements SharingRuntime { - - private final HostingMode hostingMode; - private final CatalogConnector catalogConnector; - - DefaultSharingRuntime(HostingMode hostingMode, CatalogConnector catalogConnector) { - this.hostingMode = hostingMode; - this.catalogConnector = catalogConnector; - } - - @Override - public HostingMode hostingMode() { - return hostingMode; - } - - @Override - public CatalogConnector catalogConnector() { - return catalogConnector; - } -} diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/runtime/EmbeddedStartupValidator.java b/server/opensharing-server-core/src/main/java/io/opensharing/runtime/EmbeddedStartupValidator.java index d7c617b87..17e1e9de3 100644 --- a/server/opensharing-server-core/src/main/java/io/opensharing/runtime/EmbeddedStartupValidator.java +++ b/server/opensharing-server-core/src/main/java/io/opensharing/runtime/EmbeddedStartupValidator.java @@ -1,5 +1,6 @@ package io.opensharing.runtime; +import io.opensharing.catalog.CatalogConnector; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.ApplicationArguments; @@ -13,16 +14,15 @@ class EmbeddedStartupValidator implements ApplicationRunner { private static final Logger log = LoggerFactory.getLogger(EmbeddedStartupValidator.class); - private final SharingRuntime runtime; + private final CatalogConnector catalog; - EmbeddedStartupValidator(SharingRuntime runtime) { - this.runtime = runtime; + EmbeddedStartupValidator(CatalogConnector catalog) { + this.catalog = catalog; } @Override public void run(ApplicationArguments args) { log.info( - "OpenSharing is embedded; using '{}' catalog connector from the host", - runtime.catalogConnector().name()); + "OpenSharing is embedded; using '{}' catalog connector from the host", catalog.name()); } } diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/runtime/HostingRuntimeConfiguration.java b/server/opensharing-server-core/src/main/java/io/opensharing/runtime/HostingRuntimeConfiguration.java deleted file mode 100644 index 874ddcce4..000000000 --- a/server/opensharing-server-core/src/main/java/io/opensharing/runtime/HostingRuntimeConfiguration.java +++ /dev/null @@ -1,16 +0,0 @@ -package io.opensharing.runtime; - -import io.opensharing.catalog.CatalogConnector; -import io.opensharing.config.OpenSharingProperties; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -@Configuration -class HostingRuntimeConfiguration { - - @Bean - SharingRuntime sharingRuntime(OpenSharingProperties properties, CatalogConnector catalogConnector) { - HostingMode mode = properties.getHosting().getMode(); - return new DefaultSharingRuntime(mode == null ? HostingMode.STANDALONE : mode, catalogConnector); - } -} diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/runtime/SharingRuntime.java b/server/opensharing-server-core/src/main/java/io/opensharing/runtime/SharingRuntime.java deleted file mode 100644 index 78a6a33ba..000000000 --- a/server/opensharing-server-core/src/main/java/io/opensharing/runtime/SharingRuntime.java +++ /dev/null @@ -1,11 +0,0 @@ -package io.opensharing.runtime; - -import io.opensharing.catalog.CatalogConnector; - -/** Read-only view of how this OpenSharing assembly is hosted. */ -public interface SharingRuntime { - - HostingMode hostingMode(); - - CatalogConnector catalogConnector(); -} diff --git a/server/opensharing-server/src/test/java/io/opensharing/config/CatalogConfigurationEmbeddedTest.java b/server/opensharing-server/src/test/java/io/opensharing/config/CatalogConfigurationEmbeddedTest.java index eb66d4cad..457f6ac91 100644 --- a/server/opensharing-server/src/test/java/io/opensharing/config/CatalogConfigurationEmbeddedTest.java +++ b/server/opensharing-server/src/test/java/io/opensharing/config/CatalogConfigurationEmbeddedTest.java @@ -7,7 +7,6 @@ import io.opensharing.catalog.local.LocalCatalogConnector; import io.opensharing.catalog.local.LocalCatalogLoader; import io.opensharing.runtime.HostingMode; -import io.opensharing.runtime.SharingRuntime; import java.io.IOException; import java.io.InputStream; import java.io.UncheckedIOException; @@ -27,13 +26,14 @@ }) class CatalogConfigurationEmbeddedTest { - @Autowired private SharingRuntime runtime; + @Autowired private OpenSharingProperties properties; + @Autowired private CatalogConnector catalog; @Autowired private ApplicationContext context; @Test void skipsStandaloneCatalogWiringWhenEmbedded() { - assertEquals(HostingMode.EMBEDDED, runtime.hostingMode()); - assertEquals(LocalCatalogConnector.NAME, runtime.catalogConnector().name()); + assertEquals(HostingMode.EMBEDDED, properties.getHosting().getMode()); + assertEquals(LocalCatalogConnector.NAME, catalog.name()); assertFalse(context.containsBean("catalogConfiguration")); } diff --git a/server/opensharing-server/src/test/java/io/opensharing/runtime/HostingModeTest.java b/server/opensharing-server/src/test/java/io/opensharing/runtime/HostingModeTest.java index fdfa11a7d..ce6e366cd 100644 --- a/server/opensharing-server/src/test/java/io/opensharing/runtime/HostingModeTest.java +++ b/server/opensharing-server/src/test/java/io/opensharing/runtime/HostingModeTest.java @@ -2,6 +2,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals; +import io.opensharing.catalog.CatalogConnector; +import io.opensharing.catalog.local.LocalCatalogConnector; import io.opensharing.config.OpenSharingProperties; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -17,12 +19,11 @@ class HostingModeTest { @Autowired private OpenSharingProperties properties; - @Autowired private SharingRuntime runtime; + @Autowired private CatalogConnector catalog; @Test void defaultsToStandaloneHosting() { assertEquals(HostingMode.STANDALONE, properties.getHosting().getMode()); - assertEquals(HostingMode.STANDALONE, runtime.hostingMode()); - assertEquals("local", runtime.catalogConnector().name()); + assertEquals(LocalCatalogConnector.NAME, catalog.name()); } } diff --git a/server/opensharing-server/src/test/java/io/opensharing/runtime/OpenSharingEmbeddedBuilderTest.java b/server/opensharing-server/src/test/java/io/opensharing/runtime/OpenSharingEmbeddedBuilderTest.java index b3793ef6d..f4846f052 100644 --- a/server/opensharing-server/src/test/java/io/opensharing/runtime/OpenSharingEmbeddedBuilderTest.java +++ b/server/opensharing-server/src/test/java/io/opensharing/runtime/OpenSharingEmbeddedBuilderTest.java @@ -6,6 +6,7 @@ import io.opensharing.catalog.CatalogConnector; import io.opensharing.catalog.local.LocalCatalogConnector; import io.opensharing.catalog.local.LocalCatalogLoader; +import io.opensharing.config.OpenSharingProperties; import java.io.IOException; import java.io.InputStream; import org.junit.jupiter.api.Test; @@ -30,9 +31,10 @@ void startsEmbeddedContextWithHostCatalog() throws IOException { .property("spring.main.web-application-type", "none") .run(); try { - SharingRuntime runtime = context.getBean(SharingRuntime.class); - assertEquals(HostingMode.EMBEDDED, runtime.hostingMode()); - assertEquals(LocalCatalogConnector.NAME, runtime.catalogConnector().name()); + OpenSharingProperties properties = context.getBean(OpenSharingProperties.class); + CatalogConnector catalog = context.getBean(CatalogConnector.class); + assertEquals(HostingMode.EMBEDDED, properties.getHosting().getMode()); + assertEquals(LocalCatalogConnector.NAME, catalog.name()); } finally { context.close(); } From 8ed9cb4faff7c548d0af4e939c518752dcffef3a Mon Sep 17 00:00:00 2001 From: Sun Cao Date: Tue, 15 Sep 2026 14:50:19 -0700 Subject: [PATCH 10/12] Drop custom hosting-mode types that Spring already covers. Gate CatalogConfiguration with ConditionalOnProperty and keep OpenSharing.embedded() as the host entry point. --- .../runtime/ConditionalOnHostingMode.java | 18 ------------ .../runtime/EmbeddedStartupValidator.java | 28 ------------------- .../io/opensharing/runtime/HostingMode.java | 17 ----------- .../runtime/OnHostingModeCondition.java | 23 --------------- .../io/opensharing/runtime/OpenSharing.java | 8 +----- .../io/opensharing/OpenSharingServer.java | 4 +-- .../config/CatalogConfiguration.java | 8 ++++-- .../config/OpenSharingProperties.java | 12 +++++--- .../CatalogConfigurationEmbeddedTest.java | 3 +- .../opensharing/runtime/HostingModeTest.java | 2 +- .../OpenSharingEmbeddedBuilderTest.java | 2 +- 11 files changed, 19 insertions(+), 106 deletions(-) delete mode 100644 server/opensharing-server-core/src/main/java/io/opensharing/runtime/ConditionalOnHostingMode.java delete mode 100644 server/opensharing-server-core/src/main/java/io/opensharing/runtime/EmbeddedStartupValidator.java delete mode 100644 server/opensharing-server-core/src/main/java/io/opensharing/runtime/HostingMode.java delete mode 100644 server/opensharing-server-core/src/main/java/io/opensharing/runtime/OnHostingModeCondition.java diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/runtime/ConditionalOnHostingMode.java b/server/opensharing-server-core/src/main/java/io/opensharing/runtime/ConditionalOnHostingMode.java deleted file mode 100644 index fc3d33967..000000000 --- a/server/opensharing-server-core/src/main/java/io/opensharing/runtime/ConditionalOnHostingMode.java +++ /dev/null @@ -1,18 +0,0 @@ -package io.opensharing.runtime; - -import java.lang.annotation.Documented; -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; -import org.springframework.context.annotation.Conditional; - -/** Loads a bean only when {@code opensharing.hosting.mode} matches. */ -@Target({ElementType.TYPE, ElementType.METHOD}) -@Retention(RetentionPolicy.RUNTIME) -@Documented -@Conditional(OnHostingModeCondition.class) -public @interface ConditionalOnHostingMode { - - HostingMode value(); -} diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/runtime/EmbeddedStartupValidator.java b/server/opensharing-server-core/src/main/java/io/opensharing/runtime/EmbeddedStartupValidator.java deleted file mode 100644 index 17e1e9de3..000000000 --- a/server/opensharing-server-core/src/main/java/io/opensharing/runtime/EmbeddedStartupValidator.java +++ /dev/null @@ -1,28 +0,0 @@ -package io.opensharing.runtime; - -import io.opensharing.catalog.CatalogConnector; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.boot.ApplicationArguments; -import org.springframework.boot.ApplicationRunner; -import org.springframework.stereotype.Component; - -/** Logs the host-supplied catalog when OpenSharing is embedded. */ -@Component -@ConditionalOnHostingMode(HostingMode.EMBEDDED) -class EmbeddedStartupValidator implements ApplicationRunner { - - private static final Logger log = LoggerFactory.getLogger(EmbeddedStartupValidator.class); - - private final CatalogConnector catalog; - - EmbeddedStartupValidator(CatalogConnector catalog) { - this.catalog = catalog; - } - - @Override - public void run(ApplicationArguments args) { - log.info( - "OpenSharing is embedded; using '{}' catalog connector from the host", catalog.name()); - } -} diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/runtime/HostingMode.java b/server/opensharing-server-core/src/main/java/io/opensharing/runtime/HostingMode.java deleted file mode 100644 index 0a3f4b616..000000000 --- a/server/opensharing-server-core/src/main/java/io/opensharing/runtime/HostingMode.java +++ /dev/null @@ -1,17 +0,0 @@ -package io.opensharing.runtime; - -import io.opensharing.catalog.CatalogConnector; - -/** Whether OpenSharing runs as its own process or inside a host catalog. */ -public enum HostingMode { - /** - * A standalone Spring Boot server. {@code opensharing.catalog.*} selects the catalog connector - * shipped with the reference server. - */ - STANDALONE, - /** - * Embedded in a host process. The host registers a {@link CatalogConnector} bean rather than - * using the standalone catalog configuration. - */ - EMBEDDED -} diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/runtime/OnHostingModeCondition.java b/server/opensharing-server-core/src/main/java/io/opensharing/runtime/OnHostingModeCondition.java deleted file mode 100644 index 22d045b58..000000000 --- a/server/opensharing-server-core/src/main/java/io/opensharing/runtime/OnHostingModeCondition.java +++ /dev/null @@ -1,23 +0,0 @@ -package io.opensharing.runtime; - -import java.util.Locale; -import java.util.Map; -import org.springframework.context.annotation.Condition; -import org.springframework.context.annotation.ConditionContext; -import org.springframework.core.type.AnnotatedTypeMetadata; - -final class OnHostingModeCondition implements Condition { - - @Override - public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) { - Map attributes = - metadata.getAnnotationAttributes(ConditionalOnHostingMode.class.getName()); - if (attributes == null) { - return false; - } - HostingMode required = (HostingMode) attributes.get("value"); - String raw = context.getEnvironment().getProperty("opensharing.hosting.mode", "standalone"); - HostingMode actual = HostingMode.valueOf(raw.trim().toUpperCase(Locale.ROOT)); - return required == actual; - } -} diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/runtime/OpenSharing.java b/server/opensharing-server-core/src/main/java/io/opensharing/runtime/OpenSharing.java index 8a5ab7cd1..6c2c0bb72 100644 --- a/server/opensharing-server-core/src/main/java/io/opensharing/runtime/OpenSharing.java +++ b/server/opensharing-server-core/src/main/java/io/opensharing/runtime/OpenSharing.java @@ -7,21 +7,15 @@ import java.util.LinkedHashMap; import java.util.Map; import org.springframework.beans.factory.support.DefaultListableBeanFactory; -import org.springframework.boot.SpringApplication; import org.springframework.boot.builder.SpringApplicationBuilder; import org.springframework.context.ApplicationContextInitializer; import org.springframework.context.ConfigurableApplicationContext; -/** Library entry point for running OpenSharing standalone or embedding it in a host process. */ +/** Starts OpenSharing inside a host process. */ public final class OpenSharing { private OpenSharing() {} - /** Starts the reference server in standalone mode (default deployment). */ - public static ConfigurableApplicationContext runStandalone(String... args) { - return SpringApplication.run(OpenSharingApplication.class, args); - } - /** Configures an embedded assembly the host starts inside its own process. */ public static EmbeddedBuilder embedded() { return new EmbeddedBuilder(); diff --git a/server/opensharing-server/src/main/java/io/opensharing/OpenSharingServer.java b/server/opensharing-server/src/main/java/io/opensharing/OpenSharingServer.java index 4d619c229..bb8c29aac 100644 --- a/server/opensharing-server/src/main/java/io/opensharing/OpenSharingServer.java +++ b/server/opensharing-server/src/main/java/io/opensharing/OpenSharingServer.java @@ -1,6 +1,6 @@ package io.opensharing; -import io.opensharing.runtime.OpenSharing; +import org.springframework.boot.SpringApplication; /** Runnable standalone OpenSharing server. */ public final class OpenSharingServer { @@ -8,6 +8,6 @@ public final class OpenSharingServer { private OpenSharingServer() {} public static void main(String[] args) { - OpenSharing.runStandalone(args); + SpringApplication.run(OpenSharingApplication.class, args); } } diff --git a/server/opensharing-server/src/main/java/io/opensharing/config/CatalogConfiguration.java b/server/opensharing-server/src/main/java/io/opensharing/config/CatalogConfiguration.java index 4b91e85ad..055906dfb 100644 --- a/server/opensharing-server/src/main/java/io/opensharing/config/CatalogConfiguration.java +++ b/server/opensharing-server/src/main/java/io/opensharing/config/CatalogConfiguration.java @@ -5,12 +5,11 @@ import io.opensharing.catalog.local.LocalCatalogFile; import io.opensharing.catalog.local.LocalCatalogLoader; import io.opensharing.exception.CatalogException; -import io.opensharing.runtime.ConditionalOnHostingMode; -import io.opensharing.runtime.HostingMode; import java.io.IOException; import java.io.InputStream; import java.util.Locale; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.io.Resource; @@ -18,7 +17,10 @@ /** Configures the catalog used by the standalone server. Embedded hosts supply their own bean. */ @Configuration -@ConditionalOnHostingMode(HostingMode.STANDALONE) +@ConditionalOnProperty( + name = "opensharing.hosting.mode", + havingValue = "standalone", + matchIfMissing = true) public class CatalogConfiguration { @Bean diff --git a/server/opensharing-server/src/main/java/io/opensharing/config/OpenSharingProperties.java b/server/opensharing-server/src/main/java/io/opensharing/config/OpenSharingProperties.java index fdb30d046..e80b50c35 100644 --- a/server/opensharing-server/src/main/java/io/opensharing/config/OpenSharingProperties.java +++ b/server/opensharing-server/src/main/java/io/opensharing/config/OpenSharingProperties.java @@ -1,6 +1,5 @@ package io.opensharing.config; -import io.opensharing.runtime.HostingMode; import org.springframework.boot.context.properties.ConfigurationProperties; /** OpenSharing server configuration. */ @@ -20,13 +19,18 @@ public Catalog getCatalog() { public static class Hosting { - private HostingMode mode = HostingMode.STANDALONE; + public enum Mode { + STANDALONE, + EMBEDDED + } + + private Mode mode = Mode.STANDALONE; - public HostingMode getMode() { + public Mode getMode() { return mode; } - public void setMode(HostingMode mode) { + public void setMode(Mode mode) { this.mode = mode; } } diff --git a/server/opensharing-server/src/test/java/io/opensharing/config/CatalogConfigurationEmbeddedTest.java b/server/opensharing-server/src/test/java/io/opensharing/config/CatalogConfigurationEmbeddedTest.java index 457f6ac91..5b69ac244 100644 --- a/server/opensharing-server/src/test/java/io/opensharing/config/CatalogConfigurationEmbeddedTest.java +++ b/server/opensharing-server/src/test/java/io/opensharing/config/CatalogConfigurationEmbeddedTest.java @@ -6,7 +6,6 @@ import io.opensharing.catalog.CatalogConnector; import io.opensharing.catalog.local.LocalCatalogConnector; import io.opensharing.catalog.local.LocalCatalogLoader; -import io.opensharing.runtime.HostingMode; import java.io.IOException; import java.io.InputStream; import java.io.UncheckedIOException; @@ -32,7 +31,7 @@ class CatalogConfigurationEmbeddedTest { @Test void skipsStandaloneCatalogWiringWhenEmbedded() { - assertEquals(HostingMode.EMBEDDED, properties.getHosting().getMode()); + assertEquals(OpenSharingProperties.Hosting.Mode.EMBEDDED, properties.getHosting().getMode()); assertEquals(LocalCatalogConnector.NAME, catalog.name()); assertFalse(context.containsBean("catalogConfiguration")); } diff --git a/server/opensharing-server/src/test/java/io/opensharing/runtime/HostingModeTest.java b/server/opensharing-server/src/test/java/io/opensharing/runtime/HostingModeTest.java index ce6e366cd..f0d712efa 100644 --- a/server/opensharing-server/src/test/java/io/opensharing/runtime/HostingModeTest.java +++ b/server/opensharing-server/src/test/java/io/opensharing/runtime/HostingModeTest.java @@ -23,7 +23,7 @@ class HostingModeTest { @Test void defaultsToStandaloneHosting() { - assertEquals(HostingMode.STANDALONE, properties.getHosting().getMode()); + assertEquals(OpenSharingProperties.Hosting.Mode.STANDALONE, properties.getHosting().getMode()); assertEquals(LocalCatalogConnector.NAME, catalog.name()); } } diff --git a/server/opensharing-server/src/test/java/io/opensharing/runtime/OpenSharingEmbeddedBuilderTest.java b/server/opensharing-server/src/test/java/io/opensharing/runtime/OpenSharingEmbeddedBuilderTest.java index f4846f052..837a45bfd 100644 --- a/server/opensharing-server/src/test/java/io/opensharing/runtime/OpenSharingEmbeddedBuilderTest.java +++ b/server/opensharing-server/src/test/java/io/opensharing/runtime/OpenSharingEmbeddedBuilderTest.java @@ -33,7 +33,7 @@ void startsEmbeddedContextWithHostCatalog() throws IOException { try { OpenSharingProperties properties = context.getBean(OpenSharingProperties.class); CatalogConnector catalog = context.getBean(CatalogConnector.class); - assertEquals(HostingMode.EMBEDDED, properties.getHosting().getMode()); + assertEquals(OpenSharingProperties.Hosting.Mode.EMBEDDED, properties.getHosting().getMode()); assertEquals(LocalCatalogConnector.NAME, catalog.name()); } finally { context.close(); From e7199d324ae961e99a0e1df43078e0adcda2ef9d Mon Sep 17 00:00:00 2001 From: Sun Cao Date: Tue, 15 Sep 2026 14:52:44 -0700 Subject: [PATCH 11/12] Keep one standalone catalog test and one embedded builder test. Drop overlapping hosting-mode tests and assert CatalogConfiguration is skipped from OpenSharing.embedded(). --- .../CatalogConfigurationEmbeddedTest.java | 52 ------------------- .../config/CatalogConfigurationTest.java | 3 ++ .../opensharing/runtime/HostingModeTest.java | 29 ----------- .../OpenSharingEmbeddedBuilderTest.java | 2 + 4 files changed, 5 insertions(+), 81 deletions(-) delete mode 100644 server/opensharing-server/src/test/java/io/opensharing/config/CatalogConfigurationEmbeddedTest.java delete mode 100644 server/opensharing-server/src/test/java/io/opensharing/runtime/HostingModeTest.java diff --git a/server/opensharing-server/src/test/java/io/opensharing/config/CatalogConfigurationEmbeddedTest.java b/server/opensharing-server/src/test/java/io/opensharing/config/CatalogConfigurationEmbeddedTest.java deleted file mode 100644 index 5b69ac244..000000000 --- a/server/opensharing-server/src/test/java/io/opensharing/config/CatalogConfigurationEmbeddedTest.java +++ /dev/null @@ -1,52 +0,0 @@ -package io.opensharing.config; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; - -import io.opensharing.catalog.CatalogConnector; -import io.opensharing.catalog.local.LocalCatalogConnector; -import io.opensharing.catalog.local.LocalCatalogLoader; -import java.io.IOException; -import java.io.InputStream; -import java.io.UncheckedIOException; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.context.TestConfiguration; -import org.springframework.context.ApplicationContext; -import org.springframework.context.annotation.Bean; - -@SpringBootTest( - properties = { - "spring.datasource.url=jdbc:h2:mem:opensharing-embedded-catalog;DB_CLOSE_DELAY=-1", - "opensharing.hosting.mode=embedded", - "opensharing.catalog.type=local", - "opensharing.catalog.local.file=classpath:local-catalog.yml" - }) -class CatalogConfigurationEmbeddedTest { - - @Autowired private OpenSharingProperties properties; - @Autowired private CatalogConnector catalog; - @Autowired private ApplicationContext context; - - @Test - void skipsStandaloneCatalogWiringWhenEmbedded() { - assertEquals(OpenSharingProperties.Hosting.Mode.EMBEDDED, properties.getHosting().getMode()); - assertEquals(LocalCatalogConnector.NAME, catalog.name()); - assertFalse(context.containsBean("catalogConfiguration")); - } - - @TestConfiguration - static class HostCatalog { - - @Bean - CatalogConnector catalogConnector() { - try (InputStream in = HostCatalog.class.getResourceAsStream("/local-catalog.yml")) { - return new LocalCatalogConnector( - LocalCatalogLoader.load(in, "classpath:local-catalog.yml")); - } catch (IOException e) { - throw new UncheckedIOException(e); - } - } - } -} diff --git a/server/opensharing-server/src/test/java/io/opensharing/config/CatalogConfigurationTest.java b/server/opensharing-server/src/test/java/io/opensharing/config/CatalogConfigurationTest.java index 6e3298faa..251e4b838 100644 --- a/server/opensharing-server/src/test/java/io/opensharing/config/CatalogConfigurationTest.java +++ b/server/opensharing-server/src/test/java/io/opensharing/config/CatalogConfigurationTest.java @@ -22,9 +22,12 @@ class CatalogConfigurationTest { @Autowired private CatalogConnector catalog; + @Autowired private OpenSharingProperties properties; @Test void loadsTheConfiguredLocalCatalog() { + assertEquals(OpenSharingProperties.Hosting.Mode.STANDALONE, properties.getHosting().getMode()); + UserContext user = UserContext.fromUserIdAndName("alice@example.com", "alice@example.com"); assertEquals("local", catalog.name()); diff --git a/server/opensharing-server/src/test/java/io/opensharing/runtime/HostingModeTest.java b/server/opensharing-server/src/test/java/io/opensharing/runtime/HostingModeTest.java deleted file mode 100644 index f0d712efa..000000000 --- a/server/opensharing-server/src/test/java/io/opensharing/runtime/HostingModeTest.java +++ /dev/null @@ -1,29 +0,0 @@ -package io.opensharing.runtime; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import io.opensharing.catalog.CatalogConnector; -import io.opensharing.catalog.local.LocalCatalogConnector; -import io.opensharing.config.OpenSharingProperties; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest( - properties = { - "spring.datasource.url=jdbc:h2:mem:opensharing-hosting;DB_CLOSE_DELAY=-1", - "opensharing.hosting.mode=standalone", - "opensharing.catalog.type=local", - "opensharing.catalog.local.file=classpath:local-catalog.yml" - }) -class HostingModeTest { - - @Autowired private OpenSharingProperties properties; - @Autowired private CatalogConnector catalog; - - @Test - void defaultsToStandaloneHosting() { - assertEquals(OpenSharingProperties.Hosting.Mode.STANDALONE, properties.getHosting().getMode()); - assertEquals(LocalCatalogConnector.NAME, catalog.name()); - } -} diff --git a/server/opensharing-server/src/test/java/io/opensharing/runtime/OpenSharingEmbeddedBuilderTest.java b/server/opensharing-server/src/test/java/io/opensharing/runtime/OpenSharingEmbeddedBuilderTest.java index 837a45bfd..eca5e47d3 100644 --- a/server/opensharing-server/src/test/java/io/opensharing/runtime/OpenSharingEmbeddedBuilderTest.java +++ b/server/opensharing-server/src/test/java/io/opensharing/runtime/OpenSharingEmbeddedBuilderTest.java @@ -1,6 +1,7 @@ package io.opensharing.runtime; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import io.opensharing.catalog.CatalogConnector; @@ -35,6 +36,7 @@ void startsEmbeddedContextWithHostCatalog() throws IOException { CatalogConnector catalog = context.getBean(CatalogConnector.class); assertEquals(OpenSharingProperties.Hosting.Mode.EMBEDDED, properties.getHosting().getMode()); assertEquals(LocalCatalogConnector.NAME, catalog.name()); + assertFalse(context.containsBean("catalogConfiguration")); } finally { context.close(); } From 35cfea07af222bfb508343352bbf0dfd3b82a7c2 Mon Sep 17 00:00:00 2001 From: Sun Cao Date: Tue, 6 Oct 2026 11:50:04 -0700 Subject: [PATCH 12/12] Build OpenSharing from host-supplied parts instead of booting a second Spring context. --- .../io/opensharing/runtime/OpenSharing.java | 78 ++++++------------- .../opensharing/runtime/OpenSharingTest.java | 30 +++++++ .../config/CatalogConfiguration.java | 7 +- .../config/OpenSharingConfiguration.java | 16 ++++ .../config/OpenSharingProperties.java | 23 ------ .../config/CatalogConfigurationTest.java | 11 ++- .../OpenSharingEmbeddedBuilderTest.java | 52 ------------- 7 files changed, 78 insertions(+), 139 deletions(-) create mode 100644 server/opensharing-server-core/src/test/java/io/opensharing/runtime/OpenSharingTest.java create mode 100644 server/opensharing-server/src/main/java/io/opensharing/config/OpenSharingConfiguration.java delete mode 100644 server/opensharing-server/src/test/java/io/opensharing/runtime/OpenSharingEmbeddedBuilderTest.java diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/runtime/OpenSharing.java b/server/opensharing-server-core/src/main/java/io/opensharing/runtime/OpenSharing.java index 6c2c0bb72..ebd01e44e 100644 --- a/server/opensharing-server-core/src/main/java/io/opensharing/runtime/OpenSharing.java +++ b/server/opensharing-server-core/src/main/java/io/opensharing/runtime/OpenSharing.java @@ -1,77 +1,45 @@ package io.opensharing.runtime; -import io.opensharing.OpenSharingApplication; import io.opensharing.catalog.CatalogConnector; -import java.util.ArrayList; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.Map; -import org.springframework.beans.factory.support.DefaultListableBeanFactory; -import org.springframework.boot.builder.SpringApplicationBuilder; -import org.springframework.context.ApplicationContextInitializer; -import org.springframework.context.ConfigurableApplicationContext; -/** Starts OpenSharing inside a host process. */ +/** + * OpenSharing assembled from what its host supplies. The standalone server builds one from its + * Spring beans. An embedding host, such as Unity Catalog, builds one directly, serves it through + * its own HTTP stack, and authenticates callers itself. + */ public final class OpenSharing { - private OpenSharing() {} + private final CatalogConnector catalog; - /** Configures an embedded assembly the host starts inside its own process. */ - public static EmbeddedBuilder embedded() { - return new EmbeddedBuilder(); + private OpenSharing(CatalogConnector catalog) { + this.catalog = catalog; } - public static final class EmbeddedBuilder { + public static Builder builder() { + return new Builder(); + } + + public CatalogConnector catalog() { + return catalog; + } + + public static final class Builder { private CatalogConnector catalog; - private final Map properties = new LinkedHashMap<>(); - private EmbeddedBuilder() {} + private Builder() {} - /** In-process catalog integration supplied by the host (required). */ - public EmbeddedBuilder catalog(CatalogConnector catalog) { + /** The host's catalog integration (required). */ + public Builder catalog(CatalogConnector catalog) { this.catalog = catalog; return this; } - public EmbeddedBuilder property(String key, Object value) { - properties.put(key, value); - return this; - } - - public ConfigurableApplicationContext run(String... args) { + public OpenSharing build() { if (catalog == null) { - throw new IllegalStateException( - "embedded OpenSharing requires a CatalogConnector from the host"); - } - Map merged = new LinkedHashMap<>(properties); - merged.put("opensharing.hosting.mode", "embedded"); - ArrayList allArgs = new ArrayList<>(); - for (Map.Entry entry : merged.entrySet()) { - allArgs.add("--" + entry.getKey() + "=" + entry.getValue()); + throw new IllegalStateException("OpenSharing requires a CatalogConnector from the host"); } - Collections.addAll(allArgs, args); - return new SpringApplicationBuilder(OpenSharingApplication.class) - .properties(merged) - .initializers(new EmbeddedInitializer(catalog)) - .run(allArgs.toArray(String[]::new)); - } - } - - private static final class EmbeddedInitializer - implements ApplicationContextInitializer { - - private final CatalogConnector catalog; - - private EmbeddedInitializer(CatalogConnector catalog) { - this.catalog = catalog; - } - - @Override - public void initialize(ConfigurableApplicationContext context) { - DefaultListableBeanFactory beanFactory = (DefaultListableBeanFactory) context.getBeanFactory(); - beanFactory.registerSingleton("catalogConnector", catalog); - beanFactory.registerResolvableDependency(CatalogConnector.class, catalog); + return new OpenSharing(catalog); } } } diff --git a/server/opensharing-server-core/src/test/java/io/opensharing/runtime/OpenSharingTest.java b/server/opensharing-server-core/src/test/java/io/opensharing/runtime/OpenSharingTest.java new file mode 100644 index 000000000..e1c4140fe --- /dev/null +++ b/server/opensharing-server-core/src/test/java/io/opensharing/runtime/OpenSharingTest.java @@ -0,0 +1,30 @@ +package io.opensharing.runtime; + +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import io.opensharing.catalog.CatalogConnector; +import java.lang.reflect.Proxy; +import org.junit.jupiter.api.Test; + +class OpenSharingTest { + + @Test + void refusesToBuildWithoutACatalogConnector() { + assertThrows(IllegalStateException.class, () -> OpenSharing.builder().build()); + } + + @Test + void buildsWithTheHostCatalog() { + CatalogConnector catalog = + (CatalogConnector) + Proxy.newProxyInstance( + CatalogConnector.class.getClassLoader(), + new Class[] {CatalogConnector.class}, + (proxy, method, args) -> { + throw new UnsupportedOperationException(); + }); + + assertSame(catalog, OpenSharing.builder().catalog(catalog).build().catalog()); + } +} diff --git a/server/opensharing-server/src/main/java/io/opensharing/config/CatalogConfiguration.java b/server/opensharing-server/src/main/java/io/opensharing/config/CatalogConfiguration.java index 055906dfb..c79fd56e9 100644 --- a/server/opensharing-server/src/main/java/io/opensharing/config/CatalogConfiguration.java +++ b/server/opensharing-server/src/main/java/io/opensharing/config/CatalogConfiguration.java @@ -9,18 +9,13 @@ import java.io.InputStream; import java.util.Locale; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.io.Resource; import org.springframework.core.io.ResourceLoader; -/** Configures the catalog used by the standalone server. Embedded hosts supply their own bean. */ +/** Configures the catalog used by the standalone server. */ @Configuration -@ConditionalOnProperty( - name = "opensharing.hosting.mode", - havingValue = "standalone", - matchIfMissing = true) public class CatalogConfiguration { @Bean diff --git a/server/opensharing-server/src/main/java/io/opensharing/config/OpenSharingConfiguration.java b/server/opensharing-server/src/main/java/io/opensharing/config/OpenSharingConfiguration.java new file mode 100644 index 000000000..72138065d --- /dev/null +++ b/server/opensharing-server/src/main/java/io/opensharing/config/OpenSharingConfiguration.java @@ -0,0 +1,16 @@ +package io.opensharing.config; + +import io.opensharing.catalog.CatalogConnector; +import io.opensharing.runtime.OpenSharing; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** Builds the standalone server's {@link OpenSharing} from its Spring beans. */ +@Configuration +public class OpenSharingConfiguration { + + @Bean + public OpenSharing openSharing(CatalogConnector catalog) { + return OpenSharing.builder().catalog(catalog).build(); + } +} diff --git a/server/opensharing-server/src/main/java/io/opensharing/config/OpenSharingProperties.java b/server/opensharing-server/src/main/java/io/opensharing/config/OpenSharingProperties.java index e80b50c35..ed888de62 100644 --- a/server/opensharing-server/src/main/java/io/opensharing/config/OpenSharingProperties.java +++ b/server/opensharing-server/src/main/java/io/opensharing/config/OpenSharingProperties.java @@ -6,35 +6,12 @@ @ConfigurationProperties(prefix = "opensharing") public class OpenSharingProperties { - private final Hosting hosting = new Hosting(); private final Catalog catalog = new Catalog(); - public Hosting getHosting() { - return hosting; - } - public Catalog getCatalog() { return catalog; } - public static class Hosting { - - public enum Mode { - STANDALONE, - EMBEDDED - } - - private Mode mode = Mode.STANDALONE; - - public Mode getMode() { - return mode; - } - - public void setMode(Mode mode) { - this.mode = mode; - } - } - public static class Catalog { private String type; diff --git a/server/opensharing-server/src/test/java/io/opensharing/config/CatalogConfigurationTest.java b/server/opensharing-server/src/test/java/io/opensharing/config/CatalogConfigurationTest.java index 251e4b838..6f03adcde 100644 --- a/server/opensharing-server/src/test/java/io/opensharing/config/CatalogConfigurationTest.java +++ b/server/opensharing-server/src/test/java/io/opensharing/config/CatalogConfigurationTest.java @@ -1,6 +1,7 @@ package io.opensharing.config; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; import io.opensharing.auth.AuthContext; import io.opensharing.auth.UserContext; @@ -9,6 +10,7 @@ import io.opensharing.catalog.CatalogConnector; import io.opensharing.catalog.DataSourceFormat; import io.opensharing.catalog.TableProperties; +import io.opensharing.runtime.OpenSharing; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @@ -22,12 +24,10 @@ class CatalogConfigurationTest { @Autowired private CatalogConnector catalog; - @Autowired private OpenSharingProperties properties; + @Autowired private OpenSharing openSharing; @Test void loadsTheConfiguredLocalCatalog() { - assertEquals(OpenSharingProperties.Hosting.Mode.STANDALONE, properties.getHosting().getMode()); - UserContext user = UserContext.fromUserIdAndName("alice@example.com", "alice@example.com"); assertEquals("local", catalog.name()); @@ -38,4 +38,9 @@ void loadsTheConfiguredLocalCatalog() { .additionalProperties(); assertEquals(DataSourceFormat.DELTA, table.dataSourceFormat()); } + + @Test + void buildsOpenSharingWithTheConfiguredCatalog() { + assertSame(catalog, openSharing.catalog()); + } } diff --git a/server/opensharing-server/src/test/java/io/opensharing/runtime/OpenSharingEmbeddedBuilderTest.java b/server/opensharing-server/src/test/java/io/opensharing/runtime/OpenSharingEmbeddedBuilderTest.java deleted file mode 100644 index eca5e47d3..000000000 --- a/server/opensharing-server/src/test/java/io/opensharing/runtime/OpenSharingEmbeddedBuilderTest.java +++ /dev/null @@ -1,52 +0,0 @@ -package io.opensharing.runtime; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import io.opensharing.catalog.CatalogConnector; -import io.opensharing.catalog.local.LocalCatalogConnector; -import io.opensharing.catalog.local.LocalCatalogLoader; -import io.opensharing.config.OpenSharingProperties; -import java.io.IOException; -import java.io.InputStream; -import org.junit.jupiter.api.Test; -import org.springframework.context.ConfigurableApplicationContext; - -class OpenSharingEmbeddedBuilderTest { - - @Test - void refusesToStartWithoutACatalogConnector() { - assertThrows(IllegalStateException.class, () -> OpenSharing.embedded().run()); - } - - @Test - void startsEmbeddedContextWithHostCatalog() throws IOException { - ConfigurableApplicationContext context = - OpenSharing.embedded() - .catalog(localCatalog()) - .property( - "spring.datasource.url", "jdbc:h2:mem:opensharing-embedded-builder;DB_CLOSE_DELAY=-1") - .property("spring.jpa.hibernate.ddl-auto", "create-drop") - .property("server.port", "0") - .property("spring.main.web-application-type", "none") - .run(); - try { - OpenSharingProperties properties = context.getBean(OpenSharingProperties.class); - CatalogConnector catalog = context.getBean(CatalogConnector.class); - assertEquals(OpenSharingProperties.Hosting.Mode.EMBEDDED, properties.getHosting().getMode()); - assertEquals(LocalCatalogConnector.NAME, catalog.name()); - assertFalse(context.containsBean("catalogConfiguration")); - } finally { - context.close(); - } - } - - private static CatalogConnector localCatalog() throws IOException { - try (InputStream in = - OpenSharingEmbeddedBuilderTest.class.getResourceAsStream("/local-catalog.yml")) { - return new LocalCatalogConnector( - LocalCatalogLoader.load(in, "classpath:local-catalog.yml")); - } - } -}