-
Notifications
You must be signed in to change notification settings - Fork 80
[views] Add /v1 views REST contract, controller, handler and validation #694
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
2243f54
b08778d
a033058
ed622f9
f31921a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| package com.linkedin.openhouse.common.audit; | ||
|
|
||
| import com.google.gson.JsonElement; | ||
| import javax.servlet.http.HttpServletRequest; | ||
|
|
||
| /** | ||
| * Extension point that removes sensitive values from a request payload before {@link | ||
| * ServiceAuditAspect} writes it into a {@link | ||
| * com.linkedin.openhouse.common.audit.model.ServiceAuditEvent}. | ||
| * | ||
| * <p>The aspect audits the complete cached request body of every controller call, so a route whose | ||
| * body carries content that must not be retained has to opt out here. Only the mechanism lives in | ||
| * {@code services/common}: each service contributes its own beans and names its own fields, and a | ||
| * service that contributes none has its payload audited exactly as before. | ||
| * | ||
| * <p>Implementations are handed the parsed payload and must not mutate it. Returning a modified | ||
| * copy keeps a redactor from observing another redactor's half-rewritten tree, and keeps the | ||
| * decision to drop the payload entirely with the aspect. | ||
| */ | ||
| public interface ServiceAuditPayloadRedactor { | ||
|
|
||
| /** | ||
| * Marker written in place of a redacted value. Implementations replace the value and keep the | ||
| * key, so an auditor can still see that the field was present in the request. | ||
| */ | ||
| String REDACTED_VALUE = "[REDACTED]"; | ||
|
|
||
| /** @return whether this redactor owns {@code request}. */ | ||
| boolean appliesTo(HttpServletRequest request); | ||
|
|
||
| /** | ||
| * @param requestPayload the parsed request body, which may be any {@link JsonElement} the caller | ||
| * sent, including a non-object or {@link com.google.gson.JsonNull}. | ||
| * @return a redacted copy, or the argument unchanged when there is nothing to redact. | ||
| */ | ||
| JsonElement redact(JsonElement requestPayload); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| package com.linkedin.openhouse.common.exception; | ||
|
|
||
| import org.springframework.http.HttpStatus; | ||
|
|
||
| /** | ||
| * Base class for exceptions that already know the HTTP status they should map to. | ||
| * | ||
| * <p>This is deliberately a bare seam. It carries no error-code vocabulary of its own: a subclass | ||
| * in a downstream service owns whatever taxonomy it needs and reduces that taxonomy to an {@link | ||
| * HttpStatus} here. That keeps {@code services/common} free of any service-specific enum while | ||
| * still letting {@link com.linkedin.openhouse.common.exception.handler.OpenHouseExceptionHandler} | ||
| * map the exception to a response with a single handler. | ||
| * | ||
| * <p>The status is the only thing that reaches the wire. The error body shape is unchanged, so no | ||
| * subclass taxonomy is serialized. | ||
| */ | ||
| public abstract class CodedApiException extends RuntimeException { | ||
|
|
||
| protected CodedApiException(String message) { | ||
| super(message); | ||
| } | ||
|
|
||
| protected CodedApiException(String message, Throwable cause) { | ||
| super(message, cause); | ||
| } | ||
|
|
||
| /** @return the HTTP status this failure maps to. */ | ||
| public abstract HttpStatus getHttpStatus(); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,7 @@ | |
| import com.fasterxml.jackson.databind.exc.InvalidFormatException; | ||
| import com.linkedin.openhouse.common.api.spec.ErrorResponseBody; | ||
| import com.linkedin.openhouse.common.exception.AlreadyExistsException; | ||
| import com.linkedin.openhouse.common.exception.CodedApiException; | ||
| import com.linkedin.openhouse.common.exception.EntityConcurrentModificationException; | ||
| import com.linkedin.openhouse.common.exception.InvalidSchemaEvolutionException; | ||
| import com.linkedin.openhouse.common.exception.InvalidTableMetadataException; | ||
|
|
@@ -100,6 +101,30 @@ protected ResponseEntity<ErrorResponseBody> handleToggleException( | |
| return buildResponseEntity(errorResponseBody); | ||
| } | ||
|
|
||
| /** | ||
| * Generic mapping for any exception that already knows its own HTTP status. The status comes from | ||
| * {@link CodedApiException#getHttpStatus()}; the body is the existing unchanged {@link | ||
| * ErrorResponseBody}, so no service-specific error taxonomy reaches the wire. | ||
| * | ||
| * <p>Spring selects the most specific handler, so declaring this does not change the mapping of | ||
| * any exception already handled above. | ||
| */ | ||
| @Hidden | ||
| @ExceptionHandler(CodedApiException.class) | ||
| protected ResponseEntity<ErrorResponseBody> handleCodedApiException( | ||
| CodedApiException codedApiException) { | ||
| HttpStatus httpStatus = codedApiException.getHttpStatus(); | ||
| ErrorResponseBody errorResponseBody = | ||
| ErrorResponseBody.builder() | ||
| .status(httpStatus) | ||
| .error(httpStatus.getReasonPhrase()) | ||
| .message(codedApiException.getMessage()) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [iceberg-rest][blocking] Preserve the Iceberg REST error model The pinned Please preserve a stable public error type and numeric code in the REST envelope. The internal
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fair point, and I want to be straight that this is a deliberate gap rather than an oversight:
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. To put a point on this this, we get errors like: IO errors reading table metadata are interpreted as raw 500 errors vs 503. I would preserve the errors rather than using the same RuntimeException pattern so we can cleanly delineate. |
||
| .stacktrace(getAbbreviatedStackTrace(codedApiException)) | ||
| .cause(getExceptionCause(codedApiException)) | ||
| .build(); | ||
| return buildResponseEntity(errorResponseBody); | ||
| } | ||
|
|
||
| /** | ||
| * To customize behavior of handling {@link NoHandlerFoundException} one cannot rely on {@link | ||
| * ExceptionHandler} as that causes ambiguity with {@link ResponseEntityExceptionHandler} and | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,125 @@ | ||
| package com.linkedin.openhouse.common.api.validator; | ||
|
|
||
| import static com.linkedin.openhouse.common.api.validator.ValidatorConstants.ALPHA_NUM_UNDERSCORE_ERROR_MSG; | ||
|
|
||
| import java.util.ArrayList; | ||
| import java.util.Arrays; | ||
| import java.util.Collections; | ||
| import java.util.Iterator; | ||
| import java.util.LinkedHashSet; | ||
| import java.util.List; | ||
| import java.util.Set; | ||
| import javax.validation.ConstraintViolation; | ||
| import javax.validation.Path; | ||
| import javax.validation.Validator; | ||
| import org.junit.jupiter.api.Assertions; | ||
| import org.junit.jupiter.api.Test; | ||
| import org.mockito.Mockito; | ||
|
|
||
| public class ApiValidatorUtilTest { | ||
|
|
||
| /** Stand-in root bean, only used for the simple name that {@code getField} derives. */ | ||
| private static final class SampleRequestBody {} | ||
|
|
||
| @Test | ||
| public void testValidateIdentifierAcceptsLegalIdentifier() { | ||
| List<String> validationFailures = new ArrayList<>(); | ||
| ApiValidatorUtil.validateIdentifier("databaseId", "db_1", validationFailures); | ||
| Assertions.assertEquals(Collections.emptyList(), validationFailures); | ||
| } | ||
|
|
||
| @Test | ||
| public void testValidateIdentifierRejectsEmpty() { | ||
| List<String> validationFailures = new ArrayList<>(); | ||
| ApiValidatorUtil.validateIdentifier("databaseId", "", validationFailures); | ||
| Assertions.assertEquals( | ||
| Collections.singletonList("databaseId : Cannot be empty"), validationFailures); | ||
| } | ||
|
|
||
| @Test | ||
| public void testValidateIdentifierRejectsNull() { | ||
| List<String> validationFailures = new ArrayList<>(); | ||
| ApiValidatorUtil.validateIdentifier("tableId", null, validationFailures); | ||
| Assertions.assertEquals( | ||
| Collections.singletonList("tableId : Cannot be empty"), validationFailures); | ||
| } | ||
|
|
||
| @Test | ||
| public void testValidateIdentifierRejectsIllegalCharacters() { | ||
| List<String> validationFailures = new ArrayList<>(); | ||
| ApiValidatorUtil.validateIdentifier("tableId", "t#1", validationFailures); | ||
| Assertions.assertEquals( | ||
| Collections.singletonList("tableId : provided t#1, " + ALPHA_NUM_UNDERSCORE_ERROR_MSG), | ||
| validationFailures); | ||
| } | ||
|
|
||
| @Test | ||
| public void testValidateIdentifierAppendsWithoutClearing() { | ||
| List<String> validationFailures = new ArrayList<>(); | ||
| validationFailures.add("pre-existing failure"); | ||
| ApiValidatorUtil.validateIdentifier("databaseId", "d$b", validationFailures); | ||
| Assertions.assertEquals( | ||
| Arrays.asList( | ||
| "pre-existing failure", "databaseId : provided d$b, " + ALPHA_NUM_UNDERSCORE_ERROR_MSG), | ||
| validationFailures); | ||
| } | ||
|
|
||
| @Test | ||
| public void testCollectViolationsFormatsEachViolation() { | ||
| SampleRequestBody requestBody = new SampleRequestBody(); | ||
| Set<ConstraintViolation<SampleRequestBody>> violations = new LinkedHashSet<>(); | ||
| violations.add(violation(requestBody, "tableId", "must not be empty")); | ||
| violations.add(violation(requestBody, "", "must be a consistent request")); | ||
|
|
||
| Validator validator = Mockito.mock(Validator.class); | ||
| Mockito.when(validator.validate(requestBody)).thenReturn(violations); | ||
|
|
||
| List<String> validationFailures = new ArrayList<>(); | ||
| validationFailures.add("pre-existing failure"); | ||
| ApiValidatorUtil.collectViolations(validator, requestBody, validationFailures); | ||
|
|
||
| Assertions.assertEquals( | ||
| Arrays.asList( | ||
| "pre-existing failure", | ||
| "SampleRequestBody.tableId : must not be empty", | ||
| "SampleRequestBody : must be a consistent request"), | ||
| validationFailures); | ||
| } | ||
|
|
||
| @Test | ||
| public void testCollectViolationsAddsNothingWhenBeanIsValid() { | ||
| SampleRequestBody requestBody = new SampleRequestBody(); | ||
| Validator validator = Mockito.mock(Validator.class); | ||
| Mockito.when(validator.validate(requestBody)).thenReturn(Collections.emptySet()); | ||
|
|
||
| List<String> validationFailures = new ArrayList<>(); | ||
| ApiValidatorUtil.collectViolations(validator, requestBody, validationFailures); | ||
|
|
||
| Assertions.assertEquals(Collections.emptyList(), validationFailures); | ||
| } | ||
|
|
||
| @SuppressWarnings("unchecked") | ||
| private static ConstraintViolation<SampleRequestBody> violation( | ||
| SampleRequestBody rootBean, String propertyPath, String message) { | ||
| ConstraintViolation<SampleRequestBody> violation = Mockito.mock(ConstraintViolation.class); | ||
| Mockito.when(violation.getRootBean()).thenReturn(rootBean); | ||
| Mockito.when(violation.getPropertyPath()).thenReturn(path(propertyPath)); | ||
| Mockito.when(violation.getMessage()).thenReturn(message); | ||
| return violation; | ||
| } | ||
|
|
||
| /** {@code getField} only reads the string form of a path, so an empty node list is enough. */ | ||
| private static Path path(String value) { | ||
| return new Path() { | ||
| @Override | ||
| public Iterator<Node> iterator() { | ||
| return Collections.<Node>emptyList().iterator(); | ||
| } | ||
|
|
||
| @Override | ||
| public String toString() { | ||
| return value; | ||
| } | ||
| }; | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[code-smells][blocking] Make the new API failure hierarchy checked
CodedApiExceptionextendsRuntimeException, so every expected view failure is unchecked.Validation throws
ViewRequestValidationFailureException, the disabled service throwsViewApiException, and none of the validator, service, or handler interfaces declares eitherfailure.
ViewRequestValidationFailureException.requireBadRequestadds another uncheckedIllegalArgumentException, whileViewApiExceptionaccepts a nullableerrorCodethat is laterdereferenced during exception handling.
Make the domain hierarchy checked and declare the specific failures through the internal
interfaces. Replace the
IllegalArgumentExceptionguard with a type that can represent only validvalidation codes, and keep
errorCoderequired. Translate the checked exception once inOpenHouseExceptionHandler. The legacy table API's runtime-exception convention is not a defense;this new
/v2surface is the strangler boundary.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Both specific defects are real and are fixed in a033058:
ViewApiExceptionaccepted a nullableerrorCodethatgetHttpStatus()dereferenced, so a null would have NPE'd inside the exception handler and surfaced as a 500 rather than the intended status. It's nowObjects.requireNonNullin both constructors.requireBadRequestpolicing the code range withIllegalArgumentExceptionis gone.ViewRequestValidationFailureExceptionnow takes a type that can only hold the three 400-class codes, so the guard is unnecessary rather than relocated.On making the whole hierarchy checked I'd push back for now.
CodedApiException extends RuntimeExceptionmatches every existing exception inOpenHouseExceptionHandler, and making views the only checked one forks the shared handler and the validator utilities this PR just consolidated. There's also no domain layer behindViewsServiceyet — it's a stub — so declaring checked failures through those interfaces would be declaring them through a seam with no implementation. Worth doing as a service-wide change with a sequencing story; happy to help scope that.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As discussed, the error handling from openhouse routinely mishandles errors. OpenhouseExceptionHandler is an area we can improve.