111 lines
3.4 KiB
TypeScript
111 lines
3.4 KiB
TypeScript
import { json } from "@remix-run/server-runtime";
|
|
import { QueryError } from "@internal/clickhouse";
|
|
import { z } from "zod";
|
|
import { createActionApiRoute, everyResource } from "~/services/routeBuilders/apiBuilder.server";
|
|
import { executeQuery, type QueryScope } from "~/services/queryService.server";
|
|
import { logger } from "~/services/logger.server";
|
|
import { rowsToCSV } from "~/utils/dataExport";
|
|
import { detectQueryTables } from "~/v3/detectQueryTables";
|
|
import { querySchemas } from "~/v3/querySchemas";
|
|
|
|
const BodySchema = z.object({
|
|
query: z.string(),
|
|
scope: z.enum(["organization", "project", "environment"]).default("environment"),
|
|
period: z.string().nullish(),
|
|
from: z.string().nullish(),
|
|
to: z.string().nullish(),
|
|
format: z.enum(["json", "csv"]).default("json"),
|
|
});
|
|
|
|
const allowedQueryTables = new Set(querySchemas.map((s) => s.name));
|
|
|
|
/** Every table the query reads, for per-table JWT-scope authorization.
|
|
* `null` means unparseable — callers deny by default. */
|
|
function detectTables(query: string): string[] | null {
|
|
return detectQueryTables(query, allowedQueryTables);
|
|
}
|
|
|
|
const { action, loader } = createActionApiRoute(
|
|
{
|
|
body: BodySchema,
|
|
allowJWT: true,
|
|
corsStrategy: "all",
|
|
findResource: async () => 1,
|
|
authorization: {
|
|
action: "read",
|
|
// A multi-table query reads from every detected table, so wrap with
|
|
// everyResource: a JWT scoped to one table must not pass auth for a
|
|
// query that also reads tables it isn't scoped to.
|
|
resource: (_, __, ___, body) => {
|
|
const tables = detectTables(body.query);
|
|
// Unparseable query → deny. It must not fall through to the
|
|
// permissive {type:"query",id:"all"} branch.
|
|
if (tables === null) return { type: "query", id: "__unparseable__" };
|
|
return tables.length > 0
|
|
? everyResource(tables.map((id) => ({ type: "query", id })))
|
|
: { type: "query", id: "all" };
|
|
},
|
|
},
|
|
},
|
|
async ({ body, authentication }) => {
|
|
const { query, scope, period, from, to, format } = body;
|
|
const env = authentication.environment;
|
|
|
|
const queryResult = await executeQuery({
|
|
name: "api-query",
|
|
query,
|
|
scope: scope as QueryScope,
|
|
organizationId: env.organization.id,
|
|
projectId: env.project.id,
|
|
environmentId: env.id,
|
|
period,
|
|
from,
|
|
to,
|
|
history: {
|
|
source: "API",
|
|
},
|
|
});
|
|
|
|
if (!queryResult.success) {
|
|
// QueryError surfaces customer SQL problems (invalid syntax,
|
|
// unsupported construct). Returned to the caller as 400; system
|
|
// handles it gracefully, no alert needed.
|
|
if (queryResult.error instanceof QueryError) {
|
|
logger.warn("Query API error", {
|
|
error: queryResult.error.message,
|
|
query,
|
|
});
|
|
return json({ error: queryResult.error.message }, { status: 400 });
|
|
}
|
|
|
|
logger.error("Query API error", {
|
|
error: queryResult.error,
|
|
query,
|
|
});
|
|
|
|
return json(
|
|
{ error: "An unexpected error occurred while executing the query." },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
|
|
const { result, periodClipped: _periodClipped, maxQueryPeriod: _maxQueryPeriod } = queryResult;
|
|
|
|
if (format === "csv") {
|
|
const csv = rowsToCSV(result.rows, result.columns);
|
|
|
|
return json({
|
|
format: "csv",
|
|
results: csv,
|
|
});
|
|
}
|
|
|
|
return json({
|
|
format: "json",
|
|
results: result.rows,
|
|
});
|
|
}
|
|
);
|
|
|
|
export { action, loader };
|