CE-881 - First round of adjustments to api-middleware for rendering saved-reports:

- add path params as concept
- add customizeHttpApiResponse to ApiProcessOutputInterface
- add contentType and needsFormattedAsJson in HttpApiResponse
This commit is contained in:
2024-03-29 08:42:34 -05:00
parent b37e26e03b
commit 98e9d1bf57
10 changed files with 284 additions and 28 deletions

View File

@ -1000,6 +1000,7 @@ public class ApiImplementation
ApiProcessInput apiProcessInput = apiProcessMetaData.getInput();
if(apiProcessInput != null)
{
processProcessInputFields(paramMap, badRequestMessages, runProcessInput, apiProcessInput.getPathParams());
processProcessInputFields(paramMap, badRequestMessages, runProcessInput, apiProcessInput.getQueryStringParams());
processProcessInputFields(paramMap, badRequestMessages, runProcessInput, apiProcessInput.getFormParams());
processProcessInputFields(paramMap, badRequestMessages, runProcessInput, apiProcessInput.getObjectBodyParams());
@ -1143,7 +1144,10 @@ public class ApiImplementation
ApiProcessOutputInterface output = apiProcessMetaData.getOutput();
if(output != null)
{
return (new HttpApiResponse(output.getSuccessStatusCode(runProcessInput, runProcessOutput), output.getOutputForProcess(runProcessInput, runProcessOutput)));
Serializable outputForProcess = output.getOutputForProcess(runProcessInput, runProcessOutput);
HttpApiResponse httpApiResponse = new HttpApiResponse(output.getSuccessStatusCode(runProcessInput, runProcessOutput), outputForProcess);
output.customizeHttpApiResponse(httpApiResponse, runProcessInput, runProcessOutput);
return httpApiResponse;
}
else
{

View File

@ -816,7 +816,7 @@ public class GenerateOpenApiSpecAction extends AbstractQActionFunction<GenerateO
openAPI.getPaths().put(basePath + processApiPath, path);
///////////////////////////////////////////////////////////////////////
// if the process can run async, then do the status checkin endpoitn //
// if the process can run async, then do the status checkin endpoint //
///////////////////////////////////////////////////////////////////////
if(!ApiProcessMetaData.AsyncMode.NEVER.equals(apiProcessMetaData.getAsyncMode()))
{
@ -900,12 +900,27 @@ public class GenerateOpenApiSpecAction extends AbstractQActionFunction<GenerateO
String apiName = apiInstanceMetaData.getName();
if(apiProcessInput != null)
{
/////////////////
// path params //
/////////////////
ApiProcessInputFieldsContainer pathParams = apiProcessInput.getPathParams();
if(pathParams != null)
{
for(QFieldMetaData field : CollectionUtils.nonNullList(pathParams.getFields()))
{
parameters.add(processFieldToParameter(apiInstanceMetaData, field).withIn("path"));
}
}
/////////////////////////
// query string params //
/////////////////////////
ApiProcessInputFieldsContainer queryStringParams = apiProcessInput.getQueryStringParams();
if(queryStringParams != null)
{
if(queryStringParams.getRecordIdsField() != null)
{
parameters.add(processFieldToParameter(apiInstanceMetaData, queryStringParams.getRecordIdsField()).withIn("query"));
parameters.add(processFieldToParameter(apiInstanceMetaData, queryStringParams.getRecordIdsField()).withIn("path"));
}
for(QFieldMetaData field : CollectionUtils.nonNullList(queryStringParams.getFields()))
@ -914,6 +929,9 @@ public class GenerateOpenApiSpecAction extends AbstractQActionFunction<GenerateO
}
}
/////////////////////
// Body as content //
/////////////////////
QFieldMetaData bodyField = apiProcessInput.getBodyField();
if(bodyField != null)
{
@ -939,8 +957,7 @@ public class GenerateOpenApiSpecAction extends AbstractQActionFunction<GenerateO
content.withSchema(new Schema()
.withDescription(bodyDescription)
.withType("string")
.withExample(exampleWithSingleValue.getValue())
);
.withExample(exampleWithSingleValue.getValue()));
}
methodForProcess.withRequestBody(new RequestBody()
@ -963,8 +980,9 @@ public class GenerateOpenApiSpecAction extends AbstractQActionFunction<GenerateO
.withIn("query")
.withDescription("""
Indicates if the job should be ran asynchronously.
If false, or not specified, job is ran synchronously, and returns with response status of 207 (Multi-Status) or 204 (No Content).
If true, request returns immediately with response status of 202 (Accepted).
If false or not specified, then the job is ran synchronously and returns with an appropriate response status when completed.
If true, then the request returns immediately with response status of 202 (Accepted), and a jobId in the response body,
which can then be sent to the corresponding .../status/{jobId} endpoint to follow-up on the status of the job.
""")
.withExamples(MapBuilder.of(
"false", new ExampleWithSingleValue().withValue(false).withSummary("Run the job synchronously."),
@ -988,6 +1006,7 @@ public class GenerateOpenApiSpecAction extends AbstractQActionFunction<GenerateO
{
responses.putAll(output.getSpecResponses(apiName));
}
if(!ApiProcessMetaData.AsyncMode.NEVER.equals(apiProcessMetaData.getAsyncMode()))
{
responses.put(HttpStatus.ACCEPTED.getCode(), new Response()
@ -1046,16 +1065,28 @@ public class GenerateOpenApiSpecAction extends AbstractQActionFunction<GenerateO
.withDescription("Get the status for a previous asynchronous call to the process named " + processMetaData.getLabel())
.withSecurity(getSecurity(apiInstanceMetaData, processMetaData.getName()));
////////////////////////////////////////////////////////
// add the async input for optionally-async processes //
////////////////////////////////////////////////////////
methodForProcess.setParameters(ListBuilder.of(new Parameter()
List<Parameter> parameters = new ArrayList<>();
ApiProcessInput apiProcessInput = apiProcessMetaData.getInput();
ApiProcessInputFieldsContainer pathParams = apiProcessInput.getPathParams();
if(pathParams != null)
{
for(QFieldMetaData field : CollectionUtils.nonNullList(pathParams.getFields()))
{
parameters.add(processFieldToParameter(apiInstanceMetaData, field).withIn("path"));
}
}
parameters.add(new Parameter()
.withName("jobId")
.withIn("path")
.withRequired(true)
.withDescription("Id of the job, as returned by the API call that started it.")
.withSchema(new Schema().withType("string").withFormat("uuid"))
));
.withSchema(new Schema().withType("string").withFormat("uuid")));
////////////////////////////////////////////////////////
// add the async input for optionally-async processes //
////////////////////////////////////////////////////////
methodForProcess.setParameters(parameters);
//////////////////////////////////
// build all possible responses //
@ -1126,7 +1157,7 @@ public class GenerateOpenApiSpecAction extends AbstractQActionFunction<GenerateO
{
if(apiFieldMetaData.getExample() != null)
{
parameter.withExample(apiFieldMetaData.getExample());
parameter.withExamples(Map.of("example", apiFieldMetaData.getExample()));
}
else if(apiFieldMetaData.getExamples() != null)
{

View File

@ -316,6 +316,7 @@ public class QJavalinApiHandler
ApiProcessInput input = apiProcessMetaData.getInput();
if(input != null)
{
processProcessInputFieldsContainer(context, parameters, input.getPathParams(), Context::pathParam);
processProcessInputFieldsContainer(context, parameters, input.getQueryStringParams(), Context::queryParam);
processProcessInputFieldsContainer(context, parameters, input.getFormParams(), Context::formParam);
@ -347,9 +348,7 @@ public class QJavalinApiHandler
//////////////////
QJavalinAccessLogger.logEndSuccess();
context.status(response.getStatusCode().getCode());
String resultString = toJson(Objects.requireNonNullElse(response.getResponseBodyObject(), ""));
context.result(resultString);
storeApiLog(apiLog.withStatusCode(context.statusCode()).withResponseBody(resultString));
handleProcessResponse(context, response, apiLog);
}
catch(Exception e)
{
@ -360,6 +359,52 @@ public class QJavalinApiHandler
/*******************************************************************************
**
*******************************************************************************/
private static void handleProcessResponse(Context context, HttpApiResponse response, APILog apiLog)
{
if(response.getNeedsFormattedAsJson())
{
/////////////////////////////////////////////////////////////////////////////////////////
// if the response object says that we should format the response as json, then do so. //
/////////////////////////////////////////////////////////////////////////////////////////
String resultString = toJson(Objects.requireNonNullElse(response.getResponseBodyObject(), ""));
context.result(resultString);
storeApiLog(apiLog.withStatusCode(context.statusCode()).withResponseBody(resultString));
}
else
{
if(StringUtils.hasContent(response.getContentType()))
{
context.contentType(response.getContentType());
}
////////////////////////////////////////////////////////////////////////////////////
// else, try to return it raw - as byte[], or String, or as a converted-to-String //
////////////////////////////////////////////////////////////////////////////////////
Serializable result = Objects.requireNonNullElse(response.getResponseBodyObject(), "");
if(result instanceof byte[] ba)
{
context.result(ba);
storeApiLog(apiLog.withStatusCode(context.statusCode()).withResponseBody("Byte array of length: " + ba.length));
}
else if(result instanceof String s)
{
context.result(s);
storeApiLog(apiLog.withStatusCode(context.statusCode()).withResponseBody(s));
}
else
{
String resultString = String.valueOf(result);
context.result(resultString);
storeApiLog(apiLog.withStatusCode(context.statusCode()).withResponseBody(resultString));
}
}
}
/*******************************************************************************
**
*******************************************************************************/
@ -381,9 +426,7 @@ public class QJavalinApiHandler
//////////////////
QJavalinAccessLogger.logEndSuccess();
context.status(response.getStatusCode().getCode());
String resultString = toJson(Objects.requireNonNullElse(response.getResponseBodyObject(), ""));
context.result(resultString);
storeApiLog(apiLog.withStatusCode(context.statusCode()).withResponseBody(resultString));
handleProcessResponse(context, response, apiLog);
}
catch(Exception e)
{

View File

@ -35,6 +35,14 @@ public class HttpApiResponse
private HttpStatus.Code statusCode;
private Serializable responseBodyObject;
private String contentType;
////////////////////////////////////////////////////////////////////////////////////////////////////////
// by default - QJavalinApiHandler will format the responseBodyObject as JSON. //
// set this field to false if you don't want it to do that (e.g., if your response is, say, a byte[]) //
////////////////////////////////////////////////////////////////////////////////////////////////////////
private boolean needsFormattedAsJson = true;
/*******************************************************************************
@ -119,4 +127,66 @@ public class HttpApiResponse
return (this);
}
/*******************************************************************************
** Getter for needsFormattedAsJson
*******************************************************************************/
public boolean getNeedsFormattedAsJson()
{
return (this.needsFormattedAsJson);
}
/*******************************************************************************
** Setter for needsFormattedAsJson
*******************************************************************************/
public void setNeedsFormattedAsJson(boolean needsFormattedAsJson)
{
this.needsFormattedAsJson = needsFormattedAsJson;
}
/*******************************************************************************
** Fluent setter for needsFormattedAsJson
*******************************************************************************/
public HttpApiResponse withNeedsFormattedAsJson(boolean needsFormattedAsJson)
{
this.needsFormattedAsJson = needsFormattedAsJson;
return (this);
}
/*******************************************************************************
** Getter for contentType
*******************************************************************************/
public String getContentType()
{
return (this.contentType);
}
/*******************************************************************************
** Setter for contentType
*******************************************************************************/
public void setContentType(String contentType)
{
this.contentType = contentType;
}
/*******************************************************************************
** Fluent setter for contentType
*******************************************************************************/
public HttpApiResponse withContentType(String contentType)
{
this.contentType = contentType;
return (this);
}
}

View File

@ -30,6 +30,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
*******************************************************************************/
public class ApiProcessInput
{
private ApiProcessInputFieldsContainer pathParams;
private ApiProcessInputFieldsContainer queryStringParams;
private ApiProcessInputFieldsContainer formParams;
private ApiProcessInputFieldsContainer recordBodyParams;
@ -44,6 +45,11 @@ public class ApiProcessInput
*******************************************************************************/
public String getRecordIdsParamName()
{
if(pathParams != null && pathParams.getRecordIdsField() != null)
{
return (pathParams.getRecordIdsField().getName());
}
if(queryStringParams != null && queryStringParams.getRecordIdsField() != null)
{
return (queryStringParams.getRecordIdsField().getName());
@ -217,4 +223,35 @@ public class ApiProcessInput
return (this);
}
/*******************************************************************************
** Getter for pathParams
*******************************************************************************/
public ApiProcessInputFieldsContainer getPathParams()
{
return (this.pathParams);
}
/*******************************************************************************
** Setter for pathParams
*******************************************************************************/
public void setPathParams(ApiProcessInputFieldsContainer pathParams)
{
this.pathParams = pathParams;
}
/*******************************************************************************
** Fluent setter for pathParams
*******************************************************************************/
public ApiProcessInput withPathParams(ApiProcessInputFieldsContainer pathParams)
{
this.pathParams = pathParams;
return (this);
}
}

View File

@ -24,6 +24,7 @@ package com.kingsrook.qqq.api.model.metadata.processes;
import java.io.Serializable;
import java.util.Map;
import com.kingsrook.qqq.api.model.actions.HttpApiResponse;
import com.kingsrook.qqq.api.model.openapi.Response;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
@ -61,4 +62,13 @@ public interface ApiProcessOutputInterface
.withDescription("Process has been successfully executed.")
));
}
/*******************************************************************************
**
*******************************************************************************/
default void customizeHttpApiResponse(HttpApiResponse httpApiResponse, RunProcessInput runProcessInput, RunProcessOutput runProcessOutput) throws QException
{
}
}

View File

@ -34,9 +34,11 @@ import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
import com.kingsrook.qqq.backend.core.logging.LogPair;
import com.kingsrook.qqq.backend.core.logging.QLogger;
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.processes.QProcessMetaData;
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.ObjectUtils;
import com.kingsrook.qqq.backend.core.utils.Pair;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import org.apache.commons.lang.BooleanUtils;
@ -162,9 +164,18 @@ public class ApiProcessUtils
*******************************************************************************/
public static String getProcessApiPath(QInstance qInstance, QProcessMetaData process, ApiProcessMetaData apiProcessMetaData, ApiInstanceMetaData apiInstanceMetaData)
{
StringBuilder pathParams = new StringBuilder();
if(ObjectUtils.ifCan(() -> CollectionUtils.nullSafeHasContents(apiProcessMetaData.getInput().getPathParams().getFields())))
{
for(QFieldMetaData field : apiProcessMetaData.getInput().getPathParams().getFields())
{
pathParams.append("/{").append(field.getName()).append("}");
}
}
if(StringUtils.hasContent(apiProcessMetaData.getPath()))
{
return apiProcessMetaData.getPath() + "/" + apiProcessMetaData.getApiProcessName();
return apiProcessMetaData.getPath() + "/" + apiProcessMetaData.getApiProcessName() + pathParams;
}
else if(StringUtils.hasContent(process.getTableName()))
{
@ -182,11 +193,11 @@ public class ApiProcessUtils
}
}
}
return tablePathPart + "/" + apiProcessMetaData.getApiProcessName();
return tablePathPart + "/" + apiProcessMetaData.getApiProcessName() + pathParams;
}
else
{
return apiProcessMetaData.getApiProcessName();
return apiProcessMetaData.getApiProcessName() + pathParams;
}
}

View File

@ -23,6 +23,7 @@ package com.kingsrook.qqq.api;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.session.QSession;
@ -56,7 +57,7 @@ public class BaseTest
**
*******************************************************************************/
@BeforeEach
void baseBeforeEach()
void baseBeforeEach() throws QException
{
QContext.init(TestUtils.defineInstance(), new QSession());
}

View File

@ -25,7 +25,6 @@ package com.kingsrook.qqq.api.javalin;
import com.kingsrook.qqq.api.BaseTest;
import com.kingsrook.qqq.api.TestUtils;
import com.kingsrook.qqq.api.actions.ApiImplementation;
import com.kingsrook.qqq.backend.core.exceptions.QInstanceValidationException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
@ -61,7 +60,7 @@ class QJavalinApiHandlerPermissionsTest extends BaseTest
**
*******************************************************************************/
@BeforeAll
static void beforeAll() throws QInstanceValidationException
static void beforeAll() throws Exception
{
QInstance qInstance = TestUtils.defineInstance();

View File

@ -36,7 +36,6 @@ 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.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;
@ -51,6 +50,8 @@ 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.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.savedreports.SavedReport;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import com.kingsrook.qqq.backend.core.utils.SleepUtils;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import com.kingsrook.qqq.backend.javalin.QJavalinImplementation;
@ -66,6 +67,7 @@ import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static com.kingsrook.qqq.api.TestUtils.insertPersonRecord;
import static com.kingsrook.qqq.api.TestUtils.insertSavedReport;
import static com.kingsrook.qqq.api.TestUtils.insertSimpsons;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
@ -95,7 +97,7 @@ class QJavalinApiHandlerTest extends BaseTest
**
*******************************************************************************/
@BeforeAll
static void beforeAll() throws QInstanceValidationException
static void beforeAll() throws Exception
{
QInstance qInstance = TestUtils.defineInstance();
@ -1404,6 +1406,54 @@ class QJavalinApiHandlerTest extends BaseTest
/*******************************************************************************
**
*******************************************************************************/
@Test
void testGetProcessRenderSavedReport() throws QException
{
insertSimpsons();
Integer reportId = insertSavedReport(new SavedReport()
.withLabel("Person Report")
.withTableName(TestUtils.TABLE_NAME_PERSON)
.withQueryFilterJson(JsonUtils.toJson(new QQueryFilter()))
.withColumnsJson("""
{"columns":[
{"name": "id"},
{"name": "firstName"},
{"name": "lastName"}
]}
"""));
HttpResponse<String> response = Unirest.get(BASE_URL + "/api/" + VERSION + "/savedReport/renderSavedReport/" + reportId + "?reportFormat=CSV").asString();
assertEquals(HttpStatus.OK_200, response.getStatus());
assertThat(response.getHeaders().getFirst("content-type")).contains("csv");
assertEquals("""
"Id","First Name","Last Name"
"1","Homer","Simpson"
"2","Marge","Simpson"
"3","Bart","Simpson"
"4","Lisa","Simpson"
"5","Maggie","Simpson"
""", response.getBody());
response = Unirest.get(BASE_URL + "/api/" + VERSION + "/savedReport/renderSavedReport/" + reportId + "?reportFormat=JSON").asString();
assertEquals(HttpStatus.OK_200, response.getStatus());
assertThat(response.getHeaders().getFirst("content-type")).contains("json");
JSONArray jsonArray = new JSONArray(response.getBody());
assertEquals(5, jsonArray.length());
assertThat(jsonArray.getJSONObject(0).toMap())
.hasFieldOrPropertyWithValue("id", 1)
.hasFieldOrPropertyWithValue("firstName", "Homer")
.hasFieldOrPropertyWithValue("lastName", "Simpson");
response = Unirest.get(BASE_URL + "/api/" + VERSION + "/savedReport/renderSavedReport/" + reportId + "?reportFormat=XLSX").asString();
assertEquals(HttpStatus.OK_200, response.getStatus());
assertThat(response.getHeaders().getFirst("content-type")).contains("openxmlformats-officedocument.spreadsheetml");
}
/*******************************************************************************
**
*******************************************************************************/