io.inversion.action.db.DbPostAction Maven / Gradle / Ivy
/*
* Copyright (c) 2015-2019 Rocket Partners, LLC
* https://github.com/inversion-api
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.inversion.action.db;
import io.inversion.Collection;
import io.inversion.*;
import io.inversion.json.*;
import io.inversion.rql.Term;
import io.inversion.utils.Utils;
import io.inversion.utils.KeyValue;
import org.apache.commons.collections4.keyvalue.MultiKey;
import org.apache.commons.collections4.map.MultiKeyMap;
import java.util.*;
class DbPostAction extends Action {
protected boolean collapseAll = false;
/**
* When true, forces PUTs to have an resourceKey in the URL
*/
protected boolean strictRest = false;
protected boolean getResponse = true;
@Override
protected List getDefaultIncludeMatchers(){
return Utils.asList(new RuleMatcher("POST", "{" + Request.COLLECTION_KEY + "}"));
}
public static String nextPath(String path, String next) {
return Utils.empty(path) ? next : path + "." + next;
}
public void run(Request req, Response res) throws ApiException {
if (req.isMethod("PUT", "POST")) {
upsert(req, res);
} else if (req.isMethod("PATCH")) {
patch(req, res);
} else {
throw ApiException.new400BadRequest("Method '%' is not supported by RestPostHandler");
}
}
/**
* Unlike upsert for POST/PUT, this method is specifically NOT recursive for patching
* nested documents. It will only patch the parent collection/table.
*
* TODO: add support for JSON Patch format...maybe
*
* @param req the request to run
* @param res the response to populate
*/
public void patch(Request req, Response res) throws ApiException {
JSNode body = req.getJson();
if (body == null)
throw ApiException.new400BadRequest("You must pass a JSON body on a {}", req.getMethod());
//if the caller posted back an Inversion GET style envelope with meta/data sections, unwrap to get to the real body
if(body.find("meta") instanceof JSNode && body.find("data") instanceof JSList)
body = body.findMap("data");
//if a single cell array was passed in, unwrap to get to the real body
if(body instanceof JSList && ((JSList)body).size() == 1 && ((JSList)body).get(0) instanceof JSNode)
body = ((JSList)body).getNode(0);
if (body.isList()) {
if (!Utils.empty(req.getResourceKey())) {
throw ApiException.new400BadRequest("You can't batch '{}' an array of objects to a specific resource url. You must '{}' them to a collection.", req.getMethod(), req.getMethod());
}
} else {
String href = body.getString("href");
if (req.getResourceKey() != null) {
if (href == null)
body.put("href", Utils.substringBefore(req.getUrl().toString(), "?"));
else if (!req.getUrl().toString().startsWith(href))
throw ApiException.new400BadRequest("You are PATCHING-ing an resource with a different href property than the resource URL you are PATCHING-ing to.");
}
}
List resourceKeys = req.getCollection().getDb().patch(req.getCollection(), req.getJson().asMapList());
if (resourceKeys.size() == req.getJson().asMapList().size()) {
res.withStatus(Status.SC_201_CREATED);
String location = Chain.buildLink(req.getCollection(), Utils.implode(",", resourceKeys));
res.withHeader("Location", location);
if(isGetResponse()){
Response getResponse = req.getChain().getEngine().service("GET", location);
res.getJson().put("data", getResponse.data());
}
}
else
{
throw ApiException.new404NotFound("One or more of the requested resource could not be found.");
}
//TODO: add res.withChanges()
}
public void upsert(Request req, Response res) throws ApiException {
if (strictRest) {
if (req.isPost() && req.getResourceKey() != null)
throw ApiException.new404NotFound("You are trying to POST to a specific resource url. Set 'strictRest' to false to interpret PUT vs POST intention based on presense of 'href' property in passed in JSON");
if (req.isPut() && req.getResourceKey() == null)
throw ApiException.new404NotFound("You are trying to PUT to a collection url. Set 'strictRest' to false to interpret PUT vs POST intention based on presense of 'href' property in passed in JSON");
}
Collection collection = req.getCollection();
List changes = new ArrayList<>();
List resourceKeys;
JSNode body = req.getJson();
swapRefsWithActualReferences(body);
JSList bodyArr = body.asList();
Map visited = new HashMap();
for(int i=0; i collapses = collapseStr == null ? new HashSet<>() : Utils.asSet(Utils.explode(",", collapseStr));
if (collapseAll || collapses.size() > 0) {
body = JSParser.asJSNode(body.toString());
collapse(body, collapseAll, collapses, "");
}
if (body instanceof JSList) {
if (!Utils.empty(req.getResourceKey())) {
throw ApiException.new400BadRequest("You can't batch '{}' an array of objects to a specific resource url. You must '{}' them to a collection.", req.getMethod(), req.getMethod());
}
resourceKeys = upsert(req, collection, (JSList) body);
} else {
String href = body.getString("href");
if (req.isPut() && href != null && req.getResourceKey() != null && !req.getUrl().toString().startsWith(href)) {
throw ApiException.new400BadRequest("You are PUT-ing an resource with a different href property than the resource URL you are PUT-ing to.");
}
resourceKeys = upsert(req, collection, new JSList(body));
}
res.withChanges(changes);
//-- take all of the hrefs and combine into a
//-- single href for the "Location" header
//JSList array = new JSList();
//res.getJson().put("data", array);
StringBuilder buff = new StringBuilder();
for (Object key : resourceKeys) {
String resourceKey = key + "";
String href = Chain.buildLink(collection, resourceKey);
if(href != null){
res.data().add(new JSMap("href", href));
String nextId = href.substring(href.lastIndexOf("/") + 1);
buff.append(",").append(nextId);
}
}
if (buff.length() > 0) {
res.withStatus(Status.SC_201_CREATED);
String location = Chain.buildLink(collection, buff.substring(1, buff.length()));
res.withHeader("Location", location);
if(isGetResponse()){
Response getResponse = req.getChain().getEngine().service("GET", location);
if(getResponse.isSuccess()){
res.getJson().put("data", getResponse.data());
}
else{
getResponse.getJson();
res.withBody(getResponse.getBody());
res.withStatus(getResponse.getStatus());
res.withError(getResponse.getError());
}
}
}
else
{
res.withStatus(Status.SC_204_NO_CONTENT);
}
}
/**
* README README README README
*
* Algorithm:
*
* Step 1: Upsert all nodes
in this generation...meaning not recursively including
* key values for all many-to-one foreign keys but excluding all one-to-many and many-to-many
* key changes...non many-to-one relationships involve modifying other tables that have foreign
* keys back to this collection's table, not the direct modification of the single table
* underlying this collection.
*
* Step 2: For each relationship POST back through the "front door". This is the primary
* recursion that enables nested documents to submitted all at once by client. Putting
* this step first ensures that all new objects are POSTed, with their newly created hrefs
* placed back in the JSON prior to any PUTs that depend on relationship keys to exist.
*
* Step 3: PKs generated for child documents which are actually relationship parents, are set
* as foreign keys back on the parent json (which is actually the one-to-many child)
*
* Step 4: Find the key values for all new/kept one-to-many and many-to-many relationships
*
* Step 5.1 Upsert all of those new/kept relationships and create the RQL queries needed find
* all relationships NOT in the upserts.
*
* Step 5.2 Null out all now invalid many-to-one foreign keys back
* and delete all now invalid many-to-many relationships rows.
*
* @param req the request being serviced
* @param collection the collection be modified
* @param nodes the records to update
* @return the entity keys of all upserted records
*/
protected List upsert(Request req, Collection collection, JSList nodes) {
//--
//--
//-- Step 1. Upsert this generation including many-to-one relationships where the fk is known
//--
//System.out.println("UPSERT: " + collection.getName() + ":\r\n" + nodes);
List returnList = collection.getDb().upsert(collection, nodes);
for (int i = 0; i < nodes.size(); i++) {
//-- new records need their newly assigned autogenerated key fields assigned back on them
JSMap node = (JSMap)nodes.get(i);
Map row = collection.decodeKeyToJsonNames(returnList.get(i));
for(String key : row.keySet()){
node.put(key, row.get(key));
}
//-- makes sure any ONE_TO_MANY child nodes that need a key reference back to the parent get it set on them
for (Relationship rel : collection.getRelationships()) {
if(rel.isOneToMany() && node.get(rel.getName()) instanceof JSList){
Map foreignKey = rel.getInverse().buildForeignKeyFromPrimaryKey(node);
node.getList(rel.getName()).asMapList().forEach(child -> child.putAll(foreignKey));
}
}
}
//--
//--
//-- Step 2. recurse by relationship in batch
//--
//-- THIS IS THE ONLY RECURSION IN THE ALGORITHM. IT IS NOT DIRECTLY RECURSIVE. IT
//-- SENDS THE "CHILD GENERATION" AS A POST BACK TO THE ENGINE WHICH WOULD LAND AT
//-- THE ACTION (MAYBE THIS ONE) THAT HANDLES THE UPSERT FOR THAT CHILD COLLECTION
//-- AND ITS DESCENDANTS.
for (Relationship rel : collection.getRelationships()) {
Relationship inverse = rel.getInverse();
LinkedHashMap childMap = new LinkedHashMap<>();
for (JSNode node : nodes.asMapList()) {
Object value = node.get(rel.getName());
if (value instanceof JSNode) {
for (JSMap child : ((JSNode) value).asMapList()) {
String resourceKey = rel.getRelated().encodeKeyFromJsonNames(child);
String hashKey = resourceKey != null ? resourceKey : "_child:" + childMap.size();
if(!childMap.containsKey(hashKey))
childMap.put(hashKey, child);
}
}
if (childMap.size() > 0) {
String path = Chain.buildLink(rel.getRelated());
JSList childArr = new JSList();
for (String key : childMap.keySet()) {
childArr.add(childMap.get(key));
}
Response res = req.getEngine().post(path, childArr);
if (!res.isSuccess())
res.rethrow();
if(res.data().size() != childMap.size()){
throw new ApiException("Can not determine if all children submitted were updated. Request size = {}. Response size = {}", childMap.size(), res.data().size());
}
//-- now get response and set properties BACK on the source from this generation
int i = -1;
Map updatedKeys = new HashMap();
Map updatedNodes = new HashMap();
for (String childKey : childMap.keySet()) {
i++;
JSMap newChild = (JSMap) res.data().getMap(i);
String newKey = rel.getRelated().encodeKeyFromJsonNames(newChild);
if(newKey == null)
throw new ApiException("New child key was null {}", newChild);
JSMap oldChild = childMap.get(childKey);
oldChild.clear();
oldChild.putAll(newChild);
updatedNodes.put(oldChild, newKey);
updatedKeys.put(newKey, oldChild);
}
}
}
}
//--
//--
//-- Step 3. sets foreign keys on json parent entities..this is important
//-- when a JSON parent has a MANY_TO_ONE relationship. The child object
//-- may not have even existed on the initial Step 1 Upsert.
//--
//-- TODO: you could skip this step if the child pk were known at the start of Step 1.
//-- TODO: deduplicate the upsert list
for (Relationship rel : collection.getRelationships()) {
List