com.yahoo.elide.jsonapi.JsonApi Maven / Gradle / Ivy
The newest version!
/*
* Copyright 2023, the original author or authors.
* Licensed under the Apache License, Version 2.0
* See LICENSE file in project root for terms.
*/
package com.yahoo.elide.jsonapi;
import com.yahoo.elide.Elide;
import com.yahoo.elide.ElideResponse;
import com.yahoo.elide.ElideSettings;
import com.yahoo.elide.RefreshableElide;
import com.yahoo.elide.core.TransactionRegistry;
import com.yahoo.elide.core.audit.AuditLogger;
import com.yahoo.elide.core.datastore.DataStore;
import com.yahoo.elide.core.datastore.DataStoreTransaction;
import com.yahoo.elide.core.exceptions.BadRequestException;
import com.yahoo.elide.core.exceptions.HttpStatus;
import com.yahoo.elide.core.request.route.Route;
import com.yahoo.elide.core.security.User;
import com.yahoo.elide.jsonapi.extensions.JsonApiAtomicOperations;
import com.yahoo.elide.jsonapi.extensions.JsonApiAtomicOperationsRequestScope;
import com.yahoo.elide.jsonapi.extensions.JsonApiJsonPatch;
import com.yahoo.elide.jsonapi.extensions.JsonApiJsonPatchRequestScope;
import com.yahoo.elide.jsonapi.models.JsonApiDocument;
import com.yahoo.elide.jsonapi.parser.BaseVisitor;
import com.yahoo.elide.jsonapi.parser.DeleteVisitor;
import com.yahoo.elide.jsonapi.parser.GetVisitor;
import com.yahoo.elide.jsonapi.parser.JsonApiParser;
import com.yahoo.elide.jsonapi.parser.PatchVisitor;
import com.yahoo.elide.jsonapi.parser.PostVisitor;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.tuple.Pair;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import java.io.IOException;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.function.Supplier;
import java.util.stream.Collectors;
/**
* JSON:API.
*/
@Slf4j
public class JsonApi {
@Getter
private final Elide elide;
private final ElideSettings elideSettings;
private final JsonApiSettings jsonApiSettings;
private final DataStore dataStore;
private final JsonApiMapper mapper;
private final TransactionRegistry transactionRegistry;
private final AuditLogger auditLogger;
private final JsonApiExceptionHandler jsonApiExceptionHandler;
private final boolean strictQueryParameters;
public JsonApi(RefreshableElide refreshableElide) {
this(refreshableElide.getElide());
}
public JsonApi(Elide elide) {
this.elide = elide;
this.jsonApiSettings = elide.getSettings(JsonApiSettings.class);
this.strictQueryParameters = this.jsonApiSettings.isStrictQueryParameters();
this.mapper = this.jsonApiSettings.getJsonApiMapper();
this.dataStore = this.elide.getDataStore();
this.elideSettings = this.elide.getElideSettings();
this.transactionRegistry = this.elide.getTransactionRegistry();
this.auditLogger = this.elide.getAuditLogger();
this.jsonApiExceptionHandler = this.jsonApiSettings.getJsonApiExceptionHandler();
}
/**
* Handle GET.
*
* @param route the route
* @param opaqueUser the opaque user
* @param requestId the request ID
* @return Elide response object
*/
public ElideResponse get(Route route, User opaqueUser,
UUID requestId) {
UUID requestUuid = requestId != null ? requestId : UUID.randomUUID();
if (strictQueryParameters) {
try {
verifyQueryParams(route.getParameters());
} catch (BadRequestException e) {
JsonApiErrorContext errorContext = JsonApiErrorContext.builder().mapper(this.mapper).verbose(false)
.build();
ElideResponse> errorResponse = jsonApiExceptionHandler.handleException(e, errorContext);
return toResponse(errorResponse.getStatus(), errorResponse.getBody());
}
}
return handleRequest(true, opaqueUser, dataStore::beginReadTransaction, requestUuid, (tx, user) -> {
JsonApiDocument jsonApiDoc = new JsonApiDocument();
JsonApiRequestScope requestScope = JsonApiRequestScope.builder().route(route).dataStoreTransaction(tx)
.user(user).requestId(requestUuid).elideSettings(elideSettings).jsonApiDocument(jsonApiDoc)
.build();
requestScope.setEntityProjection(new EntityProjectionMaker(elideSettings.getEntityDictionary(),
requestScope).parsePath(route.getPath()));
BaseVisitor visitor = new GetVisitor(requestScope);
return visit(route.getPath(), requestScope, visitor);
});
}
/**
* Handle POST.
*
* @param route the route
* @param jsonApiDocument the json api document
* @param opaqueUser the opaque user
* @param requestId the request ID
* @return Elide response object
*/
public ElideResponse post(Route route, String jsonApiDocument,
User opaqueUser, UUID requestId) {
UUID requestUuid = requestId != null ? requestId : UUID.randomUUID();
return handleRequest(false, opaqueUser, dataStore::beginTransaction, requestUuid, (tx, user) -> {
JsonApiDocument jsonApiDoc = mapper.readJsonApiDocument(jsonApiDocument);
JsonApiRequestScope requestScope = JsonApiRequestScope.builder().route(route).dataStoreTransaction(tx)
.user(user).requestId(requestUuid).elideSettings(elideSettings).jsonApiDocument(jsonApiDoc)
.build();
requestScope.setEntityProjection(new EntityProjectionMaker(elideSettings.getEntityDictionary(),
requestScope).parsePath(route.getPath()));
BaseVisitor visitor = new PostVisitor(requestScope);
return visit(route.getPath(), requestScope, visitor);
});
}
/**
* Handle PATCH.
*
* @param route the route
* @param jsonApiDocument the json api document
* @param opaqueUser the opaque user
* @param requestId the request ID
* @return Elide response object
*/
public ElideResponse patch(Route route, String jsonApiDocument, User opaqueUser, UUID requestId) {
UUID requestUuid = requestId != null ? requestId : UUID.randomUUID();
String accept = getFirstHeaderValueOrEmpty(route, "accept");
String contentType = getFirstHeaderValueOrEmpty(route, "content-type");
Handler handler;
if (JsonApiJsonPatch.isPatchExtension(contentType) && JsonApiJsonPatch.isPatchExtension(accept)) {
handler = (tx, user) -> {
JsonApiJsonPatchRequestScope requestScope = new JsonApiJsonPatchRequestScope(route, tx, user,
requestUuid, elideSettings);
try {
Supplier> responder = JsonApiJsonPatch.processJsonPatch(dataStore,
route.getPath(), jsonApiDocument, requestScope);
return new HandlerResult(requestScope, responder);
} catch (RuntimeException e) {
return new HandlerResult(requestScope, e);
}
};
} else {
handler = (tx, user) -> {
JsonApiDocument jsonApiDoc = mapper.readJsonApiDocument(jsonApiDocument);
JsonApiRequestScope requestScope = JsonApiRequestScope.builder().route(route).dataStoreTransaction(tx)
.user(user).requestId(requestUuid).elideSettings(elideSettings).jsonApiDocument(jsonApiDoc)
.build();
requestScope.setEntityProjection(new EntityProjectionMaker(elideSettings.getEntityDictionary(),
requestScope).parsePath(route.getPath()));
BaseVisitor visitor = new PatchVisitor(requestScope);
return visit(route.getPath(), requestScope, visitor);
};
}
return handleRequest(false, opaqueUser, dataStore::beginTransaction, requestUuid, handler);
}
/**
* Handle DELETE.
*
* @param route the route
* @param jsonApiDocument the json api document
* @param opaqueUser the opaque user
* @param requestId the request ID
* @return Elide response object
*/
public ElideResponse delete(Route route, String jsonApiDocument,
User opaqueUser, UUID requestId) {
UUID requestUuid = requestId != null ? requestId : UUID.randomUUID();
return handleRequest(false, opaqueUser, dataStore::beginTransaction, requestUuid, (tx, user) -> {
JsonApiDocument jsonApiDoc = StringUtils.isEmpty(jsonApiDocument)
? new JsonApiDocument()
: mapper.readJsonApiDocument(jsonApiDocument);
JsonApiRequestScope requestScope = JsonApiRequestScope.builder().route(route).dataStoreTransaction(tx)
.user(user).requestId(requestUuid).elideSettings(elideSettings).jsonApiDocument(jsonApiDoc).build();
requestScope.setEntityProjection(new EntityProjectionMaker(elideSettings.getEntityDictionary(),
requestScope).parsePath(route.getPath()));
BaseVisitor visitor = new DeleteVisitor(requestScope);
return visit(route.getPath(), requestScope, visitor);
});
}
/**
* Handle operations for the Atomic Operations extension.
*
* @param route the route
* @param jsonApiDocument the json api document
* @param opaqueUser the opaque user
* @param requestId the request ID
* @return Elide response object
*/
public ElideResponse operations(Route route,
String jsonApiDocument, User opaqueUser, UUID requestId) {
UUID requestUuid = requestId != null ? requestId : UUID.randomUUID();
String accept = getFirstHeaderValueOrEmpty(route, "accept");
String contentType = getFirstHeaderValueOrEmpty(route, "content-type");
Handler handler;
if (JsonApiAtomicOperations.isAtomicOperationsExtension(contentType)
&& JsonApiAtomicOperations.isAtomicOperationsExtension(accept)) {
handler = (tx, user) -> {
JsonApiAtomicOperationsRequestScope requestScope = new JsonApiAtomicOperationsRequestScope(
route, tx, user, requestUuid, elideSettings);
try {
Supplier> responder = JsonApiAtomicOperations
.processAtomicOperations(dataStore, route.getPath(), jsonApiDocument, requestScope);
return new HandlerResult(requestScope, responder);
} catch (RuntimeException e) {
return new HandlerResult(requestScope, e);
}
};
} else {
return ElideResponse.status(HttpStatus.SC_UNSUPPORTED_MEDIA_TYPE).body("Unsupported Media Type");
}
return handleRequest(false, opaqueUser, dataStore::beginTransaction, requestUuid, handler);
}
public HandlerResult visit(String path, JsonApiRequestScope requestScope, BaseVisitor visitor) {
try {
Supplier> responder = visitor.visit(JsonApiParser.parse(path));
return new HandlerResult(requestScope, responder);
} catch (RuntimeException e) {
return new HandlerResult(requestScope, e);
}
}
/**
* Handle JSON API requests.
*
* @param isReadOnly if the transaction is read only
* @param user the user object from the container
* @param transaction a transaction supplier
* @param requestId the Request ID
* @param handler a function that creates the request scope and request handler
* @param The response type (JsonNode or JsonApiDocument)
* @return the response
*/
protected ElideResponse handleRequest(boolean isReadOnly, User user,
Supplier transaction, UUID requestId,
Handler handler) {
JsonApiErrorContext errorContext = JsonApiErrorContext.builder().mapper(this.mapper)
.verbose(elideSettings.isVerboseErrors()).build();
try (DataStoreTransaction tx = transaction.get()) {
transactionRegistry.addRunningTransaction(requestId, tx);
HandlerResult result = handler.handle(tx, user);
JsonApiRequestScope requestScope = result.getRequestScope();
Supplier> responder = result.getResponder();
tx.preCommit(requestScope);
requestScope.runQueuedPreSecurityTriggers();
requestScope.getPermissionExecutor().executeCommitChecks();
requestScope.runQueuedPreFlushTriggers();
if (!isReadOnly) {
requestScope.saveOrCreateObjects();
}
tx.flush(requestScope);
requestScope.runQueuedPreCommitTriggers();
ElideResponse response = buildResponse(responder.get());
auditLogger.commit();
tx.commit(requestScope);
requestScope.runQueuedPostCommitTriggers();
if (log.isTraceEnabled()) {
requestScope.getPermissionExecutor().logCheckStats();
}
return response;
} catch (Throwable e) {
ElideResponse> errorResponse = jsonApiExceptionHandler.handleException(e, errorContext);
return toResponse(errorResponse.getStatus(), errorResponse.getBody());
} finally {
transactionRegistry.removeRunningTransaction(requestId);
auditLogger.clear();
}
}
protected ElideResponse toResponse(int status, Object body) {
String result = null;
if (body instanceof String data) {
result = data;
} else {
try {
result = body != null ? this.mapper.writeJsonApiDocument(body) : null;
} catch (JsonProcessingException e) {
return ElideResponse.status(HttpStatus.SC_INTERNAL_SERVER_ERROR).body(e.toString());
}
}
return ElideResponse.status(status).body(result);
}
protected ElideResponse buildResponse(Pair response) {
T responseNode = response.getRight();
Integer responseCode = response.getLeft();
return toResponse(responseCode, responseNode);
}
private void verifyQueryParams(Map> queryParams) {
String undefinedKeys = queryParams.keySet()
.stream()
.filter(JsonApi::notAValidKey)
.collect(Collectors.joining(", "));
if (!undefinedKeys.isEmpty()) {
throw new BadRequestException("Found undefined keys in request: " + undefinedKeys);
}
}
private static boolean notAValidKey(String key) {
boolean validKey = key.equals("sort")
|| key.startsWith("filter")
|| (key.startsWith("fields[") && key.endsWith("]"))
|| key.startsWith("page[")
|| key.equals(EntityProjectionMaker.INCLUDE);
return !validKey;
}
private static String getFirstHeaderValueOrEmpty(Route route, String headerName) {
return route.getHeaders().getOrDefault(headerName, Collections.emptyList()).stream().findFirst().orElse("");
}
/**
* A function that sets up the request handling objects.
*
* @param the request's transaction
* @param the request's user
* @param the request handling objects
*/
@FunctionalInterface
public interface Handler {
HandlerResult handle(DataStoreTransaction a, User b) throws IOException;
}
/**
* A wrapper to return multiple values, less verbose than Pair.
* @param Response type.
*/
protected static class HandlerResult {
protected JsonApiRequestScope requestScope;
protected Supplier> result;
protected RuntimeException cause;
protected HandlerResult(JsonApiRequestScope requestScope, Supplier> result) {
this.requestScope = requestScope;
this.result = result;
}
public HandlerResult(JsonApiRequestScope requestScope, RuntimeException cause) {
this.requestScope = requestScope;
this.cause = cause;
}
public Supplier> getResponder() {
if (cause != null) {
throw cause;
}
return result;
}
public JsonApiRequestScope getRequestScope() {
return requestScope;
}
}
public static final String MEDIA_TYPE = "application/vnd.api+json";
public static class JsonPatch {
private JsonPatch() {
}
public static final String MEDIA_TYPE = "application/vnd.api+json; ext=jsonpatch";
}
public static class AtomicOperations {
private AtomicOperations() {
}
public static final String MEDIA_TYPE = "application/vnd.api+json; ext=\"https://jsonapi.org/ext/atomic\"";
}
}