diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/modules/backend/implementations/memory/MemoryRecordStore.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/modules/backend/implementations/memory/MemoryRecordStore.java index d607f456..5014e0e0 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/modules/backend/implementations/memory/MemoryRecordStore.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/modules/backend/implementations/memory/MemoryRecordStore.java @@ -41,6 +41,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; import com.kingsrook.qqq.backend.core.modules.backend.implementations.utils.BackendQueryFilterUtils; import com.kingsrook.qqq.backend.core.utils.CollectionUtils; import com.kingsrook.qqq.backend.core.utils.ListingHash; +import com.kingsrook.qqq.backend.core.utils.ValueUtils; /******************************************************************************* @@ -255,7 +256,7 @@ public class MemoryRecordStore QFieldMetaData primaryKeyField = table.getField(table.getPrimaryKeyField()); for(QRecord record : input.getRecords()) { - Serializable primaryKeyValue = record.getValue(primaryKeyField.getName()); + Serializable primaryKeyValue = ValueUtils.getValueAsFieldType(primaryKeyField.getType(), record.getValue(primaryKeyField.getName())); if(tableData.containsKey(primaryKeyValue)) { QRecord recordToUpdate = tableData.get(primaryKeyValue); @@ -286,11 +287,13 @@ public class MemoryRecordStore return (0); } - QTableMetaData table = input.getTable(); - Map tableData = getTableData(table); - int rowsDeleted = 0; + QTableMetaData table = input.getTable(); + QFieldMetaData primaryKeyField = table.getField(table.getPrimaryKeyField()); + Map tableData = getTableData(table); + int rowsDeleted = 0; for(Serializable primaryKeyValue : input.getPrimaryKeys()) { + primaryKeyValue = ValueUtils.getValueAsFieldType(primaryKeyField.getType(), primaryKeyValue); if(tableData.containsKey(primaryKeyValue)) { tableData.remove(primaryKeyValue); diff --git a/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/actions/GenerateOpenApiSpecAction.java b/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/actions/GenerateOpenApiSpecAction.java index 30401fd5..6397fc6b 100644 --- a/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/actions/GenerateOpenApiSpecAction.java +++ b/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/actions/GenerateOpenApiSpecAction.java @@ -62,6 +62,7 @@ import com.kingsrook.qqq.backend.core.utils.StringUtils; import com.kingsrook.qqq.backend.core.utils.YamlUtils; import com.kingsrook.qqq.backend.core.utils.collections.ListBuilder; import com.kingsrook.qqq.backend.core.utils.collections.MapBuilder; +import io.javalin.http.HttpStatus; /******************************************************************************* @@ -272,7 +273,7 @@ public class GenerateOpenApiSpecAction extends AbstractQActionFunction buildStandardErrorResponses() { return MapBuilder.of( - 400, new Response().withRef("#/components/responses/400"), - 401, new Response().withRef("#/components/responses/401"), - 403, new Response().withRef("#/components/responses/403"), - 500, new Response().withRef("#/components/responses/500") + HttpStatus.BAD_REQUEST.getCode(), new Response().withRef("#/components/responses/" + HttpStatus.BAD_REQUEST.getCode()), + HttpStatus.UNAUTHORIZED.getCode(), new Response().withRef("#/components/responses/" + HttpStatus.UNAUTHORIZED.getCode()), + HttpStatus.FORBIDDEN.getCode(), new Response().withRef("#/components/responses/" + HttpStatus.FORBIDDEN.getCode()), + HttpStatus.INTERNAL_SERVER_ERROR.getCode(), new Response().withRef("#/components/responses/" + HttpStatus.INTERNAL_SERVER_ERROR.getCode()) ); } diff --git a/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/javalin/ApiPathNotFoundException.java b/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/javalin/ApiPathNotFoundException.java new file mode 100644 index 00000000..a9d68e2d --- /dev/null +++ b/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/javalin/ApiPathNotFoundException.java @@ -0,0 +1,40 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2023. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qqq.api.javalin; + + +/******************************************************************************* + ** Runtime exception + *******************************************************************************/ +public class ApiPathNotFoundException extends RuntimeException +{ + + /******************************************************************************* + ** Constructor + ** + *******************************************************************************/ + public ApiPathNotFoundException(String message) + { + super(message); + } + +} diff --git a/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/javalin/QBadRequestException.java b/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/javalin/QBadRequestException.java index 3bb62205..8513d133 100644 --- a/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/javalin/QBadRequestException.java +++ b/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/javalin/QBadRequestException.java @@ -40,4 +40,15 @@ public class QBadRequestException extends QException super(message); } + + + /******************************************************************************* + ** Constructor + ** + *******************************************************************************/ + public QBadRequestException(String message, Throwable root) + { + super(message, root); + } + } diff --git a/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/javalin/QJavalinApiHandler.java b/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/javalin/QJavalinApiHandler.java index 55c85e0d..864aed7d 100644 --- a/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/javalin/QJavalinApiHandler.java +++ b/qqq-middleware-api/src/main/java/com/kingsrook/qqq/api/javalin/QJavalinApiHandler.java @@ -31,6 +31,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.stream.Collectors; import com.kingsrook.qqq.api.ApiMiddlewareType; import com.kingsrook.qqq.api.actions.GenerateOpenApiSpecAction; import com.kingsrook.qqq.api.actions.GetTableApiFieldsAction; @@ -43,10 +44,14 @@ import com.kingsrook.qqq.api.model.metadata.tables.ApiTableMetaData; import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper; import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType; import com.kingsrook.qqq.backend.core.actions.tables.CountAction; +import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction; import com.kingsrook.qqq.backend.core.actions.tables.GetAction; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction; import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.exceptions.QAuthenticationException; +import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.exceptions.QModuleDispatchException; import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException; import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException; @@ -55,18 +60,25 @@ import com.kingsrook.qqq.backend.core.logging.QLogger; import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput; import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput; import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteOutput; import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput; import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateOutput; import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.utils.CollectionUtils; import com.kingsrook.qqq.backend.core.utils.ExceptionUtils; import com.kingsrook.qqq.backend.core.utils.JsonUtils; import com.kingsrook.qqq.backend.core.utils.StringUtils; @@ -79,6 +91,7 @@ import io.javalin.apibuilder.EndpointGroup; import io.javalin.http.ContentType; import io.javalin.http.Context; import org.eclipse.jetty.http.HttpStatus; +import org.json.JSONObject; import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; import static com.kingsrook.qqq.backend.javalin.QJavalinImplementation.SLOW_LOG_THRESHOLD_MS; @@ -132,11 +145,29 @@ public class QJavalinApiHandler // delete("/bulk", QJavalinApiHandler::bulkDelete); }); }); + + ////////////////////////////////////////////////////////////////////////////////////////////// + // default all other /api/ requests (for the methods we support) to a standard 404 response // + ////////////////////////////////////////////////////////////////////////////////////////////// + ApiBuilder.get("/api/*", QJavalinApiHandler::doPathNotFound); + ApiBuilder.delete("/api/*", QJavalinApiHandler::doPathNotFound); + ApiBuilder.patch("/api/*", QJavalinApiHandler::doPathNotFound); + ApiBuilder.post("/api/*", QJavalinApiHandler::doPathNotFound); }); } + /******************************************************************************* + ** + *******************************************************************************/ + private static void doPathNotFound(Context context) + { + handleException(context, new QNotFoundException("Could not find any resources at path " + context.path())); + } + + + /******************************************************************************* ** *******************************************************************************/ @@ -152,7 +183,7 @@ public class QJavalinApiHandler } catch(Exception e) { - QJavalinImplementation.handleException(context, e); + handleException(context, e); } } @@ -195,9 +226,6 @@ public class QJavalinApiHandler try { - // todo - make sure version is known in this instance - // todo - make sure table is supported in this version - QTableMetaData table = qInstance.getTable(tableName); validateTableAndVersion(context, version, table); @@ -230,8 +258,7 @@ public class QJavalinApiHandler + table.getFields().get(table.getPrimaryKeyField()).getLabel() + " of " + primaryKey)); } - List tableApiFields = new GetTableApiFieldsAction().execute(new GetTableApiFieldsInput().withTableName(tableName).withVersion(version)).getFields(); - LinkedHashMap outputRecord = toApiRecord(record, tableApiFields); + LinkedHashMap outputRecord = toApiRecord(record, tableName, version); QJavalinAccessLogger.logEndSuccess(); context.result(JsonUtils.toJson(outputRecord)); @@ -239,7 +266,7 @@ public class QJavalinApiHandler catch(Exception e) { QJavalinAccessLogger.logEndFail(e); - QJavalinImplementation.handleException(context, e); + handleException(context, e); } } @@ -258,9 +285,6 @@ public class QJavalinApiHandler { List badRequestMessages = new ArrayList<>(); - // todo - make sure version is known in this instance - // todo - make sure table is supported in this version - QTableMetaData table = qInstance.getTable(tableName); validateTableAndVersion(context, version, table); @@ -449,11 +473,10 @@ public class QJavalinApiHandler /////////////////////////////// // map record fields for api // /////////////////////////////// - List tableApiFields = new GetTableApiFieldsAction().execute(new GetTableApiFieldsInput().withTableName(tableName).withVersion(version)).getFields(); - ArrayList> records = new ArrayList<>(); + ArrayList> records = new ArrayList<>(); for(QRecord record : queryOutput.getRecords()) { - records.add(toApiRecord(record, tableApiFields)); + records.add(toApiRecord(record, tableName, version)); } output.put("records", records); @@ -520,7 +543,7 @@ public class QJavalinApiHandler GT(">", QCriteriaOperator.GREATER_THAN, QCriteriaOperator.LESS_THAN_OR_EQUALS, false, 1), EMPTY("EMPTY", QCriteriaOperator.IS_BLANK, QCriteriaOperator.IS_NOT_BLANK, true, 0), BETWEEN("BETWEEN ", QCriteriaOperator.BETWEEN, QCriteriaOperator.NOT_BETWEEN, true, 2), - IN("IN ", QCriteriaOperator.IN, QCriteriaOperator.NOT_IN, true, 2), + IN("IN ", QCriteriaOperator.IN, QCriteriaOperator.NOT_IN, true, null), // todo MATCHES ; @@ -529,14 +552,14 @@ public class QJavalinApiHandler private final QCriteriaOperator positiveOperator; private final QCriteriaOperator negativeOperator; private final boolean supportsNot; - private final int noOfValues; // 0 & 1 mean 0 & 1 ... 2 means 1 or more... + private final Integer noOfValues; // null means many (IN) /******************************************************************************* ** *******************************************************************************/ - Operator(String prefix, QCriteriaOperator positiveOperator, QCriteriaOperator negativeOperator, boolean supportsNot, int noOfValues) + Operator(String prefix, QCriteriaOperator positiveOperator, QCriteriaOperator negativeOperator, boolean supportsNot, Integer noOfValues) { this.prefix = prefix; this.positiveOperator = positiveOperator; @@ -551,7 +574,7 @@ public class QJavalinApiHandler /******************************************************************************* ** *******************************************************************************/ - private static QFilterCriteria parseQueryParamToCriteria(String name, String value) throws QBadRequestException + private static QFilterCriteria parseQueryParamToCriteria(String name, String value) throws QException { /////////////////////////////////// // process & discard a leading ! // @@ -600,7 +623,11 @@ public class QJavalinApiHandler // todo - quotes? // //////////////////////////////////// List criteriaValues; - if(selectedOperator.noOfValues == 1) + if(selectedOperator.noOfValues == null) + { + criteriaValues = Arrays.asList(value.split(",")); + } + else if(selectedOperator.noOfValues == 1) { criteriaValues = ListBuilder.of(value); } @@ -612,9 +639,17 @@ public class QJavalinApiHandler } criteriaValues = null; } - else + else if(selectedOperator.noOfValues == 2) { criteriaValues = Arrays.asList(value.split(",")); + if(criteriaValues.size() != 2) + { + throw (new QBadRequestException("Operator " + selectedOperator.prefix + " for field " + name + " requires 2 values (received " + criteriaValues.size() + ")")); + } + } + else + { + throw (new QException("Unexpected noOfValues [" + selectedOperator.noOfValues + "] in operator [" + selectedOperator + "]")); } return (new QFilterCriteria(name, isNot ? selectedOperator.negativeOperator : selectedOperator.positiveOperator, criteriaValues)); @@ -627,7 +662,57 @@ public class QJavalinApiHandler *******************************************************************************/ private static void doInsert(Context context) { + String version = context.pathParam("version"); + String tableName = context.pathParam("tableName"); + try + { + QTableMetaData table = qInstance.getTable(tableName); + validateTableAndVersion(context, version, table); + + InsertInput insertInput = new InsertInput(); + + setupSession(context, insertInput); + QJavalinAccessLogger.logStart("insert", logPair("table", tableName)); + + insertInput.setTableName(tableName); + + PermissionsHelper.checkTablePermissionThrowing(insertInput, TablePermissionSubType.INSERT); + + try + { + if(!StringUtils.hasContent(context.body())) + { + throw (new QBadRequestException("Missing required POST body")); + } + + JSONObject jsonObject = new JSONObject(context.body()); + insertInput.setRecords(List.of(toQRecord(jsonObject, tableName, version))); + } + catch(QBadRequestException qbre) + { + throw (qbre); + } + catch(Exception e) + { + throw (new QBadRequestException("Body could not be parsed as a JSON object: " + e.getMessage(), e)); + } + + InsertAction insertAction = new InsertAction(); + InsertOutput insertOutput = insertAction.execute(insertInput); + + LinkedHashMap outputRecord = new LinkedHashMap<>(); + outputRecord.put(table.getPrimaryKeyField(), insertOutput.getRecords().get(0).getValue(table.getPrimaryKeyField())); + + QJavalinAccessLogger.logEndSuccess(); + context.status(HttpStatus.Code.CREATED.getCode()); + context.result(JsonUtils.toJson(outputRecord)); + } + catch(Exception e) + { + QJavalinAccessLogger.logEndFail(e); + handleException(context, e); + } } @@ -637,7 +722,76 @@ public class QJavalinApiHandler *******************************************************************************/ private static void doUpdate(Context context) { + String version = context.pathParam("version"); + String tableName = context.pathParam("tableName"); + String primaryKey = context.pathParam("primaryKey"); + try + { + QTableMetaData table = qInstance.getTable(tableName); + validateTableAndVersion(context, version, table); + + UpdateInput updateInput = new UpdateInput(); + + setupSession(context, updateInput); + QJavalinAccessLogger.logStart("update", logPair("table", tableName)); + + updateInput.setTableName(tableName); + + PermissionsHelper.checkTablePermissionThrowing(updateInput, TablePermissionSubType.EDIT); + + /////////////////////////////////////////////////////// + // throw a not found error if the record isn't found // + /////////////////////////////////////////////////////// + GetInput getInput = new GetInput(); + getInput.setTableName(tableName); + getInput.setPrimaryKey(primaryKey); + GetAction getAction = new GetAction(); + GetOutput getOutput = getAction.execute(getInput); + if(getOutput.getRecord() == null) + { + throw (new QNotFoundException("Could not find " + table.getLabel() + " with " + + table.getFields().get(table.getPrimaryKeyField()).getLabel() + " of " + primaryKey)); + } + + try + { + if(!StringUtils.hasContent(context.body())) + { + throw (new QBadRequestException("Missing required POST body")); + } + + JSONObject jsonObject = new JSONObject(context.body()); + QRecord qRecord = toQRecord(jsonObject, tableName, version); + qRecord.setValue(table.getPrimaryKeyField(), primaryKey); + updateInput.setRecords(List.of(qRecord)); + } + catch(QBadRequestException qbre) + { + throw (qbre); + } + catch(Exception e) + { + throw (new QBadRequestException("Body could not be parsed as a JSON object: " + e.getMessage(), e)); + } + + UpdateAction updateAction = new UpdateAction(); + UpdateOutput updateOutput = updateAction.execute(updateInput); + + List errors = updateOutput.getRecords().get(0).getErrors(); + if(CollectionUtils.nullSafeHasContents(errors)) + { + throw (new QException("Error updating " + table.getLabel() + ": " + StringUtils.joinWithCommasAndAnd(errors))); + } + + QJavalinAccessLogger.logEndSuccess(); + context.status(HttpStatus.Code.NO_CONTENT.getCode()); + } + catch(Exception e) + { + QJavalinAccessLogger.logEndFail(e); + handleException(context, e); + } } @@ -647,7 +801,57 @@ public class QJavalinApiHandler *******************************************************************************/ private static void doDelete(Context context) { + String version = context.pathParam("version"); + String tableName = context.pathParam("tableName"); + String primaryKey = context.pathParam("primaryKey"); + try + { + QTableMetaData table = qInstance.getTable(tableName); + validateTableAndVersion(context, version, table); + + DeleteInput deleteInput = new DeleteInput(); + + setupSession(context, deleteInput); + QJavalinAccessLogger.logStart("delete", logPair("table", tableName)); + + deleteInput.setTableName(tableName); + deleteInput.setPrimaryKeys(List.of(primaryKey)); + + PermissionsHelper.checkTablePermissionThrowing(deleteInput, TablePermissionSubType.DELETE); + + /////////////////////////////////////////////////////// + // throw a not found error if the record isn't found // + /////////////////////////////////////////////////////// + GetInput getInput = new GetInput(); + getInput.setTableName(tableName); + getInput.setPrimaryKey(primaryKey); + GetAction getAction = new GetAction(); + GetOutput getOutput = getAction.execute(getInput); + if(getOutput.getRecord() == null) + { + throw (new QNotFoundException("Could not find " + table.getLabel() + " with " + + table.getFields().get(table.getPrimaryKeyField()).getLabel() + " of " + primaryKey)); + } + + /////////////////// + // do the delete // + /////////////////// + DeleteAction deleteAction = new DeleteAction(); + DeleteOutput deleteOutput = deleteAction.execute(deleteInput); + if(CollectionUtils.nullSafeHasContents(deleteOutput.getRecordsWithErrors())) + { + throw (new QException("Error deleting " + table.getLabel() + ": " + StringUtils.joinWithCommasAndAnd(deleteOutput.getRecordsWithErrors().get(0).getErrors()))); + } + + QJavalinAccessLogger.logEndSuccess(); + context.status(HttpStatus.Code.NO_CONTENT.getCode()); + } + catch(Exception e) + { + QJavalinAccessLogger.logEndFail(e); + handleException(context, e); + } } @@ -655,16 +859,52 @@ public class QJavalinApiHandler /******************************************************************************* ** *******************************************************************************/ - private static LinkedHashMap toApiRecord(QRecord record, List tableApiFields) + private static LinkedHashMap toApiRecord(QRecord record, String tableName, String apiVersion) throws QException { - LinkedHashMap outputRecord = new LinkedHashMap<>(); + List tableApiFields = new GetTableApiFieldsAction().execute(new GetTableApiFieldsInput().withTableName(tableName).withVersion(apiVersion)).getFields(); + LinkedHashMap outputRecord = new LinkedHashMap<>(); for(QFieldMetaData tableApiField : tableApiFields) { // todo - what about display values / possible values // todo - handle removed-from-this-version fields!! outputRecord.put(tableApiField.getName(), record.getValue(tableApiField.getName())); } - return outputRecord; + return (outputRecord); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static QRecord toQRecord(JSONObject jsonObject, String tableName, String apiVersion) throws QException + { + List unrecognizedFieldNames = new ArrayList<>(); + + List tableApiFields = new GetTableApiFieldsAction().execute(new GetTableApiFieldsInput().withTableName(tableName).withVersion(apiVersion)).getFields(); + Map apiFieldsMap = tableApiFields.stream().collect(Collectors.toMap(f -> f.getName(), f -> f)); + + QRecord qRecord = new QRecord(); + + for(String jsonKey : jsonObject.keySet()) + { + if(apiFieldsMap.containsKey(jsonKey)) + { + QFieldMetaData field = apiFieldsMap.get(jsonKey); + qRecord.setValue(field.getName(), jsonObject.get(jsonKey)); + } + else + { + unrecognizedFieldNames.add(jsonKey); + } + } + + if(!unrecognizedFieldNames.isEmpty()) + { + throw (new QBadRequestException("Request body contained " + unrecognizedFieldNames.size() + " unrecognized field name" + StringUtils.plural(unrecognizedFieldNames) + ": " + StringUtils.joinWithCommasAndAnd(unrecognizedFieldNames))); + } + + return (qRecord); } @@ -709,6 +949,12 @@ public class QJavalinApiHandler } else { + if(e instanceof ApiPathNotFoundException) + { + respondWithError(context, HttpStatus.Code.NOT_FOUND, e.getMessage()); // 404 + return; + } + if(e instanceof QAuthenticationException) { respondWithError(context, HttpStatus.Code.UNAUTHORIZED, e.getMessage()); // 401 diff --git a/qqq-middleware-api/src/test/java/com/kingsrook/qqq/api/javalin/QJavalinApiHandlerTest.java b/qqq-middleware-api/src/test/java/com/kingsrook/qqq/api/javalin/QJavalinApiHandlerTest.java index dfc17e82..77307c8a 100644 --- a/qqq-middleware-api/src/test/java/com/kingsrook/qqq/api/javalin/QJavalinApiHandlerTest.java +++ b/qqq-middleware-api/src/test/java/com/kingsrook/qqq/api/javalin/QJavalinApiHandlerTest.java @@ -26,15 +26,20 @@ import java.util.ArrayList; import java.util.List; import com.kingsrook.qqq.api.BaseTest; import com.kingsrook.qqq.api.TestUtils; +import com.kingsrook.qqq.backend.core.actions.tables.GetAction; import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.exceptions.QInstanceValidationException; +import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetOutput; import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.utils.StringUtils; import com.kingsrook.qqq.backend.javalin.QJavalinImplementation; import kong.unirest.HttpResponse; import kong.unirest.Unirest; +import org.eclipse.jetty.http.HttpStatus; import org.json.JSONArray; import org.json.JSONObject; import org.junit.jupiter.api.AfterAll; @@ -42,6 +47,8 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; /******************************************************************************* @@ -77,7 +84,7 @@ class QJavalinApiHandlerTest extends BaseTest ** *******************************************************************************/ @AfterAll - public static void afterAll() + static void afterAll() { qJavalinImplementation.stopJavalinServer(); } @@ -104,6 +111,24 @@ class QJavalinApiHandlerTest extends BaseTest + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testRandom404s() + { + for(String method : new String[] { "get", "post", "patch", "delete" }) + { + assertErrorResponse(HttpStatus.NOT_FOUND_404, "Could not find any resources at path", Unirest.request(method, BASE_URL + "/api/" + VERSION + "/notATable/").asString()); + assertErrorResponse(HttpStatus.NOT_FOUND_404, "Could not find any resources at path", Unirest.request(method, BASE_URL + "/api/" + VERSION + "/notATable/notAnId").asString()); + assertErrorResponse(HttpStatus.NOT_FOUND_404, "Could not find any resources at path", Unirest.request(method, BASE_URL + "/api/").asString()); + assertErrorResponse(HttpStatus.NOT_FOUND_404, "Could not find any resources at path", Unirest.request(method, BASE_URL + "/api/foo").asString()); + assertErrorResponse(HttpStatus.NOT_FOUND_404, "Could not find any resources at path", Unirest.request(method, BASE_URL + "/api/" + VERSION + "/person/1/2").asString()); + } + } + + + /******************************************************************************* ** *******************************************************************************/ @@ -111,7 +136,7 @@ class QJavalinApiHandlerTest extends BaseTest void testGet404() { HttpResponse response = Unirest.get(BASE_URL + "/api/" + VERSION + "/person/1").asString(); - assertEquals(404, response.getStatus()); + assertEquals(HttpStatus.NOT_FOUND_404, response.getStatus()); JSONObject jsonObject = new JSONObject(response.getBody()); assertEquals("Could not find Person with Id of 1", jsonObject.getString("error")); } @@ -124,10 +149,10 @@ class QJavalinApiHandlerTest extends BaseTest @Test void testGet200() throws QException { - insertTestRecord(1, "Homer", "Simpson"); + insertPersonRecord(1, "Homer", "Simpson"); HttpResponse response = Unirest.get(BASE_URL + "/api/" + VERSION + "/person/1").asString(); - assertEquals(200, response.getStatus()); + assertEquals(HttpStatus.OK_200, response.getStatus()); JSONObject jsonObject = new JSONObject(response.getBody()); assertEquals(1, jsonObject.getInt("id")); assertEquals("Homer", jsonObject.getString("firstName")); @@ -136,27 +161,14 @@ class QJavalinApiHandlerTest extends BaseTest - /******************************************************************************* - ** - *******************************************************************************/ - private static void insertTestRecord(Integer id, String firstName, String lastName) throws QException - { - InsertInput insertInput = new InsertInput(); - insertInput.setTableName(TestUtils.TABLE_NAME_PERSON); - insertInput.setRecords(List.of(new QRecord().withValue("id", id).withValue("firstName", firstName).withValue("lastName", lastName))); - new InsertAction().execute(insertInput); - } - - - /******************************************************************************* ** *******************************************************************************/ @Test void testQuery404() { - assertError(404, BASE_URL + "/api/" + VERSION + "/notATable/query?"); - assertError(404, BASE_URL + "/api/notAVersion/person/query?"); + assertError(HttpStatus.NOT_FOUND_404, BASE_URL + "/api/" + VERSION + "/notATable/query?"); + assertError(HttpStatus.NOT_FOUND_404, BASE_URL + "/api/notAVersion/person/query?"); } @@ -196,7 +208,7 @@ class QJavalinApiHandlerTest extends BaseTest void testQuery200NoParams() { HttpResponse response = Unirest.get(BASE_URL + "/api/" + VERSION + "/person/query").asString(); - assertEquals(200, response.getStatus()); + assertEquals(HttpStatus.OK_200, response.getStatus()); JSONObject jsonObject = new JSONObject(response.getBody()); assertEquals(0, jsonObject.getInt("count")); assertEquals(1, jsonObject.getInt("pageNo")); @@ -211,10 +223,10 @@ class QJavalinApiHandlerTest extends BaseTest @Test void testQuery200SomethingFound() throws QException { - insertTestRecord(1, "Homer", "Simpson"); + insertPersonRecord(1, "Homer", "Simpson"); HttpResponse response = Unirest.get(BASE_URL + "/api/" + VERSION + "/person/query").asString(); - assertEquals(200, response.getStatus()); + assertEquals(HttpStatus.OK_200, response.getStatus()); JSONObject jsonObject = new JSONObject(response.getBody()); assertEquals(1, jsonObject.getInt("count")); assertEquals(1, jsonObject.getInt("pageNo")); @@ -327,16 +339,300 @@ class QJavalinApiHandlerTest extends BaseTest + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testQuery200ManyParams() + { + HttpResponse response = Unirest.get(BASE_URL + "/api/" + VERSION + "/person/query?pageSize=49&pageNo=2&includeCount=true&booleanOperator=AND&firstName=Homer&orderBy=firstName desc").asString(); + assertEquals(HttpStatus.OK_200, response.getStatus()); + JSONObject jsonObject = new JSONObject(response.getBody()); + assertEquals(0, jsonObject.getInt("count")); + assertEquals(2, jsonObject.getInt("pageNo")); + assertEquals(49, jsonObject.getInt("pageSize")); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testInsert201() throws QException + { + HttpResponse response = Unirest.post(BASE_URL + "/api/" + VERSION + "/person/") + .body(""" + {"firstName": "Moe"} + """) + .asString(); + assertEquals(HttpStatus.CREATED_201, response.getStatus()); + JSONObject jsonObject = new JSONObject(response.getBody()); + assertEquals(1, jsonObject.getInt("id")); + + QRecord record = getPersonRecord(1); + assertEquals("Moe", record.getValueString("firstName")); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testInsert400s() throws QException + { + HttpResponse response = Unirest.post(BASE_URL + "/api/" + VERSION + "/person/") + .body(""" + [{"firstName": "Moe"}] + """) + .asString(); + assertErrorResponse(HttpStatus.BAD_REQUEST_400, "Body could not be parsed as a JSON object: A JSONObject text must begin with '{'", response); + + response = Unirest.post(BASE_URL + "/api/" + VERSION + "/person/") + .body(""" + "firstName"="Moe" + """) + .asString(); + assertErrorResponse(HttpStatus.BAD_REQUEST_400, "Body could not be parsed as a JSON object: A JSONObject text must begin with '{'", response); + + response = Unirest.post(BASE_URL + "/api/" + VERSION + "/person/") + // no body + .asString(); + assertErrorResponse(HttpStatus.BAD_REQUEST_400, "Missing required POST body", response); + + response = Unirest.post(BASE_URL + "/api/" + VERSION + "/person/") + .body(""" + {"firstName": "Moe", "firstName": "Barney"} + """) + .asString(); + assertErrorResponse(HttpStatus.BAD_REQUEST_400, "Body could not be parsed as a JSON object: Duplicate key \"firstName\"", response); + + response = Unirest.post(BASE_URL + "/api/" + VERSION + "/person/") + .body(""" + {"firstName": "Moe", "foo": "bar"} + """) + .asString(); + assertErrorResponse(HttpStatus.BAD_REQUEST_400, "Request body contained 1 unrecognized field name: foo", response); + + response = Unirest.post(BASE_URL + "/api/" + VERSION + "/person/") + .body(""" + {"firstName": "Moe", "foo": "bar", "bar": true, "baz": 1} + """) + .asString(); + assertErrorResponse(HttpStatus.BAD_REQUEST_400, "Request body contained 3 unrecognized field names: ", response); + + /////////////////////////////////// + // assert it didn't get inserted // + /////////////////////////////////// + QRecord personRecord = getPersonRecord(1); + assertNull(personRecord); + + /////////////////////////////////////////////////////////////////////////////////////////// + // apparently, as long as the body *starts with* json, the JSONObject constructor builds // + // a json object out of it?? so... this in this case we expected 400, but get 201... // + /////////////////////////////////////////////////////////////////////////////////////////// + response = Unirest.post(BASE_URL + "/api/" + VERSION + "/person/") + .body(""" + {"firstName": "Moe"} + Not json + """) + .asString(); + assertErrorResponse(HttpStatus.CREATED_201, null, response); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testUpdate204() throws QException + { + insertPersonRecord(1, "CM", "Burns"); + + HttpResponse response = Unirest.patch(BASE_URL + "/api/" + VERSION + "/person/1") + .body(""" + {"firstName": "Charles"} + """) + .asString(); + assertEquals(HttpStatus.NO_CONTENT_204, response.getStatus()); + assertFalse(StringUtils.hasContent(response.getBody())); + + QRecord record = getPersonRecord(1); + assertEquals("Charles", record.getValueString("firstName")); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testUpdate404() + { + HttpResponse response = Unirest.patch(BASE_URL + "/api/" + VERSION + "/person/1") + .body(""" + {"firstName": "Charles"} + """) + .asString(); + assertErrorResponse(HttpStatus.NOT_FOUND_404, "Could not find Person with Id of 1", response); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testUpdate400s() throws QException + { + insertPersonRecord(1, "Mo", "Szyslak"); + + HttpResponse response = Unirest.patch(BASE_URL + "/api/" + VERSION + "/person/1") + .body(""" + [{"firstName": "Moe"}] + """) + .asString(); + assertErrorResponse(HttpStatus.BAD_REQUEST_400, "Body could not be parsed as a JSON object: A JSONObject text must begin with '{'", response); + + response = Unirest.patch(BASE_URL + "/api/" + VERSION + "/person/1") + .body(""" + "firstName"="Moe" + """) + .asString(); + assertErrorResponse(HttpStatus.BAD_REQUEST_400, "Body could not be parsed as a JSON object: A JSONObject text must begin with '{'", response); + + response = Unirest.patch(BASE_URL + "/api/" + VERSION + "/person/1") + // no body + .asString(); + assertErrorResponse(HttpStatus.BAD_REQUEST_400, "Missing required POST body", response); + + response = Unirest.patch(BASE_URL + "/api/" + VERSION + "/person/1") + .body(""" + {"firstName": "Moe", "firstName": "Barney"} + """) + .asString(); + assertErrorResponse(HttpStatus.BAD_REQUEST_400, "Body could not be parsed as a JSON object: Duplicate key \"firstName\"", response); + + response = Unirest.patch(BASE_URL + "/api/" + VERSION + "/person/1") + .body(""" + {"firstName": "Moe", "foo": "bar"} + """) + .asString(); + assertErrorResponse(HttpStatus.BAD_REQUEST_400, "Request body contained 1 unrecognized field name: foo", response); + + response = Unirest.patch(BASE_URL + "/api/" + VERSION + "/person/1") + .body(""" + {"firstName": "Moe", "foo": "bar", "bar": true, "baz": 1} + """) + .asString(); + assertErrorResponse(HttpStatus.BAD_REQUEST_400, "Request body contained 3 unrecognized field names: ", response); + + /////////////////////////////////// + // assert it didn't get updated. // + /////////////////////////////////// + QRecord personRecord = getPersonRecord(1); + assertEquals("Mo", personRecord.getValueString("firstName")); + + /////////////////////////////////////////////////////////////////////////////////////////// + // apparently, as long as the body *starts with* json, the JSONObject constructor builds // + // a json object out of it?? so... this in this case we expected 400, but get 204... // + /////////////////////////////////////////////////////////////////////////////////////////// + response = Unirest.patch(BASE_URL + "/api/" + VERSION + "/person/1") + .body(""" + {"firstName": "Moe"} + Not json + """) + .asString(); + assertErrorResponse(HttpStatus.NO_CONTENT_204, null, response); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testDeleteWithoutPkey() throws QException + { + insertPersonRecord(1, "CM", "Burns"); + + HttpResponse response = Unirest.delete(BASE_URL + "/api/" + VERSION + "/person/").asString(); + assertErrorResponse(HttpStatus.NOT_FOUND_404, "Could not find any resources at path", response); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testDelete204() throws QException + { + insertPersonRecord(1, "CM", "Burns"); + + HttpResponse response = Unirest.delete(BASE_URL + "/api/" + VERSION + "/person/1").asString(); + assertEquals(HttpStatus.NO_CONTENT_204, response.getStatus()); + assertFalse(StringUtils.hasContent(response.getBody())); + + QRecord record = getPersonRecord(1); + assertNull(record); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testDelete404() + { + HttpResponse response = Unirest.delete(BASE_URL + "/api/" + VERSION + "/person/1") + .asString(); + assertErrorResponse(HttpStatus.NOT_FOUND_404, "Could not find Person with Id of 1", response); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static QRecord getPersonRecord(Integer id) throws QException + { + GetInput getInput = new GetInput(); + getInput.setTableName(TestUtils.TABLE_NAME_PERSON); + getInput.setPrimaryKey(id); + GetOutput getOutput = new GetAction().execute(getInput); + QRecord record = getOutput.getRecord(); + return record; + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static void insertPersonRecord(Integer id, String firstName, String lastName) throws QException + { + InsertInput insertInput = new InsertInput(); + insertInput.setTableName(TestUtils.TABLE_NAME_PERSON); + insertInput.setRecords(List.of(new QRecord().withValue("id", id).withValue("firstName", firstName).withValue("lastName", lastName))); + new InsertAction().execute(insertInput); + } + + + /******************************************************************************* ** *******************************************************************************/ private static void insertSimpsons() throws QException { - insertTestRecord(1, "Homer", "Simpson"); - insertTestRecord(2, "Marge", "Simpson"); - insertTestRecord(3, "Bart", "Simpson"); - insertTestRecord(4, "Lisa", "Simpson"); - insertTestRecord(5, "Maggie", "Simpson"); + insertPersonRecord(1, "Homer", "Simpson"); + insertPersonRecord(2, "Marge", "Simpson"); + insertPersonRecord(3, "Bart", "Simpson"); + insertPersonRecord(4, "Lisa", "Simpson"); + insertPersonRecord(5, "Maggie", "Simpson"); } @@ -347,7 +643,7 @@ class QJavalinApiHandlerTest extends BaseTest private void assertPersonQueryFindsFirstNames(List expectedFirstNames, String queryString) { HttpResponse response = Unirest.get(BASE_URL + "/api/" + VERSION + "/person/query?" + queryString).asString(); - assertEquals(200, response.getStatus()); + assertEquals(HttpStatus.OK_200, response.getStatus()); JSONObject jsonObject = new JSONObject(response.getBody()); assertEquals(expectedFirstNames.size(), jsonObject.getInt("count")); @@ -368,22 +664,6 @@ class QJavalinApiHandlerTest extends BaseTest - /******************************************************************************* - ** - *******************************************************************************/ - @Test - void testQuery200ManyParams() - { - HttpResponse response = Unirest.get(BASE_URL + "/api/" + VERSION + "/person/query?pageSize=49&pageNo=2&includeCount=true&booleanOperator=AND&firstName=Homer&orderBy=firstName desc").asString(); - assertEquals(200, response.getStatus()); - JSONObject jsonObject = new JSONObject(response.getBody()); - assertEquals(0, jsonObject.getInt("count")); - assertEquals(2, jsonObject.getInt("pageNo")); - assertEquals(49, jsonObject.getInt("pageSize")); - } - - - /******************************************************************************* ** *******************************************************************************/ @@ -401,10 +681,30 @@ class QJavalinApiHandlerTest extends BaseTest private void assertError(String expectedErrorMessage, String url) { HttpResponse response = Unirest.get(url).asString(); - assertEquals(400, response.getStatus()); + assertErrorResponse(HttpStatus.BAD_REQUEST_400, expectedErrorMessage, response); JSONObject jsonObject = new JSONObject(response.getBody()); String error = jsonObject.getString("error"); assertThat(error).contains(expectedErrorMessage); } + + + /******************************************************************************* + ** + *******************************************************************************/ + private void assertErrorResponse(Integer expectedStatusCode, String expectedErrorMessage, HttpResponse response) + { + if(expectedStatusCode != null) + { + assertEquals(expectedStatusCode, response.getStatus()); + } + + if(expectedErrorMessage != null) + { + JSONObject jsonObject = new JSONObject(response.getBody()); + String error = jsonObject.getString("error"); + assertThat(error).contains(expectedErrorMessage); + } + } + } \ No newline at end of file