diff --git a/qqq-backend-module-api/src/main/java/com/kingsrook/qqq/backend/module/api/actions/BaseAPIActionUtil.java b/qqq-backend-module-api/src/main/java/com/kingsrook/qqq/backend/module/api/actions/BaseAPIActionUtil.java
index 0a8e705b..d8ecf17a 100644
--- a/qqq-backend-module-api/src/main/java/com/kingsrook/qqq/backend/module/api/actions/BaseAPIActionUtil.java
+++ b/qqq-backend-module-api/src/main/java/com/kingsrook/qqq/backend/module/api/actions/BaseAPIActionUtil.java
@@ -26,11 +26,14 @@ import java.io.IOException;
import java.io.Serializable;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
+import java.time.Instant;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
+import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
+import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
@@ -61,9 +64,12 @@ import com.kingsrook.qqq.backend.module.api.exceptions.OAuthCredentialsException
import com.kingsrook.qqq.backend.module.api.exceptions.OAuthExpiredTokenException;
import com.kingsrook.qqq.backend.module.api.exceptions.RateLimitException;
import com.kingsrook.qqq.backend.module.api.model.AuthorizationType;
+import com.kingsrook.qqq.backend.module.api.model.OutboundAPILog;
import com.kingsrook.qqq.backend.module.api.model.metadata.APIBackendMetaData;
import com.kingsrook.qqq.backend.module.api.model.metadata.APITableBackendDetails;
+import org.apache.commons.io.IOUtils;
import org.apache.http.HttpEntity;
+import org.apache.http.HttpEntityEnclosingRequest;
import org.apache.http.HttpResponse;
import org.apache.http.HttpStatus;
import org.apache.http.client.methods.CloseableHttpResponse;
@@ -878,6 +884,8 @@ public class BaseAPIActionUtil
{
QHttpResponse qResponse = new QHttpResponse(response);
+ logOutboundApiCall(request, qResponse);
+
int statusCode = qResponse.getStatusCode();
if(statusCode == HttpStatus.SC_TOO_MANY_REQUESTS)
{
@@ -942,6 +950,46 @@ public class BaseAPIActionUtil
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ protected void logOutboundApiCall(HttpRequestBase request, QHttpResponse response)
+ {
+ QTableMetaData table = QContext.getQInstance().getTable(OutboundAPILog.TABLE_NAME);
+ if(table == null)
+ {
+ return;
+ }
+
+ String requestBody = null;
+ if(request instanceof HttpEntityEnclosingRequest entityRequest)
+ {
+ try
+ {
+ requestBody = StringUtils.join("\n", IOUtils.readLines(entityRequest.getEntity().getContent()));
+ }
+ catch(Exception e)
+ {
+ // leave it null...
+ }
+ }
+
+ InsertInput insertInput = new InsertInput();
+ insertInput.setTableName(table.getName());
+ insertInput.setRecords(List.of(new OutboundAPILog()
+ .withMethod(request.getMethod())
+ .withUrl(request.getURI().toString()) // todo - does this have the query string?
+ .withTimestamp(Instant.now())
+ .withRequestBody(requestBody)
+ .withStatusCode(response.getStatusCode())
+ .withResponseBody(response.getContent())
+ .toQRecord()
+ ));
+ new InsertAction().executeAsync(insertInput);
+ }
+
+
+
/*******************************************************************************
**
*******************************************************************************/
diff --git a/qqq-backend-module-api/src/main/java/com/kingsrook/qqq/backend/module/api/model/OutboundAPILog.java b/qqq-backend-module-api/src/main/java/com/kingsrook/qqq/backend/module/api/model/OutboundAPILog.java
new file mode 100644
index 00000000..7dc460bd
--- /dev/null
+++ b/qqq-backend-module-api/src/main/java/com/kingsrook/qqq/backend/module/api/model/OutboundAPILog.java
@@ -0,0 +1,320 @@
+/*
+ * 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.backend.module.api.model;
+
+
+import java.time.Instant;
+import com.kingsrook.qqq.backend.core.exceptions.QException;
+import com.kingsrook.qqq.backend.core.model.data.QField;
+import com.kingsrook.qqq.backend.core.model.data.QRecord;
+import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
+
+
+/*******************************************************************************
+ ** Entity bean for OutboundApiLog table
+ *******************************************************************************/
+public class OutboundAPILog extends QRecordEntity
+{
+ public static final String TABLE_NAME = "outboundApiLog";
+
+ @QField(isEditable = false)
+ private Integer id;
+
+ @QField()
+ private Instant timestamp;
+
+ @QField(possibleValueSourceName = "outboundApiMethod")
+ private String method;
+
+ @QField(possibleValueSourceName = "outboundApiStatusCode")
+ private Integer statusCode;
+
+ @QField(label = "URL")
+ private String url;
+
+ @QField()
+ private String requestBody;
+
+ @QField()
+ private String responseBody;
+
+
+
+ /*******************************************************************************
+ ** Constructor
+ **
+ *******************************************************************************/
+ public OutboundAPILog()
+ {
+ }
+
+
+
+ /*******************************************************************************
+ ** Constructor
+ **
+ *******************************************************************************/
+ public OutboundAPILog(QRecord qRecord) throws QException
+ {
+ populateFromQRecord(qRecord);
+ }
+
+
+
+ /*******************************************************************************
+ ** Getter for id
+ **
+ *******************************************************************************/
+ public Integer getId()
+ {
+ return id;
+ }
+
+
+
+ /*******************************************************************************
+ ** Setter for id
+ **
+ *******************************************************************************/
+ public void setId(Integer id)
+ {
+ this.id = id;
+ }
+
+
+
+ /*******************************************************************************
+ ** Fluent setter for id
+ **
+ *******************************************************************************/
+ public OutboundAPILog withId(Integer id)
+ {
+ this.id = id;
+ return (this);
+ }
+
+
+
+ /*******************************************************************************
+ ** Getter for timestamp
+ **
+ *******************************************************************************/
+ public Instant getTimestamp()
+ {
+ return timestamp;
+ }
+
+
+
+ /*******************************************************************************
+ ** Setter for timestamp
+ **
+ *******************************************************************************/
+ public void setTimestamp(Instant timestamp)
+ {
+ this.timestamp = timestamp;
+ }
+
+
+
+ /*******************************************************************************
+ ** Fluent setter for timestamp
+ **
+ *******************************************************************************/
+ public OutboundAPILog withTimestamp(Instant timestamp)
+ {
+ this.timestamp = timestamp;
+ return (this);
+ }
+
+
+
+ /*******************************************************************************
+ ** Getter for method
+ **
+ *******************************************************************************/
+ public String getMethod()
+ {
+ return method;
+ }
+
+
+
+ /*******************************************************************************
+ ** Setter for method
+ **
+ *******************************************************************************/
+ public void setMethod(String method)
+ {
+ this.method = method;
+ }
+
+
+
+ /*******************************************************************************
+ ** Fluent setter for method
+ **
+ *******************************************************************************/
+ public OutboundAPILog withMethod(String method)
+ {
+ this.method = method;
+ return (this);
+ }
+
+
+
+ /*******************************************************************************
+ ** Getter for statusCode
+ **
+ *******************************************************************************/
+ public Integer getStatusCode()
+ {
+ return statusCode;
+ }
+
+
+
+ /*******************************************************************************
+ ** Setter for statusCode
+ **
+ *******************************************************************************/
+ public void setStatusCode(Integer statusCode)
+ {
+ this.statusCode = statusCode;
+ }
+
+
+
+ /*******************************************************************************
+ ** Fluent setter for statusCode
+ **
+ *******************************************************************************/
+ public OutboundAPILog withStatusCode(Integer statusCode)
+ {
+ this.statusCode = statusCode;
+ return (this);
+ }
+
+
+
+ /*******************************************************************************
+ ** Getter for url
+ **
+ *******************************************************************************/
+ public String getUrl()
+ {
+ return url;
+ }
+
+
+
+ /*******************************************************************************
+ ** Setter for url
+ **
+ *******************************************************************************/
+ public void setUrl(String url)
+ {
+ this.url = url;
+ }
+
+
+
+ /*******************************************************************************
+ ** Fluent setter for url
+ **
+ *******************************************************************************/
+ public OutboundAPILog withUrl(String url)
+ {
+ this.url = url;
+ return (this);
+ }
+
+
+
+ /*******************************************************************************
+ ** Getter for requestBody
+ **
+ *******************************************************************************/
+ public String getRequestBody()
+ {
+ return requestBody;
+ }
+
+
+
+ /*******************************************************************************
+ ** Setter for requestBody
+ **
+ *******************************************************************************/
+ public void setRequestBody(String requestBody)
+ {
+ this.requestBody = requestBody;
+ }
+
+
+
+ /*******************************************************************************
+ ** Fluent setter for requestBody
+ **
+ *******************************************************************************/
+ public OutboundAPILog withRequestBody(String requestBody)
+ {
+ this.requestBody = requestBody;
+ return (this);
+ }
+
+
+
+ /*******************************************************************************
+ ** Getter for responseBody
+ **
+ *******************************************************************************/
+ public String getResponseBody()
+ {
+ return responseBody;
+ }
+
+
+
+ /*******************************************************************************
+ ** Setter for responseBody
+ **
+ *******************************************************************************/
+ public void setResponseBody(String responseBody)
+ {
+ this.responseBody = responseBody;
+ }
+
+
+
+ /*******************************************************************************
+ ** Fluent setter for responseBody
+ **
+ *******************************************************************************/
+ public OutboundAPILog withResponseBody(String responseBody)
+ {
+ this.responseBody = responseBody;
+ return (this);
+ }
+
+}
+
diff --git a/qqq-backend-module-api/src/main/java/com/kingsrook/qqq/backend/module/api/model/OutboundAPILogMetaDataProvider.java b/qqq-backend-module-api/src/main/java/com/kingsrook/qqq/backend/module/api/model/OutboundAPILogMetaDataProvider.java
new file mode 100644
index 00000000..7cb7e062
--- /dev/null
+++ b/qqq-backend-module-api/src/main/java/com/kingsrook/qqq/backend/module/api/model/OutboundAPILogMetaDataProvider.java
@@ -0,0 +1,153 @@
+/*
+ * 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.backend.module.api.model;
+
+
+import java.util.List;
+import java.util.function.Consumer;
+import com.kingsrook.qqq.backend.core.exceptions.QException;
+import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
+import com.kingsrook.qqq.backend.core.model.metadata.fields.AdornmentType;
+import com.kingsrook.qqq.backend.core.model.metadata.fields.FieldAdornment;
+import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior;
+import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
+import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue;
+import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
+import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
+import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
+import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
+import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
+import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
+
+
+/*******************************************************************************
+ **
+ *******************************************************************************/
+public class OutboundAPILogMetaDataProvider
+{
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public static void defineAll(QInstance qInstance, String backendName, Consumer backendDetailEnricher) throws QException
+ {
+ definePossibleValueSources(qInstance);
+ defineOutboundAPILogTable(qInstance, backendName, backendDetailEnricher);
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ private static void definePossibleValueSources(QInstance instance)
+ {
+ instance.addPossibleValueSource(new QPossibleValueSource()
+ .withName("outboundApiMethod")
+ .withType(QPossibleValueSourceType.ENUM)
+ .withEnumValues(List.of(
+ new QPossibleValue<>("GET"),
+ new QPossibleValue<>("POST"),
+ new QPossibleValue<>("PUT"),
+ new QPossibleValue<>("PATCH"),
+ new QPossibleValue<>("DELETE")
+ )));
+
+ instance.addPossibleValueSource(new QPossibleValueSource()
+ .withName("outboundApiStatusCode")
+ .withType(QPossibleValueSourceType.ENUM)
+ .withEnumValues(List.of(
+ new QPossibleValue<>(200, "200 (OK)"),
+ new QPossibleValue<>(201, "201 (Created)"),
+ new QPossibleValue<>(204, "204 (No Content)"),
+ new QPossibleValue<>(207, "207 (Multi-Status)"),
+ new QPossibleValue<>(400, "400 (Bad Request)"),
+ new QPossibleValue<>(401, "401 (Not Authorized)"),
+ new QPossibleValue<>(403, "403 (Forbidden)"),
+ new QPossibleValue<>(404, "404 (Not Found)"),
+ new QPossibleValue<>(429, "429 (Too Many Requests)"),
+ new QPossibleValue<>(500, "500 (Internal Server Error)")
+ )));
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ private static void defineOutboundAPILogTable(QInstance qInstance, String backendName, Consumer backendDetailEnricher) throws QException
+ {
+
+ QTableMetaData tableMetaData = new QTableMetaData()
+ .withName(OutboundAPILog.TABLE_NAME)
+ .withLabel("Outbound API Log")
+ .withIcon(new QIcon().withName("data_object"))
+ .withBackendName(backendName)
+ .withRecordLabelFormat("%s")
+ .withPrimaryKeyField("id")
+ .withFieldsFromEntity(OutboundAPILog.class)
+ .withSection(new QFieldSection("identity", new QIcon().withName("badge"), Tier.T1, List.of("id")))
+ .withSection(new QFieldSection("request", new QIcon().withName("arrow_upward"), Tier.T2, List.of("method", "url", "requestBody")))
+ .withSection(new QFieldSection("response", new QIcon().withName("arrow_downward"), Tier.T2, List.of("statusCode", "responseBody")))
+ .withSection(new QFieldSection("dates", new QIcon().withName("calendar_month"), Tier.T3, List.of("timestamp")))
+ .withoutCapabilities(Capability.TABLE_INSERT, Capability.TABLE_UPDATE, Capability.TABLE_DELETE);
+
+ tableMetaData.getField("requestBody").withFieldAdornment(new FieldAdornment(AdornmentType.CODE_EDITOR).withValue(AdornmentType.CodeEditorValues.languageMode("json")));
+ tableMetaData.getField("responseBody").withFieldAdornment(new FieldAdornment(AdornmentType.CODE_EDITOR).withValue(AdornmentType.CodeEditorValues.languageMode("json")));
+
+ tableMetaData.getField("method").withFieldAdornment(new FieldAdornment(AdornmentType.CHIP)
+ .withValue(AdornmentType.ChipValues.colorValue("GET", AdornmentType.ChipValues.COLOR_INFO))
+ .withValue(AdornmentType.ChipValues.colorValue("POST", AdornmentType.ChipValues.COLOR_SUCCESS))
+ .withValue(AdornmentType.ChipValues.colorValue("DELETE", AdornmentType.ChipValues.COLOR_ERROR))
+ .withValue(AdornmentType.ChipValues.colorValue("PATCH", AdornmentType.ChipValues.COLOR_WARNING)));
+
+ tableMetaData.getField("statusCode").withFieldAdornment(new FieldAdornment(AdornmentType.CHIP)
+ .withValue(AdornmentType.ChipValues.colorValue(200, AdornmentType.ChipValues.COLOR_SUCCESS))
+ .withValue(AdornmentType.ChipValues.colorValue(201, AdornmentType.ChipValues.COLOR_SUCCESS))
+ .withValue(AdornmentType.ChipValues.colorValue(204, AdornmentType.ChipValues.COLOR_SUCCESS))
+ .withValue(AdornmentType.ChipValues.colorValue(207, AdornmentType.ChipValues.COLOR_INFO))
+ .withValue(AdornmentType.ChipValues.colorValue(400, AdornmentType.ChipValues.COLOR_ERROR))
+ .withValue(AdornmentType.ChipValues.colorValue(401, AdornmentType.ChipValues.COLOR_ERROR))
+ .withValue(AdornmentType.ChipValues.colorValue(403, AdornmentType.ChipValues.COLOR_ERROR))
+ .withValue(AdornmentType.ChipValues.colorValue(404, AdornmentType.ChipValues.COLOR_ERROR))
+ .withValue(AdornmentType.ChipValues.colorValue(429, AdornmentType.ChipValues.COLOR_ERROR))
+ .withValue(AdornmentType.ChipValues.colorValue(500, AdornmentType.ChipValues.COLOR_ERROR)));
+
+ ///////////////////////////////////////////
+ // these are the lengths of a MySQL TEXT //
+ ///////////////////////////////////////////
+ tableMetaData.getField("requestBody").withMaxLength(65_535).withBehavior(ValueTooLongBehavior.TRUNCATE_ELLIPSIS);
+ tableMetaData.getField("responseBody").withMaxLength(65_535).withBehavior(ValueTooLongBehavior.TRUNCATE_ELLIPSIS);
+
+ /////////////////////////
+ // limit url to 250... //
+ /////////////////////////
+ tableMetaData.getField("url").withMaxLength(250).withBehavior(ValueTooLongBehavior.TRUNCATE_ELLIPSIS);
+
+ if(backendDetailEnricher != null)
+ {
+ backendDetailEnricher.accept(tableMetaData);
+ }
+
+ qInstance.addTable(tableMetaData);
+ }
+}