Files
qbit-data-analysis/src/main/java/com/kingsrook/qbits/dataanalysis/api/AnalysisFileSpec.java
T
james.maesandClaude Opus 5.5 d2c9871d98 feat(saved): saved, shared, starred, scheduled and widget analyses, export, SQL handoff
Extension 1.4.0: saved analyses of every kind re-run as the viewer
(fields they cannot analyze are dropped), owner-or-shared READ through
core record locks with owner-only writes (and a database CHECK so a
private row can never lose its read owner), personal stars, schedules
that run as the owner and mail only recipients who could run the analysis
themselves, CSV/XLSX export, the SQL Workbench handoff, an analysis
dashboard widget, and an Analyses home built from standard widgets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:23:23 -05:00

196 lines
9.7 KiB
Java

/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* 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
*
* https://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 com.kingsrook.qbits.dataanalysis.api;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.dataanalysis.DataAnalysisRuntime;
import com.kingsrook.qbits.dataanalysis.saved.AnalysisExport;
import com.kingsrook.qbits.dataanalysis.saved.SavedDefinition;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.middleware.javalin.executors.AbstractMiddlewareExecutor;
import com.kingsrook.qqq.middleware.javalin.executors.io.AbstractMiddlewareInput;
import com.kingsrook.qqq.middleware.javalin.executors.io.AbstractMiddlewareOutputInterface;
import com.kingsrook.qqq.middleware.javalin.specs.AbstractEndpointSpec;
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
import com.kingsrook.qqq.middleware.javalin.specs.BasicResponse;
import com.kingsrook.qqq.openapi.model.Content;
import com.kingsrook.qqq.openapi.model.HttpMethod;
import com.kingsrook.qqq.openapi.model.In;
import com.kingsrook.qqq.openapi.model.Parameter;
import com.kingsrook.qqq.openapi.model.RequestBody;
import com.kingsrook.qqq.openapi.model.Response;
import com.kingsrook.qqq.openapi.model.Schema;
import com.kingsrook.qqq.openapi.model.Type;
import io.javalin.http.Context;
/*******************************************************************************
** Binary endpoints extend AbstractEndpointSpec directly, preserving core sessions.
*******************************************************************************/
public class AnalysisFileSpec extends AbstractEndpointSpec<AnalysisFileSpec.Input, AnalysisFileSpec.Output, AnalysisFileSpec.Executor>
{
/*******************************************************************************
** POST an arbitrary supported typed request plus CSV/XLSX format.
*******************************************************************************/
@Override
public BasicOperation defineBasicOperation()
{
return (new BasicOperation().withPath(DataAnalysisApi.PREFIX + "/export").withHttpMethod(HttpMethod.POST)
.withShortSummary("Export an analysis result").withLongDescription("Execute a typed analysis as the signed-in viewer and download its result table as CSV or XLSX. Requires source READ/Analyze and FULL fields; uses core report formats."));
}
/*******************************************************************************
** Saved export has only id/format parameters; ad hoc export uses a JSON body.
*******************************************************************************/
@Override
public List<Parameter> defineRequestParameters()
{
return (List.of());
}
/*******************************************************************************
** Typed discriminated request plus file format.
*******************************************************************************/
@Override
public RequestBody defineRequestBody()
{
return (new RequestBody().withRequired(true).withContent(Map.of("application/json", new Content()
.withSchema(new Schema().withRef("#/components/schemas/DataAnalysisSavedRequestV1")))));
}
/*******************************************************************************
** Both file representations are declared, without JSON response wrapping.
*******************************************************************************/
@Override
public Map<Integer, Response> defineResponses()
{
Schema file = new Schema().withRef("#/components/schemas/DataAnalysisFileV1");
return (Map.of(200, new Response().withDescription("Analysis result file").withContent(new java.util.TreeMap<>(Map.of("text/csv", new Content().withSchema(file),
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", new Content().withSchema(file))))));
}
/*******************************************************************************
** Read transport data only; execution waits for middleware session setup.
*******************************************************************************/
@Override
public Input buildInput(Context context) throws Exception
{
Input input = new Input();
if(this instanceof Saved)
{
input.id = SavedApi.id(context.pathParam("id"));
input.format = context.queryParam("format");
}
else
{
if(context.contentType() == null || !context.contentType().toLowerCase(java.util.Locale.ROOT).startsWith("application/json"))
{
throw new QBadRequestException("The request body must be JSON.");
}
input.body = SavedDefinition.object(context.body());
input.format = input.body.get("format") instanceof String value ? value : null;
}
AnalysisExport.format(input.format);
return (input);
}
/*******************************************************************************
** Actual file bytes, a stable filename and the matching MIME type.
*******************************************************************************/
@Override
public void handleOutput(Context context, Output output)
{
context.contentType(output.file.mimeType()).header("Content-Disposition", "attachment; filename=\"" + output.file.filename() + "\"")
.header("Cache-Control", "no-store").result(output.file.bytes());
}
/*******************************************************************************
** Saved GET variant uses the same file executor.
*******************************************************************************/
public static class Saved extends AnalysisFileSpec
{
/*******************************************************************************
** Core-visible saved definition is re-run as viewer before export.
*******************************************************************************/
@Override
public BasicOperation defineBasicOperation()
{
return (new BasicOperation().withPath(DataAnalysisApi.PREFIX + "/saved/{id}/export").withHttpMethod(HttpMethod.GET)
.withShortSummary("Export a saved analysis").withLongDescription("Re-run a visible saved analysis as the viewer, applying current source permissions and dropping unavailable fields, then download its result table as CSV or XLSX."));
}
/*******************************************************************************
** Required id and CSV/XLSX query format.
*******************************************************************************/
@Override
public List<Parameter> defineRequestParameters()
{
return (List.of(new Parameter().withName("id").withIn(In.PATH).withRequired(true).withDescription("Visible saved analysis id.").withSchema(new Schema().withType(Type.INTEGER)),
new Parameter().withName("format").withIn(In.QUERY).withRequired(true).withDescription("Result file format.").withSchema(new Schema().withType(Type.STRING).withEnumValues(List.of("csv", "xlsx")))));
}
/*******************************************************************************
** GET has no request body.
*******************************************************************************/
@Override
public RequestBody defineRequestBody()
{
return (null);
}
}
/*******************************************************************************
** Input data only, inherited middleware fields carry the authenticated session.
*******************************************************************************/
public static class Input extends AbstractMiddlewareInput
{
private Integer id;
private String format;
private Map<String, Object> body;
}
/*******************************************************************************
** Binary output stays out of JSON serialization.
*******************************************************************************/
public static class Output implements AbstractMiddlewareOutputInterface
{
private AnalysisExport.File file;
}
/*******************************************************************************
** Invoked after the normal middleware session pipeline.
*******************************************************************************/
public static class Executor extends AbstractMiddlewareExecutor<Input, Output>
{
/*******************************************************************************
** No direct source access outside the existing authorized analysis engine.
*******************************************************************************/
@Override
public void execute(Input input, Output output) throws QException
{
var result = input.id == null ? SavedApi.definition(input.body, null).run(DataAnalysisRuntime.service(), true)
: DataAnalysisRuntime.saved().run(input.id);
output.file = AnalysisExport.file(result, input.format);
}
}
}