Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
@@ -0,0 +1,18 @@
|
||||
import { Fragment } from "react";
|
||||
import { Colors } from "~/application/enums/shared/Colors";
|
||||
import SimpleBadge from "~/components/ui/badges/SimpleBadge";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function ApiCallSpeedBadge({ duration }: { duration: number }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{duration < 10 && <SimpleBadge title={t("metrics.speed.lightningFast")} color={Colors.GREEN} />}
|
||||
{duration >= 10 && duration < 100 && <SimpleBadge title={t("metrics.speed.fast")} color={Colors.TEAL} />}
|
||||
{duration >= 100 && duration < 1000 && <SimpleBadge title={t("metrics.speed.average")} color={Colors.YELLOW} />}
|
||||
{duration >= 1000 && duration < 2000 && <SimpleBadge title={t("metrics.speed.slow")} color={Colors.ORANGE} />}
|
||||
{duration >= 2000 && <SimpleBadge title={t("metrics.speed.verySlow")} color={Colors.RED} />}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Fragment } from "react";
|
||||
import { Colors } from "~/application/enums/shared/Colors";
|
||||
import SimpleBadge from "~/components/ui/badges/SimpleBadge";
|
||||
import { ApiKeyLogDto } from "../dtos/ApiKeyLogDto";
|
||||
|
||||
export default function ApiCallStatusBadge({ item, underline }: { item: ApiKeyLogDto; underline?: boolean }) {
|
||||
const title = item.status?.toString() ?? "?";
|
||||
const color =
|
||||
item.status === null
|
||||
? Colors.YELLOW
|
||||
: item.status >= 200 && item.status < 300
|
||||
? Colors.GREEN
|
||||
: item.status >= 300 && item.status < 400
|
||||
? Colors.ORANGE
|
||||
: item.status >= 400
|
||||
? Colors.RED
|
||||
: Colors.UNDEFINED;
|
||||
return (
|
||||
<Fragment>
|
||||
<SimpleBadge title={title} color={color} underline={underline} />
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { ApiEndpointDto } from "../dtos/ApiEndpointDto";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import CollapsibleRow from "~/components/ui/tables/CollapsibleRow";
|
||||
import ApiEndpointTitle from "./ApiEndpointTitle";
|
||||
import MonacoEditor from "~/components/editors/MonacoEditor";
|
||||
|
||||
export default function ApiEndpoint({ item }: { item: ApiEndpointDto }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CollapsibleRow className="bg-secondary" value={<ApiEndpointTitle item={item} />} title={<ApiEndpointTitle item={item} />}>
|
||||
<div className="space-y-1 overflow-hidden pt-2">
|
||||
<div className="space-y-2">
|
||||
{item.responseSchema && (
|
||||
<>
|
||||
<div className="text-foreground text-sm font-bold">{t("api.docs.responseSchema")}</div>
|
||||
<div className="h-96 overflow-auto p-2">
|
||||
<MonacoEditor value={item.responseSchema} onChange={() => {}} theme="vs-dark" language="json" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{item.bodyExample && (
|
||||
<>
|
||||
<div className="text-foreground text-sm font-bold">{t("api.docs.bodyExample")}</div>
|
||||
<div className="h-96 overflow-auto p-2">
|
||||
<MonacoEditor value={item.bodyExample} onChange={() => {}} theme="vs-dark" language="json" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleRow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import ApiUtils from "~/utils/app/ApiUtils";
|
||||
import { ApiEndpointDto } from "../dtos/ApiEndpointDto";
|
||||
import SimpleBadge from "~/components/ui/badges/SimpleBadge";
|
||||
|
||||
export default function ApiEndpointTitle({ item }: { item: ApiEndpointDto }) {
|
||||
return (
|
||||
<div className="flex flex-col truncate">
|
||||
<div className="flex items-center space-x-2">
|
||||
<SimpleBadge title={item.method} color={ApiUtils.getMethodColor(item.method)} />
|
||||
<h3 className="text-foreground text-sm font-bold">{item.route}</h3>
|
||||
</div>
|
||||
<div className="text-muted-foreground truncate text-xs italic">{item.description}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import InputSearch from "~/components/ui/input/InputSearch";
|
||||
import type { ApiEndpointDto } from "../dtos/ApiEndpointDto";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import EmptyState from "~/components/ui/emptyState/EmptyState";
|
||||
import ApiEndpoint from "./ApiEndpoint";
|
||||
|
||||
export default function ApiEndpointsTable({ items }: { items: ApiEndpointDto[] }) {
|
||||
const { t } = useTranslation();
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const filteredItems = useMemo(() => {
|
||||
const normalizedSearch = searchInput.trim().toLowerCase();
|
||||
if (!normalizedSearch) return items;
|
||||
return items.filter((f) => {
|
||||
return (
|
||||
f.route.toLowerCase().includes(normalizedSearch) ||
|
||||
f.method.toLowerCase().includes(normalizedSearch) ||
|
||||
f.description.toLowerCase().includes(normalizedSearch) ||
|
||||
f.entity.name.toLowerCase().includes(normalizedSearch) ||
|
||||
f.entity.title.toLowerCase().includes(normalizedSearch) ||
|
||||
f.entity.titlePlural.toLowerCase().includes(normalizedSearch) ||
|
||||
t(f.entity.title).toLowerCase().includes(normalizedSearch) ||
|
||||
t(f.entity.titlePlural).toLowerCase().includes(normalizedSearch)
|
||||
);
|
||||
});
|
||||
}, [items, searchInput, t]);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<InputSearch value={searchInput} setValue={setSearchInput} />
|
||||
{filteredItems.length === 0 && (
|
||||
<EmptyState
|
||||
captions={{
|
||||
thereAreNo: t("api.endpoints.empty"),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{filteredItems.map((endpoint, idx) => {
|
||||
return <ApiEndpoint key={idx} item={endpoint} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { useSubmit, useSearchParams, useNavigation, useParams } from "react-router";
|
||||
import { useState } from "react";
|
||||
import { FilterablePropertyDto } from "~/application/dtos/data/FilterablePropertyDto";
|
||||
import { PaginationDto } from "~/application/dtos/data/PaginationDto";
|
||||
import SimpleBadge from "~/components/ui/badges/SimpleBadge";
|
||||
import ButtonSecondary from "~/components/ui/buttons/ButtonSecondary";
|
||||
import RefreshIcon from "~/components/ui/icons/RefreshIcon";
|
||||
import InputFilters from "~/components/ui/input/InputFilters";
|
||||
import InputSearchWithURL from "~/components/ui/input/InputSearchWithURL";
|
||||
import ShowPayloadModalButton from "~/components/ui/json/ShowPayloadModalButton";
|
||||
import { FilterableValueLink } from "~/components/ui/links/FilterableValueLink";
|
||||
import TableSimple from "~/components/ui/tables/TableSimple";
|
||||
import ApiUtils from "~/utils/app/ApiUtils";
|
||||
import { useAppOrAdminData } from "~/utils/data/useAppOrAdminData";
|
||||
import { getUserHasPermission } from "~/utils/helpers/PermissionsHelper";
|
||||
import { useLocaleFormatters } from "~/locale/format";
|
||||
import { ApiKeyLogDto } from "../dtos/ApiKeyLogDto";
|
||||
import ApiCallSpeedBadge from "./ApiCallSpeedBadge";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ApiCallStatusBadge from "./ApiCallStatusBadge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
items: ApiKeyLogDto[];
|
||||
filterableProperties: FilterablePropertyDto[];
|
||||
pagination: PaginationDto;
|
||||
};
|
||||
}
|
||||
export default function ApiKeyLogsDetails({ data }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const formatters = useLocaleFormatters();
|
||||
const appOrAdminData = useAppOrAdminData();
|
||||
const submit = useSubmit();
|
||||
const navigation = useNavigation();
|
||||
const params = useParams();
|
||||
|
||||
const canDelete = !params.tenant;
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [selectedRows, setSelectedRows] = useState<ApiKeyLogDto[]>([]);
|
||||
function onDelete(ids: string[]) {
|
||||
if (!canDelete) {
|
||||
return;
|
||||
}
|
||||
const form = new FormData();
|
||||
form.set("action", "delete");
|
||||
form.set("ids", ids.join(","));
|
||||
submit(form, {
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex w-full items-center space-x-2">
|
||||
<div className="grow">
|
||||
<InputSearchWithURL size="sm" />
|
||||
</div>
|
||||
{canDelete && selectedRows.length > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!getUserHasPermission(appOrAdminData, "admin.apiKeys.delete")}
|
||||
onClick={() => {
|
||||
onDelete(selectedRows.map((x) => x.id));
|
||||
setSelectedRows([]);
|
||||
}}
|
||||
>
|
||||
{t("shared.delete")} {selectedRows.length}
|
||||
</Button>
|
||||
)}
|
||||
{/* <Button type="button" variant="outline" size="sm" onClick={() => setSearchParams(searchParams)} isLoading={navigation.state === "loading"}>
|
||||
<RefreshIcon className="size-3.5" />
|
||||
</Button> */}
|
||||
<InputFilters size="sm" filters={data.filterableProperties} />
|
||||
</div>
|
||||
<TableSimple
|
||||
selectedRows={canDelete ? selectedRows : undefined}
|
||||
onSelected={canDelete ? setSelectedRows : undefined}
|
||||
items={data.items}
|
||||
pagination={data.pagination}
|
||||
actions={[
|
||||
{
|
||||
title: t("shared.delete"),
|
||||
onClick: (_, item) => onDelete([item.id]),
|
||||
hidden: () => !canDelete,
|
||||
disabled: () => !canDelete,
|
||||
destructive: true,
|
||||
},
|
||||
]}
|
||||
headers={[
|
||||
{
|
||||
name: "createdAt",
|
||||
title: t("shared.createdAt"),
|
||||
value: (item) => formatters.dateTime(item.createdAt),
|
||||
formattedValue: (item) => (
|
||||
<div className="text-muted-foreground text-xs">{item.createdAt && <span>{formatters.dateTime(item.createdAt)}</span>}</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "tenant",
|
||||
title: t("models.tenant.object"),
|
||||
value: (item) => {
|
||||
const tenantName = item.apiKey?.tenant?.name ?? item.tenant?.name;
|
||||
const tenantId = item.apiKey?.tenant?.id ?? item.tenant?.id;
|
||||
return <FilterableValueLink name="tenantId" value={tenantName} param={tenantId} />;
|
||||
},
|
||||
hidden: !!params.tenant,
|
||||
},
|
||||
{
|
||||
name: "apiKeyId",
|
||||
title: t("models.apiKey.alias"),
|
||||
value: (item) => <FilterableValueLink name="apiKeyId" value={item.apiKey?.alias ?? t("shared.notSet")} param={item.apiKeyId ?? "{null}"} />,
|
||||
},
|
||||
{
|
||||
name: "ip",
|
||||
title: t("models.apiKeyLog.ip"),
|
||||
value: (item) => item.ip,
|
||||
formattedValue: (item) => (
|
||||
<div>{item.ip.length > 0 ? <FilterableValueLink name="ip" value={item.ip} /> : <span className="text-gray-300">{t("shared.notSet")}</span>}</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "endpoint",
|
||||
title: t("models.apiKeyLog.endpoint"),
|
||||
value: (item) => <FilterableValueLink name="endpoint" value={item.endpoint} />,
|
||||
},
|
||||
{
|
||||
name: "method",
|
||||
title: t("models.apiKeyLog.method"),
|
||||
value: (item) => item.method,
|
||||
formattedValue: (item) => (
|
||||
<div>
|
||||
<FilterableValueLink name="method" value={item.method}>
|
||||
<SimpleBadge title={item.method} color={ApiUtils.getMethodColor(item.method)} underline />
|
||||
</FilterableValueLink>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
title: t("models.apiKeyLog.status"),
|
||||
value: (item) => item.status,
|
||||
formattedValue: (item) => (
|
||||
<div>
|
||||
{item.status ? (
|
||||
<span>
|
||||
<FilterableValueLink name="status" value={item.status.toString()}>
|
||||
<ApiCallStatusBadge item={item} underline />
|
||||
</FilterableValueLink>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-300">{t("shared.notSet")}</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "duration",
|
||||
title: t("models.apiKeyLog.duration"),
|
||||
value: (item) =>
|
||||
item.duration === null ? (
|
||||
<span className="text-muted-foreground text-xs italic">{t("shared.notSet")}</span>
|
||||
) : (
|
||||
<div>{formatters.number(Number(item.duration), { style: "unit", unit: "millisecond", unitDisplay: "short", maximumFractionDigits: 3 })}</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "speed",
|
||||
title: t("models.apiKeyLog.speed"),
|
||||
value: (item) =>
|
||||
item.duration === null ? <span className="text-muted-foreground text-xs italic">{t("shared.notSet")}</span> : <ApiCallSpeedBadge duration={Number(item.duration)} />,
|
||||
},
|
||||
{
|
||||
name: "params",
|
||||
title: t("models.apiKeyLog.params"),
|
||||
value: (item) =>
|
||||
item.params === "{}" ? (
|
||||
<span className="text-muted-foreground text-xs italic">{t("shared.notSet")}</span>
|
||||
) : (
|
||||
<ShowPayloadModalButton description={item.params} payload={item.params} />
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "error",
|
||||
title: t("models.apiKeyLog.error"),
|
||||
value: (item) => <div className="text-red-500">{item.error}</div>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readRepoFile } from "~/utils/testing/repoPaths";
|
||||
|
||||
describe("ApiKeyLogsSummary i18n", () => {
|
||||
it("keeps visible summary controls behind semantic API log keys", () => {
|
||||
const source = [
|
||||
"apps/web/app/modules/api/components/ApiKeyLogsSummary.tsx",
|
||||
"apps/web/app/modules/api/components/ApiCallSpeedBadge.tsx",
|
||||
"apps/web/app/modules/metrics/components/SpeedBadge.tsx",
|
||||
"apps/web/app/modules/api/components/ApiEndpoint.tsx",
|
||||
"apps/web/app/modules/api/services/ApiSpecsService.ts",
|
||||
]
|
||||
.map((file) => readRepoFile(file))
|
||||
.join("\n");
|
||||
|
||||
expect(source).toContain("models.apiKeyLog.groupBy.placeholder");
|
||||
expect(source).toContain("models.apiKeyLog.calls");
|
||||
expect(source).toContain("models.apiKeyLog.avgDuration");
|
||||
expect(source).toContain("metrics.speed.lightningFast");
|
||||
expect(source).toContain("metrics.speed.verySlow");
|
||||
expect(source).toContain("api.docs.responseSchema");
|
||||
expect(source).toContain("api.docs.bodyExample");
|
||||
expect(source).toContain("api.specs.hostDescription");
|
||||
expect(source).toContain("api.specs.operations.getAll");
|
||||
expect(source).not.toContain("ui.autogenerated");
|
||||
expect(source).not.toContain("lightning_fast");
|
||||
expect(source).not.toContain("response_schema");
|
||||
expect(source).not.toContain("body_example");
|
||||
expect(source).not.toContain('"Group by"');
|
||||
expect(source).not.toContain('"Select group by"');
|
||||
expect(source).not.toContain('"Please select at least one group by"');
|
||||
expect(source).not.toContain('"Speed"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import { Link, useParams, useSearchParams } from "react-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import ApiKeyLogUtils from "../utils/ApiKeyLogUtils";
|
||||
import { RowHeaderDisplayDto } from "~/application/dtos/data/RowHeaderDisplayDto";
|
||||
import { ApiCallSummaryDto } from "../dtos/ApiCallSummaryDto";
|
||||
import ApiKeyLogsConstants from "../utils/ApiKeyLogsConstants";
|
||||
import { FilterablePropertyDto } from "~/application/dtos/data/FilterablePropertyDto";
|
||||
import { FilterableValueLink } from "~/components/ui/links/FilterableValueLink";
|
||||
import WarningBanner from "~/components/ui/banners/WarningBanner";
|
||||
import InputCombobox from "~/components/ui/input/InputCombobox";
|
||||
import InputFilters from "~/components/ui/input/InputFilters";
|
||||
import TableSimple from "~/components/ui/tables/TableSimple";
|
||||
import { useLocaleFormatters } from "~/locale/format";
|
||||
import UrlUtils from "~/utils/app/UrlUtils";
|
||||
import ApiUtils from "~/utils/app/ApiUtils";
|
||||
import SimpleBadge from "~/components/ui/badges/SimpleBadge";
|
||||
import { Colors } from "~/application/enums/shared/Colors";
|
||||
import ApiCallSpeedBadge from "./ApiCallSpeedBadge";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
items: ApiCallSummaryDto[];
|
||||
allTenants: { id: string; name: string }[];
|
||||
filterableProperties: FilterablePropertyDto[];
|
||||
};
|
||||
}
|
||||
export default function ApiKeyLogsSummary({ data }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const formatters = useLocaleFormatters();
|
||||
const params = useParams();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [groupBy, setGroupBy] = useState<string[]>(ApiKeyLogUtils.getGroupByValues(searchParams));
|
||||
|
||||
const [headers, setHeaders] = useState<RowHeaderDisplayDto<ApiCallSummaryDto>[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (ApiKeyLogUtils.getGroupByValues(searchParams).sort().join(",") !== groupBy.sort().join(",")) {
|
||||
searchParams.delete("groupBy");
|
||||
groupBy.forEach((by) => {
|
||||
searchParams.append("groupBy", by);
|
||||
});
|
||||
setSearchParams(searchParams);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [groupBy]);
|
||||
|
||||
useEffect(() => {
|
||||
const headers: RowHeaderDisplayDto<ApiCallSummaryDto>[] = [];
|
||||
let currentGroupBy = groupBy;
|
||||
if (currentGroupBy.length === 0) {
|
||||
currentGroupBy = ApiKeyLogsConstants.DEFAULT_GROUP_BY;
|
||||
}
|
||||
const getTenant = (item: ApiCallSummaryDto) => {
|
||||
return data.allTenants.find((x) => x.id === item.tenantId);
|
||||
};
|
||||
const getCountLink = (item: ApiCallSummaryDto) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
// searchParams.set("pageSize", "100");
|
||||
currentGroupBy.forEach((groupBy) => {
|
||||
if (groupBy === "method") {
|
||||
searchParams.append("method", item.method);
|
||||
} else if (groupBy === "endpoint") {
|
||||
searchParams.append("endpoint", item.endpoint);
|
||||
} else if (groupBy === "params") {
|
||||
searchParams.append("params", item.params);
|
||||
} else if (groupBy === "status") {
|
||||
searchParams.append("status", item.status?.toString() ?? "{null}");
|
||||
}
|
||||
});
|
||||
return UrlUtils.getModulePath(params, `api/logs?${searchParams.toString()}`);
|
||||
};
|
||||
if (groupBy.includes("method")) {
|
||||
headers.push({
|
||||
name: "method",
|
||||
title: t("models.apiKeyLog.method"),
|
||||
value: (item) => (
|
||||
<FilterableValueLink name="method" value={item.method}>
|
||||
<SimpleBadge title={item.method} color={ApiUtils.getMethodColor(item.method)} underline />
|
||||
</FilterableValueLink>
|
||||
),
|
||||
});
|
||||
}
|
||||
if (groupBy.includes("endpoint")) {
|
||||
headers.push({
|
||||
name: "endpoint",
|
||||
title: t("models.apiKeyLog.endpoint"),
|
||||
value: (item) => <FilterableValueLink name="endpoint" value={item.endpoint} />,
|
||||
});
|
||||
}
|
||||
if (groupBy.includes("params")) {
|
||||
headers.push({
|
||||
name: "params",
|
||||
title: t("models.apiKeyLog.params"),
|
||||
value: (item) => <FilterableValueLink name="params" value={item.params} />,
|
||||
});
|
||||
}
|
||||
if (groupBy.includes("status")) {
|
||||
headers.push({
|
||||
name: "status",
|
||||
title: t("models.apiKeyLog.status"),
|
||||
value: (item) => (
|
||||
<FilterableValueLink name="status" value={item.status?.toString() ?? "{null}"}>
|
||||
<SimpleBadge title={item.status?.toString() ?? t("shared.unknown")} color={item.status?.toString().startsWith("4") ? Colors.RED : Colors.GREEN} />
|
||||
</FilterableValueLink>
|
||||
),
|
||||
});
|
||||
}
|
||||
if (groupBy.includes("tenantId")) {
|
||||
headers.push({
|
||||
name: "tenantId",
|
||||
title: t("models.apiKeyLog.tenant"),
|
||||
value: (item) => <FilterableValueLink name="tenantId" value={getTenant(item)?.name ?? ""} />,
|
||||
});
|
||||
}
|
||||
headers.push({
|
||||
name: "apiCalls",
|
||||
title: t("models.apiKeyLog.apiCalls"),
|
||||
align: "right",
|
||||
value: (item) => (
|
||||
<div className="flex justify-end text-right">
|
||||
<Link to={getCountLink(item)} className="hover:underline">
|
||||
{t("models.apiKeyLog.calls", { count: Number(item._count._all), formattedCount: formatters.number(Number(item._count._all)) })}
|
||||
</Link>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
headers.push({
|
||||
name: "duration",
|
||||
title: t("models.apiKeyLog.avgDuration"),
|
||||
value: (item) =>
|
||||
item._avg.duration === null ? (
|
||||
<span className="text-muted-foreground text-xs italic">{t("shared.notAvailable")}</span>
|
||||
) : (
|
||||
<div>{formatters.number(Number(item._avg.duration), { style: "unit", unit: "millisecond", unitDisplay: "short", maximumFractionDigits: 3 })}</div>
|
||||
),
|
||||
});
|
||||
headers.push({
|
||||
name: "speed",
|
||||
title: t("models.apiKeyLog.speed"),
|
||||
value: (item) =>
|
||||
item._avg.duration === null ? (
|
||||
<span className="text-muted-foreground text-xs italic">{t("shared.notAvailable")}</span>
|
||||
) : (
|
||||
<ApiCallSpeedBadge duration={Number(item._avg.duration)} />
|
||||
),
|
||||
});
|
||||
headers.push({
|
||||
name: "actions",
|
||||
title: "",
|
||||
value: (item) => (
|
||||
<Link to={getCountLink(item)} className="hover:underline">
|
||||
{t("shared.details")} <span className="ml-1">→</span>
|
||||
</Link>
|
||||
),
|
||||
});
|
||||
|
||||
setHeaders(headers);
|
||||
}, [data, groupBy, params, t]);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex w-full items-center space-x-2">
|
||||
<InputCombobox
|
||||
name="groupBy"
|
||||
prefix={t("models.apiKeyLog.groupBy.prefix")}
|
||||
className="bg-background h-8"
|
||||
selectPlaceholder={t("models.apiKeyLog.groupBy.placeholder")}
|
||||
options={[
|
||||
{ value: "method", name: t("models.apiKeyLog.method") },
|
||||
{ value: "endpoint", name: t("models.apiKeyLog.endpoint") },
|
||||
{ value: "params", name: t("models.apiKeyLog.params") },
|
||||
{ value: "status", name: t("models.apiKeyLog.status") },
|
||||
{ value: "tenantId", name: t("models.apiKeyLog.tenant") },
|
||||
]}
|
||||
value={groupBy}
|
||||
onChange={(value) => {
|
||||
setGroupBy(value as string[]);
|
||||
}}
|
||||
/>
|
||||
<div className="grow">{/* <InputSearchWithURL /> */}</div>
|
||||
<InputFilters size="sm" filters={data.filterableProperties} />
|
||||
</div>
|
||||
{groupBy.length === 0 ? (
|
||||
<WarningBanner title={t("models.apiKeyLog.groupBy.title")} text={t("models.apiKeyLog.groupBy.required")} />
|
||||
) : (
|
||||
<TableSimple items={data.items} headers={headers} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// @vitest-environment jsdom
|
||||
import "@testing-library/jest-dom";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ApiSpecsDto } from "../services/ApiSpecsService";
|
||||
import ApiSpecs from "./ApiSpecs";
|
||||
|
||||
vi.mock("~/components/editors/MonacoEditor", () => ({ default: ({ value }: { value: string }) => <pre data-testid="monaco">{value}</pre> }));
|
||||
vi.mock("~/components/ui/icons/ExternalLinkEmptyIcon", () => ({ default: () => null }));
|
||||
vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string) => key }) }));
|
||||
|
||||
const item = {
|
||||
openApi: { openapi: "3.1.0" },
|
||||
postmanCollection: { info: { name: "API" }, item: [] },
|
||||
apiDocsUrl: "https://api.example.com/v1/docs",
|
||||
} as unknown as ApiSpecsDto;
|
||||
|
||||
describe("ApiSpecs", () => {
|
||||
it("can hide the admin-only Postman download while still rendering the collection preview", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ApiSpecs item={item} postmanCollectionUrl={null} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByText("docs.openApi")).toBeInTheDocument();
|
||||
expect(screen.queryByText("docs.downloadPostmanCollection")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("link", { name: /postman/i })).not.toBeInTheDocument();
|
||||
expect(screen.getAllByTestId("monaco")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("can hide the full Postman collection preview on tenant-facing docs", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ApiSpecs item={item} postmanCollectionUrl={null} showPostmanCollection={false} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.queryByText("docs.downloadPostmanCollection")).not.toBeInTheDocument();
|
||||
expect(screen.getAllByTestId("monaco")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import MonacoEditor from "~/components/editors/MonacoEditor";
|
||||
import ExternalLinkEmptyIcon from "~/components/ui/icons/ExternalLinkEmptyIcon";
|
||||
import { ApiSpecsDto } from "../services/ApiSpecsService";
|
||||
import { Link } from "react-router";
|
||||
import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function ApiSpecs({
|
||||
item,
|
||||
className = "h-96 overflow-auto",
|
||||
withLinkToDocs = true,
|
||||
postmanCollectionUrl = "/postman_collection.json",
|
||||
showPostmanCollection = true,
|
||||
}: {
|
||||
item: ApiSpecsDto;
|
||||
className?: string;
|
||||
withLinkToDocs?: boolean;
|
||||
postmanCollectionUrl?: string | null;
|
||||
showPostmanCollection?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const shouldShowPostmanCollection = showPostmanCollection && Boolean(item.postmanCollection);
|
||||
return (
|
||||
<div className={clsx("grid gap-4", shouldShowPostmanCollection ? "grid-cols-2" : "grid-cols-1")}>
|
||||
<div className="space-y-2">
|
||||
{withLinkToDocs && (
|
||||
<Link to={item.apiDocsUrl} target="_blank">
|
||||
<div className="hover:border-border hover:bg-background rounded-md border border-transparent px-2 py-1.5">
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" width="256" height="256" preserveAspectRatio="xMidYMid" viewBox="0 0 256 256">
|
||||
<path
|
||||
fill="#fff"
|
||||
d="M127.06 255.997C58.85 255.527-.457 198.918.003 126.998.445 57.796 57.952-.469 129.11.003c69.157.46 127.503 57.864 126.885 128.191.573 69.69-58.06 128.295-128.935 127.803zm0 0C58.85 255.527-.457 198.918.003 126.998.445 57.796 57.952-.469 129.11.003c69.157.46 127.503 57.864 126.885 128.191.573 69.69-58.06 128.295-128.935 127.803z"
|
||||
/>
|
||||
<path
|
||||
fill="#49a32b"
|
||||
d="M127.185 238.997C68.032 238.59 16.604 189.5 17.002 127.131c.384-60.012 50.253-110.54 111.961-110.129 59.972.399 110.569 50.18 110.033 111.167.496 60.433-50.349 111.255-111.811 110.828zm0 0C68.032 238.59 16.604 189.5 17.002 127.131c.384-60.012 50.253-110.54 111.961-110.129 59.972.399 110.569 50.18 110.033 111.167.496 60.433-50.349 111.255-111.811 110.828z"
|
||||
/>
|
||||
<path
|
||||
fill="#fff"
|
||||
d="M169.327 127.956c-.284 5.29-4.906 9.683-9.46 8.917h-.069a9.133 9.133 0 0 1-9.145-9.124 9.276 9.276 0 0 1 9.427-8.96c5.045.025 9.52 4.288 9.247 9.167zM88.201 179.22c1.902.056 3.806.015 6 .015v13.786c-13.635 2.305-24.866-1.566-27.622-13.091a76.501 76.501 0 0 1-1.736-12.886c-.293-4.592.213-9.235-.137-13.819-.97-12.612-2.603-16.918-14.706-17.514v-15.696a22.665 22.665 0 0 1 2.632-.452c6.636-.327 9.433-2.362 10.917-8.898a74.57 74.57 0 0 0 1.194-11.122c.526-7.217.34-14.551 1.542-21.651 1.737-10.267 8.111-15.255 18.64-15.814 2.996-.16 6.001-.024 9.396-.024v14.09c-1.397.1-2.677.303-3.95.265-8.58-.263-9.023 2.66-9.65 9.762-.39 4.454.149 8.984-.155 13.454a107.113 107.113 0 0 1-1.781 13.245c-1.238 6.339-5.135 11.053-10.535 15.053 10.483 6.823 11.676 17.423 12.357 28.188.366 5.784.199 11.61.786 17.366.457 4.467 2.195 5.607 6.808 5.743zm8.836-60.43h.162a9.078 9.078 0 0 1 8.928 9.226 8.884 8.884 0 0 1-9.396 8.851 9.046 9.046 0 1 1 .306-18.078zm31.236 0c5.483-.043 9.124 3.51 9.152 8.93.03 5.565-3.422 9.125-8.868 9.149-5.54.024-9.186-3.48-9.216-8.867a8.671 8.671 0 0 1 8.933-9.213zm65.4-6.867c1.459 5.447 4.298 7.362 10.032 7.623.94.044 1.875.203 3.163.346v15.692c-.697.23-1.412.4-2.138.512-7.684.478-11.186 3.63-11.962 11.335-.496 4.919-.455 9.892-.796 14.828a125.806 125.806 0 0 1-1.476 16.18c-1.96 9.703-8.019 14.544-18.029 15.135-3.221.19-6.466.03-9.94.03v-14.026c1.87-.115 3.52-.275 5.174-.314 5.981-.143 8.096-2.071 8.389-8.011.324-6.525.465-13.059.757-19.586.423-9.433 3.006-17.861 11.795-23.745-5.03-3.585-9.067-7.928-10.112-13.783-1.265-7.097-1.674-14.351-2.355-21.548-.336-3.597-.32-7.226-.671-10.821-.379-3.88-3.045-5.224-6.577-5.31-2.024-.05-4.055-.01-6.642-.01V62.754c16.509-2.741 27.913 2.752 28.973 18.548.443 6.632.378 13.297.803 19.93.186 3.608.726 7.19 1.612 10.691z"
|
||||
/>
|
||||
</svg>
|
||||
<div className="text-foreground truncate text-sm font-medium">{t("docs.openApi")}</div>
|
||||
</div>
|
||||
<ExternalLinkEmptyIcon className="ml-2 h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
<div className={clsx(className)}>
|
||||
<MonacoEditor value={JSON.stringify(item.openApi, null, 2)} theme="vs-dark" language="json" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{shouldShowPostmanCollection && (
|
||||
<div className="space-y-2">
|
||||
{withLinkToDocs && postmanCollectionUrl && (
|
||||
<Link to={postmanCollectionUrl} target="_blank">
|
||||
<div className="hover:border-border hover:bg-background rounded-md border border-transparent px-2 py-1.5">
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<svg className="h-4 w-4" xmlns="http://www.w3.org/2000/svg" width="800" height="800" preserveAspectRatio="xMidYMid" viewBox="0 0 256 256">
|
||||
<path
|
||||
fill="#FF6C37"
|
||||
d="M254.953 144.253c8.959-70.131-40.569-134.248-110.572-143.206C74.378-7.912 10.005 41.616 1.047 111.619c-8.959 70.003 40.569 134.248 110.572 143.334 70.131 8.959 134.248-40.569 143.334-110.7Z"
|
||||
/>
|
||||
<path fill="#FFF" d="m174.2 82.184-54.007 54.007-15.229-15.23c53.11-53.11 58.358-48.503 69.236-38.777Z" />
|
||||
<path
|
||||
fill="#FF6C37"
|
||||
d="M120.193 137.47c-.384 0-.64-.128-.895-.384l-15.358-15.229a1.237 1.237 0 0 1 0-1.792c54.007-54.006 59.638-48.887 71.028-38.649.255.256.383.512.383.896s-.128.64-.383.896l-54.007 53.878c-.128.256-.512.384-.768.384Zm-13.437-16.509 13.437 13.438 52.087-52.087c-9.47-8.446-15.87-11.006-65.524 38.65Z"
|
||||
/>
|
||||
<path fill="#FFF" d="m135.679 151.676-14.718-14.718 54.007-54.006c14.46 14.59-7.167 38.265-39.29 68.724Z" />
|
||||
<path
|
||||
fill="#FF6C37"
|
||||
d="M135.679 152.956c-.384 0-.64-.128-.896-.384l-14.718-14.718c-.256-.256-.256-.512-.256-.896s.128-.64.384-.895L174.2 82.056a1.237 1.237 0 0 1 1.791 0 15.58 15.58 0 0 1 4.991 11.902c-.256 14.206-16.38 32.25-44.28 58.614-.383.256-.767.384-1.023.384Zm-12.926-15.998c8.19 8.319 11.646 11.646 12.926 12.926 21.5-20.476 42.36-41.464 42.488-55.926.128-3.327-1.152-6.655-3.327-9.214l-52.087 52.214Z"
|
||||
/>
|
||||
<path
|
||||
fill="#FFF"
|
||||
d="m105.22 121.345 10.878 10.878c.256.256.256.512 0 .768-.128.128-.128.128-.256.128l-22.524 4.863c-1.152.128-2.175-.64-2.431-1.791-.128-.64.128-1.28.512-1.664l13.053-13.054c.256-.256.64-.384.768-.128Z"
|
||||
/>
|
||||
<path
|
||||
fill="#FF6C37"
|
||||
d="M92.934 139.262c-1.92 0-3.327-1.536-3.327-3.455 0-.896.384-1.792 1.024-2.432l13.053-13.054c.768-.64 1.792-.64 2.56 0l10.878 10.878c.768.64.768 1.792 0 2.56-.256.256-.512.384-.896.512l-22.524 4.863c-.256 0-.512.128-.768.128Zm11.902-16.51-12.542 12.543c-.256.256-.383.64-.128 1.024.128.383.512.511.896.383l21.116-4.607-9.342-9.342Z"
|
||||
/>
|
||||
<path
|
||||
fill="#FFF"
|
||||
d="M202.739 52.238c-8.191-7.935-21.373-7.679-29.307.64-7.935 8.318-7.679 21.372.64 29.306A20.678 20.678 0 0 0 199.155 85l-14.59-14.59 18.174-18.172Z"
|
||||
/>
|
||||
<path
|
||||
fill="#FF6C37"
|
||||
d="M188.405 89.223c-12.158 0-22.012-9.854-22.012-22.012 0-12.158 9.854-22.012 22.012-22.012 5.631 0 11.134 2.176 15.23 6.143.255.256.383.512.383.896s-.128.64-.384.895L186.357 70.41l13.566 13.566c.512.512.512 1.28 0 1.792l-.256.256c-3.327 2.047-7.295 3.199-11.262 3.199Zm0-41.337c-10.75 0-19.452 8.703-19.324 19.453 0 10.75 8.702 19.452 19.452 19.324 2.944 0 5.887-.64 8.575-2.047l-13.438-13.31c-.256-.256-.384-.512-.384-.896s.128-.64.384-.895l17.149-17.15c-3.456-2.943-7.807-4.479-12.414-4.479Z"
|
||||
/>
|
||||
<path
|
||||
fill="#FFF"
|
||||
d="m203.122 52.622-.255-.256-18.301 18.044 14.461 14.462c1.408-.896 2.816-1.92 3.967-3.072a20.51 20.51 0 0 0 .128-29.178Z"
|
||||
/>
|
||||
<path
|
||||
fill="#FF6C37"
|
||||
d="M199.155 86.28c-.384 0-.64-.128-.896-.384l-14.589-14.59c-.256-.256-.384-.512-.384-.896s.128-.64.384-.895l18.173-18.173a1.237 1.237 0 0 1 1.791 0l.384.256c8.575 8.574 8.575 22.396.128 31.098-1.28 1.28-2.687 2.432-4.223 3.328-.384.128-.64.256-.768.256Zm-12.798-15.87 12.926 12.926c1.024-.64 2.048-1.536 2.816-2.304 7.294-7.294 7.678-19.196.64-26.875L186.357 70.41Z"
|
||||
/>
|
||||
<path
|
||||
fill="#FFF"
|
||||
d="M176.375 84.488a7.879 7.879 0 0 0-11.134 0l-48.247 48.247 8.063 8.063 51.062-44.792c3.328-2.816 3.584-7.807.768-11.134-.256-.128-.384-.256-.512-.384Z"
|
||||
/>
|
||||
<path
|
||||
fill="#FF6C37"
|
||||
d="M124.929 142.077c-.384 0-.64-.128-.896-.383l-8.063-8.063a1.237 1.237 0 0 1 0-1.792l48.247-48.247a9.115 9.115 0 0 1 12.926 0 9.115 9.115 0 0 1 0 12.926l-.384.384-51.063 44.792c-.128.255-.384.383-.767.383Zm-6.143-9.342 6.27 6.271 50.167-44.024c2.816-2.304 3.072-6.527.768-9.342-2.303-2.816-6.526-3.072-9.342-.768-.128.128-.256.256-.512.384l-47.351 47.48Z"
|
||||
/>
|
||||
<path
|
||||
fill="#FFF"
|
||||
d="M80.009 187.637c-.512.256-.768.768-.64 1.28l2.175 9.214c.512 1.28-.256 2.816-1.663 3.2-1.024.384-2.176 0-2.816-.768l-14.077-13.95 45.943-45.943 15.87.256 10.75 10.75c-2.56 2.175-18.045 17.149-55.542 35.961Z"
|
||||
/>
|
||||
<path
|
||||
fill="#FF6C37"
|
||||
d="M78.985 202.61c-1.024 0-2.048-.383-2.688-1.151l-13.95-13.95c-.255-.256-.383-.512-.383-.896 0-.383.128-.64.384-.895l45.944-45.944c.256-.256.64-.384.895-.384l15.87.256c.383 0 .64.128.895.384l10.75 10.75c.256.256.384.64.384 1.024s-.128.64-.512.896l-.895.767c-13.566 11.902-31.995 23.804-54.902 35.194l2.175 9.086c.384 1.664-.384 3.456-1.92 4.352-.767.384-1.407.512-2.047.512Zm-14.078-15.997 13.182 13.054c.384.64 1.152.896 1.792.512.64-.384.896-1.152.512-1.792l-2.176-9.214c-.256-1.152.256-2.176 1.28-2.688 22.652-11.39 40.952-23.163 54.39-34.81l-9.47-9.47-14.718-.256-44.792 44.664Z"
|
||||
/>
|
||||
<path
|
||||
fill="#FFF"
|
||||
d="m52.11 197.62 11.006-11.007 16.38 16.381-26.107-1.791c-1.151-.128-1.92-1.152-1.791-2.304 0-.512.128-1.024.512-1.28Z"
|
||||
/>
|
||||
<path
|
||||
fill="#FF6C37"
|
||||
d="m79.497 204.146-26.236-1.791c-1.92-.128-3.199-1.792-3.071-3.712.128-.768.384-1.535 1.024-2.047L62.22 185.59a1.237 1.237 0 0 1 1.792 0l16.38 16.38c.385.385.512.897.257 1.408-.256.512-.64.768-1.152.768Zm-16.381-15.74-10.11 10.11c-.384.255-.384.895 0 1.151.127.128.255.256.511.256l22.652 1.536-13.053-13.054ZM104.452 146.557c-.768 0-1.28-.64-1.28-1.28 0-.384.128-.64.384-.896l12.414-12.414a1.237 1.237 0 0 1 1.792 0l8.062 8.063c.384.384.512.768.384 1.28-.128.384-.512.767-1.023.895l-20.477 4.352h-.256Zm12.414-11.902-8.446 8.446 13.821-2.943-5.375-5.503Z"
|
||||
/>
|
||||
<path fill="#FFF" d="m124.8 140.926-14.077 3.071c-1.024.256-2.048-.384-2.303-1.408-.128-.64 0-1.28.511-1.791l7.807-7.807 8.063 7.935Z" />
|
||||
<path
|
||||
fill="#FF6C37"
|
||||
d="M110.467 145.277a3.168 3.168 0 0 1-3.2-3.2c0-.895.385-1.663.897-2.303l7.806-7.807a1.237 1.237 0 0 1 1.792 0l8.062 8.063c.384.384.512.768.384 1.28-.128.384-.512.767-1.023.895l-14.078 3.072h-.64Zm6.399-10.622-6.91 6.91c-.257.257-.257.512-.129.768s.384.384.768.384l11.774-2.56-5.503-5.502ZM203.25 64.907c-.256-.767-1.151-1.151-1.92-.895-.767.255-1.151 1.151-.895 1.92 0 .127.128.255.128.383.768 1.536.512 3.455-.512 4.863-.512.64-.384 1.536.128 2.048.64.512 1.536.384 2.048-.256 1.92-2.432 2.303-5.503 1.023-8.063Z"
|
||||
/>
|
||||
</svg>
|
||||
<div className="text-foreground truncate text-sm font-medium">{t("docs.downloadPostmanCollection")}</div>
|
||||
</div>
|
||||
<ExternalLinkEmptyIcon className="ml-2 h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<div className={clsx(className)}>
|
||||
<MonacoEditor value={JSON.stringify(item.postmanCollection, null, 2)} theme="vs-dark" language="json" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export type ApiCallSummaryDto = {
|
||||
tenantId: string | null;
|
||||
method: string;
|
||||
endpoint: string;
|
||||
params: string;
|
||||
status: number;
|
||||
_avg: { duration: number | null };
|
||||
_count: { _all: number };
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { LegacyEntityWithDetails } from "@toolctrl/db/entities.server";
|
||||
|
||||
export type ApiEndpointDto = {
|
||||
entity: LegacyEntityWithDetails;
|
||||
route: string;
|
||||
method: string;
|
||||
description: string;
|
||||
responseSchema: string;
|
||||
bodyExample: string;
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { ApiKeyLog } from "@toolctrl/db/prisma-browser";
|
||||
|
||||
export type ApiKeyLogDto = ApiKeyLog & {
|
||||
apiKey: { alias: string; tenant: { id: string; name: string } } | null;
|
||||
tenant?: { id: string; name: string; slug: string } | null | undefined;
|
||||
};
|
||||
@@ -0,0 +1,186 @@
|
||||
import { adminGetAllLegacyTenantSummaries } from "@toolctrl/db/accounts.server";
|
||||
import {
|
||||
adminDeleteLegacyApiKeyLogsByIds,
|
||||
countLegacyApiKeyLogs,
|
||||
deleteTenantLegacyApiKeyLogsByIds,
|
||||
getLegacyApiKeyLogAdminDetails,
|
||||
groupLegacyApiKeyLogs,
|
||||
tenantLegacyApiKeyLogScope,
|
||||
type LegacyApiKeyLogOrderByInput,
|
||||
type LegacyApiKeyLogWhereInput,
|
||||
} from "@toolctrl/db/api-keys.server";
|
||||
import { Prisma } from "@toolctrl/db/prisma-browser";
|
||||
import { Params } from "react-router";
|
||||
import { FilterablePropertyDto } from "~/application/dtos/data/FilterablePropertyDto";
|
||||
import { getFiltersFromCurrentUrl, getPaginationFromCurrentUrl } from "~/utils/helpers/RowPaginationHelper";
|
||||
import { getTenantIdOrNull } from "~/utils/services/.server/urlService";
|
||||
import { ApiCallSummaryDto } from "../dtos/ApiCallSummaryDto";
|
||||
import ApiKeyLogUtils from "../utils/ApiKeyLogUtils";
|
||||
import { ApiKeyLogDto } from "../dtos/ApiKeyLogDto";
|
||||
import { PaginationDto } from "~/application/dtos/data/PaginationDto";
|
||||
|
||||
async function getSummary({ request, params }: { request: Request; params: Params }) {
|
||||
const { filterableProperties, whereFilters, allTenants } = await getFilters({ request, params });
|
||||
const searchParams = new URL(request.url).searchParams;
|
||||
let groupBy = ApiKeyLogUtils.getGroupByValues(searchParams);
|
||||
let items: ApiCallSummaryDto[] = [];
|
||||
if (groupBy.length > 0) {
|
||||
try {
|
||||
const data = await groupLegacyApiKeyLogs({
|
||||
by: groupBy,
|
||||
where: whereFilters,
|
||||
});
|
||||
items = data.map((x) => x as ApiCallSummaryDto);
|
||||
} catch {
|
||||
throw Error("Invalid group by: " + groupBy.join(", "));
|
||||
}
|
||||
}
|
||||
return { items, allTenants, filterableProperties };
|
||||
}
|
||||
|
||||
async function getDetails({ request, params }: { request: Request; params: Params }): Promise<{
|
||||
items: ApiKeyLogDto[];
|
||||
filterableProperties: FilterablePropertyDto[];
|
||||
pagination: PaginationDto;
|
||||
}> {
|
||||
const { filterableProperties, whereFilters } = await getFilters({ request, params });
|
||||
|
||||
const urlSearchParams = new URL(request.url).searchParams;
|
||||
const pagination = getPaginationFromCurrentUrl(urlSearchParams);
|
||||
|
||||
const orderBy: LegacyApiKeyLogOrderByInput | LegacyApiKeyLogOrderByInput[] = !pagination.sortedBy.length
|
||||
? { createdAt: "desc" }
|
||||
: pagination.sortedBy.map((x) => ({
|
||||
[x.name]: x.direction,
|
||||
}));
|
||||
const items = await getLegacyApiKeyLogAdminDetails({
|
||||
take: pagination.pageSize,
|
||||
skip: pagination.pageSize * (pagination.page - 1),
|
||||
where: whereFilters,
|
||||
includeTenant: !whereFilters.tenantId,
|
||||
orderBy,
|
||||
});
|
||||
const totalItems = await countLegacyApiKeyLogs(whereFilters);
|
||||
|
||||
return {
|
||||
items: items as ApiKeyLogDto[],
|
||||
filterableProperties,
|
||||
pagination: {
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
totalItems,
|
||||
totalPages: Math.ceil(totalItems / pagination.pageSize),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function getGroupBys() {
|
||||
const allMethods = [
|
||||
{ name: "GET", value: "GET" },
|
||||
{ name: "POST", value: "POST" },
|
||||
{ name: "PUT", value: "PUT" },
|
||||
{ name: "PATCH", value: "PATCH" },
|
||||
{ name: "DELETE", value: "DELETE" },
|
||||
];
|
||||
|
||||
return {
|
||||
allMethods,
|
||||
};
|
||||
}
|
||||
async function getFilterableProperties(tenantId: string | null) {
|
||||
const { allMethods } = await getGroupBys();
|
||||
const filterableProperties: FilterablePropertyDto[] = [
|
||||
{ name: "method", title: "models.apiKeyLog.method", options: allMethods },
|
||||
{
|
||||
name: "endpoint",
|
||||
title: "models.apiKeyLog.endpoint",
|
||||
},
|
||||
{
|
||||
name: "params",
|
||||
title: "models.apiKeyLog.params",
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
title: "models.apiKeyLog.status",
|
||||
},
|
||||
{
|
||||
name: "apiKeyId",
|
||||
title: "models.apiKey.object",
|
||||
},
|
||||
];
|
||||
let allTenants: { id: string; name: string; slug: string }[] = [];
|
||||
if (tenantId === null) {
|
||||
allTenants = await adminGetAllLegacyTenantSummaries();
|
||||
filterableProperties.unshift({
|
||||
name: "tenantId",
|
||||
title: "models.tenant.object",
|
||||
options: [
|
||||
{ value: "null", name: "{null}" },
|
||||
...allTenants.map((item) => {
|
||||
return {
|
||||
value: item.id,
|
||||
name: item.name,
|
||||
};
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
return { filterableProperties, allTenants };
|
||||
}
|
||||
|
||||
async function getFilters({ request, params }: { request: Request; params: Params }) {
|
||||
const tenantId = await getTenantIdOrNull({ request, params });
|
||||
const { filterableProperties, allTenants } = await getFilterableProperties(tenantId);
|
||||
const filters = getFiltersFromCurrentUrl(request, filterableProperties);
|
||||
const q = filters.query || "";
|
||||
|
||||
const AND_filters: Prisma.ApiKeyLogWhereInput[] = [];
|
||||
filterableProperties.forEach((filter) => {
|
||||
const value = filters.properties.find((f) => f.name === filter.name)?.value;
|
||||
if (value) {
|
||||
let formattedValue = filter.name === "status" ? Number(value) : value;
|
||||
AND_filters.push({
|
||||
[filter.name]: value === "null" ? null : formattedValue,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const OR_filters: Prisma.ApiKeyLogWhereInput[] = [];
|
||||
if (q) {
|
||||
OR_filters.push(
|
||||
{ ip: { contains: q, mode: "insensitive" } },
|
||||
{ endpoint: { contains: q, mode: "insensitive" } },
|
||||
{ method: { contains: q, mode: "insensitive" } },
|
||||
{ params: { contains: q, mode: "insensitive" } },
|
||||
{ error: { contains: q, mode: "insensitive" } }
|
||||
);
|
||||
}
|
||||
const whereFilters: LegacyApiKeyLogWhereInput = {};
|
||||
if (OR_filters.length > 0) {
|
||||
whereFilters.OR = OR_filters;
|
||||
}
|
||||
if (AND_filters.length > 0) {
|
||||
whereFilters.AND = AND_filters;
|
||||
}
|
||||
if (tenantId) {
|
||||
const existingAndFilters = Array.isArray(whereFilters.AND) ? whereFilters.AND : whereFilters.AND ? [whereFilters.AND] : [];
|
||||
whereFilters.AND = [...existingAndFilters, tenantLegacyApiKeyLogScope(tenantId)];
|
||||
}
|
||||
|
||||
return { filterableProperties, whereFilters, allTenants };
|
||||
}
|
||||
|
||||
async function adminDeleteMany(ids: string[]) {
|
||||
return await adminDeleteLegacyApiKeyLogsByIds(ids);
|
||||
}
|
||||
|
||||
async function deleteManyForTenant(ids: string[], tenantId: string | null) {
|
||||
return await deleteTenantLegacyApiKeyLogsByIds({ ids, tenantId });
|
||||
}
|
||||
|
||||
export default {
|
||||
getSummary,
|
||||
getDetails,
|
||||
adminDeleteMany,
|
||||
deleteManyForTenant,
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@toolctrl/db/entities.server", () => ({
|
||||
getAllLegacyEntities: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("~/locale/i18next.server", () => ({
|
||||
getTranslations: vi.fn(async () => ({ t: (value: string) => value, translations: {}, locale: "en" })),
|
||||
}));
|
||||
|
||||
vi.mock("~/utils/url.server", () => ({
|
||||
getAbsoluteApiUrl: vi.fn((_request: Request, path: string) => `https://api.example.com${path.startsWith("/") ? path : `/${path}`}`),
|
||||
}));
|
||||
|
||||
import { getAllLegacyEntities } from "@toolctrl/db/entities.server";
|
||||
import ApiSpecsService from "./ApiSpecsService";
|
||||
|
||||
const entity = {
|
||||
id: "entity_1",
|
||||
slug: "contacts",
|
||||
title: "Contact",
|
||||
titlePlural: "Contacts",
|
||||
hasApi: true,
|
||||
active: true,
|
||||
properties: [],
|
||||
childEntities: [{ id: "child_relation" }],
|
||||
parentEntities: [],
|
||||
};
|
||||
|
||||
describe("ApiSpecsService", () => {
|
||||
it("documents canonical API auth and idempotency for generated specs and Postman requests", async () => {
|
||||
vi.mocked(getAllLegacyEntities).mockResolvedValue([entity] as never);
|
||||
|
||||
const specs = await ApiSpecsService.generateSpecs({ request: new Request("https://admin.example.com/api/docs") });
|
||||
const openApi = specs.openApi;
|
||||
|
||||
expect(openApi.servers).toEqual([{ url: "https://api.example.com", description: "API host" }]);
|
||||
expect(openApi.components.securitySchemes.bearerAuth).toMatchObject({
|
||||
type: "http",
|
||||
scheme: "bearer",
|
||||
bearerFormat: "service-token",
|
||||
});
|
||||
expect(openApi.paths["/v1/entities/{entity}/rows"].post.parameters).toContainEqual(expect.objectContaining({ name: "Idempotency-Key", required: true }));
|
||||
expect(openApi.paths["/v1/entities/{entity}/rows/{rowId}"].patch.parameters).toContainEqual(expect.objectContaining({ name: "Idempotency-Key", required: true }));
|
||||
expect(openApi.paths["/v1/entities/{entity}/rows/{rowId}"].delete.parameters).toContainEqual(expect.objectContaining({ name: "Idempotency-Key", required: true }));
|
||||
expect(openApi.paths["/v1/relationships"].post.parameters).toContainEqual(expect.objectContaining({ name: "Idempotency-Key", required: true }));
|
||||
expect(openApi.paths["/v1/relationships/{id}"].delete.parameters).toContainEqual(expect.objectContaining({ name: "Idempotency-Key", required: true }));
|
||||
|
||||
const postmanItems = specs.postmanCollection.item as Array<{
|
||||
name: string;
|
||||
request: { header: Array<{ key: string; value?: string }>; url: { raw: string; query?: Array<{ key: string; value: string; disabled?: boolean }> } };
|
||||
}>;
|
||||
expect(postmanItems.find((item) => item.name === "GET /v1/session/current-user")?.request.header).toEqual([
|
||||
expect.objectContaining({ key: "Authorization", value: "Bearer <token>" }),
|
||||
]);
|
||||
expect(postmanItems.find((item) => item.name === "POST /v1/api-keys/validate")?.request.header).toEqual([
|
||||
expect.objectContaining({ key: "x-api-key", value: "<api-key-or-service-token>" }),
|
||||
]);
|
||||
expect(postmanItems.some((item) => item.name.startsWith("POST /v1/workflows") || item.name.startsWith("GET /v1/workflows"))).toBe(false);
|
||||
expect(postmanItems.find((item) => item.name === "GET /v1/entities/:entity/rows")?.request.header).toEqual([
|
||||
expect.objectContaining({ key: "x-api-key", value: "<api-key>" }),
|
||||
]);
|
||||
expect(postmanItems.find((item) => item.name === "GET /v1/entities/:entity/rows")?.request.url.query).toContainEqual(
|
||||
expect.objectContaining({ key: "tenantId", value: "<tenantId>" })
|
||||
);
|
||||
expect(postmanItems.find((item) => item.name === "GET /v1/entities/:entity/rows")?.request.url.raw).toContain("tenantId=%3CtenantId%3E");
|
||||
expect(postmanItems.find((item) => item.name === "POST /v1/entities/:entity/rows")?.request.header).toEqual([
|
||||
expect.objectContaining({ key: "x-api-key", value: "<api-key>" }),
|
||||
expect.objectContaining({ key: "Idempotency-Key" }),
|
||||
]);
|
||||
expect(postmanItems.find((item) => item.name === "POST /v1/ai/upload")?.request.header).toContainEqual(
|
||||
expect.objectContaining({ key: "x-tenant-id", value: "<x-tenant-id>" })
|
||||
);
|
||||
expect(postmanItems.find((item) => item.name === "POST /v1/analytics/events")?.request.header).toEqual([
|
||||
expect.objectContaining({ key: "x-analytics-token" }),
|
||||
]);
|
||||
expect(postmanItems.find((item) => item.name === "GET /v1/exports/:exportJobId/download-url")?.request.header).toEqual([
|
||||
expect.objectContaining({ key: "x-api-key", value: "<api-key>" }),
|
||||
]);
|
||||
expect(postmanItems.find((item) => item.name === "POST /v1/billing/stripe/webhooks")?.request.header).toEqual([
|
||||
expect.objectContaining({ key: "stripe-signature" }),
|
||||
]);
|
||||
expect(postmanItems.find((item) => item.name === "POST /v1/events/webhooks/attempts/:id")?.request.header).toEqual([
|
||||
expect.objectContaining({ key: "Authorization", value: "Basic <base64(mailjet:webhook-secret)>" }),
|
||||
]);
|
||||
|
||||
expect((specs.endpoints ?? []).map((endpoint) => endpoint.route)).toEqual([
|
||||
"https://api.example.com/v1/entities/contacts/rows",
|
||||
"https://api.example.com/v1/entities/contacts/rows/:rowId",
|
||||
"https://api.example.com/v1/entities/contacts/rows",
|
||||
"https://api.example.com/v1/entities/contacts/rows/:rowId",
|
||||
"https://api.example.com/v1/entities/contacts/rows/:rowId",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,340 @@
|
||||
import { openApiDocument } from "@toolctrl/api/openapi";
|
||||
import { getAllLegacyEntities, type LegacyEntityWithDetails } from "@toolctrl/db/entities.server";
|
||||
import type { TFunction } from "i18next";
|
||||
import type { ApiEndpointDto } from "../dtos/ApiEndpointDto";
|
||||
import ApiHelper from "~/utils/helpers/ApiHelper";
|
||||
import { getTranslations } from "~/locale/i18next.server";
|
||||
import { getAbsoluteApiUrl } from "~/utils/url.server";
|
||||
import { apiPath } from "~/utils/app/CanonicalUrlUtils";
|
||||
|
||||
export type ApiSpecsDto = {
|
||||
endpoints?: ApiEndpointDto[];
|
||||
openApi: CanonicalOpenApiDocument;
|
||||
postmanCollection: PostmanCollection;
|
||||
apiDocsUrl: string;
|
||||
};
|
||||
|
||||
type CanonicalOpenApiDocument = typeof openApiDocument & {
|
||||
servers: Array<{ url: string; description: string }>;
|
||||
};
|
||||
|
||||
type PostmanCollection = {
|
||||
info: {
|
||||
_postman_id: string;
|
||||
name: string;
|
||||
schema: string;
|
||||
};
|
||||
item: Array<ReturnType<typeof toPostmanItem>>;
|
||||
};
|
||||
|
||||
type PostmanHeader = { key: string; value: string; type: "text"; description?: string };
|
||||
type PostmanQueryParam = { key: string; value: string; disabled?: boolean; description?: string };
|
||||
|
||||
async function generateSpecs({ request }: { request: Request }): Promise<ApiSpecsDto> {
|
||||
const { t } = await getTranslations(request);
|
||||
const entities = (await getAllLegacyEntities({ tenantId: null, active: true })).filter((entity) => entity.hasApi);
|
||||
const openApi = generateCanonicalOpenApiSpecs({ request, t });
|
||||
|
||||
return {
|
||||
endpoints: entities.flatMap((entity) => getEntityEndpoints({ entity, t, apiOrigin: getApiOrigin(request) })),
|
||||
openApi,
|
||||
postmanCollection: generatePostmanCollection({ request, openApi }),
|
||||
apiDocsUrl: getAbsoluteApiUrl(request, apiPath("docs", { mode: "host-aware" })),
|
||||
};
|
||||
}
|
||||
|
||||
function getApiOrigin(request: Request) {
|
||||
return new URL(getAbsoluteApiUrl(request, "/")).origin;
|
||||
}
|
||||
|
||||
function generateCanonicalOpenApiSpecs({ request, t }: { request: Request; t: TFunction }): CanonicalOpenApiDocument {
|
||||
return {
|
||||
...openApiDocument,
|
||||
servers: [
|
||||
{
|
||||
url: getApiOrigin(request),
|
||||
description: translateApiSpec(t, "api.specs.hostDescription", "API host"),
|
||||
},
|
||||
],
|
||||
} as CanonicalOpenApiDocument;
|
||||
}
|
||||
|
||||
function getEntityApiRoute(apiOrigin: string, path: string) {
|
||||
return new URL(apiPath(path, { mode: "host-aware" }), apiOrigin).toString();
|
||||
}
|
||||
|
||||
function getEntityEndpoints({ entity, t, apiOrigin }: { entity: LegacyEntityWithDetails; t: TFunction; apiOrigin: string }) {
|
||||
const body = Object.fromEntries(
|
||||
entity.properties
|
||||
.filter((property) => !property.isDefault)
|
||||
.map((property) => [property.name, ApiHelper.getSampleValue({ property: property as never }) ?? ""])
|
||||
);
|
||||
const schema = Object.fromEntries(
|
||||
entity.properties
|
||||
.filter((property) => !property.isDefault)
|
||||
.map((property) => [property.name, ApiHelper.getSchemaType({ property: property as never }) ?? ""])
|
||||
);
|
||||
|
||||
return [
|
||||
{
|
||||
entity,
|
||||
route: getEntityApiRoute(apiOrigin, `entities/${entity.slug}/rows`),
|
||||
method: "GET",
|
||||
description: translateApiSpec(t, "api.specs.operations.getAll", `Get all ${t(entity.titlePlural)}`, { name: t(entity.titlePlural) }),
|
||||
responseSchema: JSON.stringify(schema, null, 2),
|
||||
bodyExample: "",
|
||||
},
|
||||
{
|
||||
entity,
|
||||
route: getEntityApiRoute(apiOrigin, `entities/${entity.slug}/rows/:rowId`),
|
||||
method: "GET",
|
||||
description: translateApiSpec(t, "api.specs.operations.get", `Get ${t(entity.title)}`, { name: t(entity.title) }),
|
||||
responseSchema: JSON.stringify(schema, null, 2),
|
||||
bodyExample: "",
|
||||
},
|
||||
{
|
||||
entity,
|
||||
route: getEntityApiRoute(apiOrigin, `entities/${entity.slug}/rows`),
|
||||
method: "POST",
|
||||
description: translateApiSpec(t, "api.specs.operations.create", `Create ${t(entity.title)}`, { name: t(entity.title) }),
|
||||
responseSchema: JSON.stringify(schema, null, 2),
|
||||
bodyExample: JSON.stringify(body, null, 2),
|
||||
},
|
||||
{
|
||||
entity,
|
||||
route: getEntityApiRoute(apiOrigin, `entities/${entity.slug}/rows/:rowId`),
|
||||
method: "PATCH",
|
||||
description: translateApiSpec(t, "api.specs.operations.update", `Update ${t(entity.title)}`, { name: t(entity.title) }),
|
||||
responseSchema: JSON.stringify(schema, null, 2),
|
||||
bodyExample: JSON.stringify(body, null, 2),
|
||||
},
|
||||
{
|
||||
entity,
|
||||
route: getEntityApiRoute(apiOrigin, `entities/${entity.slug}/rows/:rowId`),
|
||||
method: "DELETE",
|
||||
description: translateApiSpec(t, "api.specs.operations.delete", `Delete ${t(entity.title)}`, { name: t(entity.title) }),
|
||||
responseSchema: "",
|
||||
bodyExample: "",
|
||||
},
|
||||
] satisfies ApiEndpointDto[];
|
||||
}
|
||||
|
||||
function translateApiSpec(t: TFunction, key: string, fallback: string, options?: Record<string, unknown>) {
|
||||
const translated = t(key, options);
|
||||
return translated === key ? fallback : translated;
|
||||
}
|
||||
|
||||
function generatePostmanCollection({ request, openApi }: { request: Request; openApi: CanonicalOpenApiDocument }): PostmanCollection {
|
||||
const serverUrl = getApiOrigin(request);
|
||||
const paths = openApi.paths as Record<string, Record<string, unknown>>;
|
||||
|
||||
return {
|
||||
info: {
|
||||
_postman_id: "canonical-api",
|
||||
name: `${process.env.APP_NAME ?? "Boilerplate"} API`,
|
||||
schema: "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
|
||||
},
|
||||
item: Object.entries(paths).flatMap(([path, operations]) =>
|
||||
Object.entries(operations)
|
||||
.filter(([method]) => isHttpMethod(method))
|
||||
.map(([method, operation]) => toPostmanItem({ serverUrl, path, method: method.toUpperCase(), operation }))
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function toPostmanItem({ serverUrl, path, method, operation }: { serverUrl: string; path: string; method: string; operation: unknown }) {
|
||||
const description = getOperationString(operation, "description") ?? getOperationString(operation, "summary") ?? "";
|
||||
const postmanPath = path.replace(/\{([^}]+)\}/g, ":$1");
|
||||
const request: Record<string, unknown> = {
|
||||
url: getPostmanUrl(serverUrl, postmanPath, operation),
|
||||
method,
|
||||
header: getPostmanHeaders(operation),
|
||||
description,
|
||||
};
|
||||
|
||||
if (hasRequestBody(operation)) {
|
||||
request.body = {
|
||||
mode: "raw",
|
||||
raw: "{}",
|
||||
options: { raw: { language: "json" } },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: `${method} ${postmanPath}`,
|
||||
request,
|
||||
};
|
||||
}
|
||||
|
||||
function getPostmanUrl(serverUrl: string, route: string, operation: unknown) {
|
||||
const url = new URL(route, serverUrl);
|
||||
const query = getPostmanQueryParams(operation);
|
||||
for (const parameter of query) {
|
||||
if (!parameter.disabled) {
|
||||
url.searchParams.set(parameter.key, parameter.value);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
raw: url.toString(),
|
||||
protocol: url.protocol.replace(":", ""),
|
||||
host: [url.hostname],
|
||||
path: url.pathname.replace(/^\//, "").split("/"),
|
||||
port: url.port,
|
||||
...(query.length > 0 ? { query } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function getPostmanHeaders(operation: unknown) {
|
||||
const headers: PostmanHeader[] = [];
|
||||
const securityScheme = getPreferredPostmanSecurityScheme(getOperationArray(operation, "security"));
|
||||
const parameters = getOperationArray(operation, "parameters");
|
||||
|
||||
if (securityScheme === "apiKeyHeader") {
|
||||
headers.push({ key: "x-api-key", value: getApiKeyHeaderPlaceholder(operation), type: "text" });
|
||||
}
|
||||
if (securityScheme === "analyticsTokenHeader") {
|
||||
headers.push({ key: "x-analytics-token", value: "<analytics-token>", type: "text" });
|
||||
}
|
||||
if (securityScheme === "stripeWebhookSignature") {
|
||||
headers.push({ key: "stripe-signature", value: "<stripe-signature>", type: "text" });
|
||||
}
|
||||
if (securityScheme === "mailjetWebhookAuth") {
|
||||
headers.push({ key: "Authorization", value: "Basic <base64(mailjet:webhook-secret)>", type: "text" });
|
||||
}
|
||||
if (securityScheme === "bearerAuth" || securityScheme === "apiSessionBearer") {
|
||||
headers.push({ key: "Authorization", value: "Bearer <token>", type: "text" });
|
||||
}
|
||||
if (parameters.some((entry) => isParameterNamed(entry, "Idempotency-Key"))) {
|
||||
headers.push({ key: "Idempotency-Key", value: "<unique-operation-key>", type: "text" });
|
||||
}
|
||||
|
||||
for (const parameter of getPostmanHeaderParams(parameters)) {
|
||||
if (!headers.some((header) => header.key.toLowerCase() === parameter.key.toLowerCase())) {
|
||||
headers.push(parameter);
|
||||
}
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
function getPostmanQueryParams(operation: unknown): PostmanQueryParam[] {
|
||||
return getOperationArray(operation, "parameters")
|
||||
.filter((parameter) => getParameterLocation(parameter) === "query")
|
||||
.map((parameter) => {
|
||||
const key = getParameterName(parameter);
|
||||
return {
|
||||
key,
|
||||
value: `<${key}>`,
|
||||
...(getParameterRequired(parameter) ? {} : { disabled: true }),
|
||||
...getParameterDescription(parameter),
|
||||
};
|
||||
})
|
||||
.filter((parameter) => parameter.key.length > 0);
|
||||
}
|
||||
|
||||
function getPostmanHeaderParams(parameters: unknown[]): PostmanHeader[] {
|
||||
return parameters
|
||||
.filter((parameter) => getParameterLocation(parameter) === "header")
|
||||
.map((parameter) => {
|
||||
const key = getParameterName(parameter);
|
||||
return {
|
||||
key,
|
||||
value: `<${key}>`,
|
||||
type: "text" as const,
|
||||
...getParameterDescription(parameter),
|
||||
};
|
||||
})
|
||||
.filter((parameter) => parameter.key.length > 0);
|
||||
}
|
||||
|
||||
function getPreferredPostmanSecurityScheme(security: unknown[]) {
|
||||
if (security.some((entry) => isSecurityScheme(entry, "apiKeyHeader"))) {
|
||||
return "apiKeyHeader";
|
||||
}
|
||||
if (security.some((entry) => isSecurityScheme(entry, "analyticsTokenHeader"))) {
|
||||
return "analyticsTokenHeader";
|
||||
}
|
||||
if (security.some((entry) => isSecurityScheme(entry, "stripeWebhookSignature"))) {
|
||||
return "stripeWebhookSignature";
|
||||
}
|
||||
if (security.some((entry) => isSecurityScheme(entry, "mailjetWebhookAuth"))) {
|
||||
return "mailjetWebhookAuth";
|
||||
}
|
||||
if (security.some((entry) => isSecurityScheme(entry, "bearerAuth"))) {
|
||||
return "bearerAuth";
|
||||
}
|
||||
if (security.some((entry) => isSecurityScheme(entry, "apiSessionBearer"))) {
|
||||
return "apiSessionBearer";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getOperationString(operation: unknown, key: string) {
|
||||
return operation && typeof operation === "object" && typeof (operation as Record<string, unknown>)[key] === "string"
|
||||
? (operation as Record<string, string>)[key]
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function getOperationArray(operation: unknown, key: string) {
|
||||
const value = operation && typeof operation === "object" ? (operation as Record<string, unknown>)[key] : undefined;
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function hasRequestBody(operation: unknown) {
|
||||
return Boolean(operation && typeof operation === "object" && "requestBody" in operation);
|
||||
}
|
||||
|
||||
function hasScopedApiKeyPermissions(operation: unknown) {
|
||||
return Boolean(operation && typeof operation === "object" && "x-api-key-permissions" in operation);
|
||||
}
|
||||
|
||||
function getApiKeyHeaderPlaceholder(operation: unknown) {
|
||||
if (operation && typeof operation === "object" && "x-api-key-placeholder" in operation) {
|
||||
const placeholder = (operation as { "x-api-key-placeholder"?: unknown })["x-api-key-placeholder"];
|
||||
if (typeof placeholder === "string" && placeholder.length > 0) {
|
||||
return placeholder;
|
||||
}
|
||||
}
|
||||
return hasScopedApiKeyPermissions(operation) ? "<api-key>" : "<service-token>";
|
||||
}
|
||||
|
||||
function isSecurityScheme(value: unknown, scheme: string) {
|
||||
return Boolean(value && typeof value === "object" && scheme in value);
|
||||
}
|
||||
|
||||
function isParameterNamed(value: unknown, name: string) {
|
||||
return Boolean(value && typeof value === "object" && (value as Record<string, unknown>).name === name);
|
||||
}
|
||||
|
||||
function getParameterName(value: unknown) {
|
||||
return value && typeof value === "object" && typeof (value as Record<string, unknown>).name === "string"
|
||||
? ((value as Record<string, string>).name as string)
|
||||
: "";
|
||||
}
|
||||
|
||||
function getParameterLocation(value: unknown) {
|
||||
return value && typeof value === "object" && typeof (value as Record<string, unknown>).in === "string"
|
||||
? ((value as Record<string, string>).in as string)
|
||||
: "";
|
||||
}
|
||||
|
||||
function getParameterRequired(value: unknown) {
|
||||
return Boolean(value && typeof value === "object" && (value as Record<string, unknown>).required === true);
|
||||
}
|
||||
|
||||
function getParameterDescription(value: unknown) {
|
||||
if (value && typeof value === "object" && typeof (value as Record<string, unknown>).description === "string") {
|
||||
return { description: (value as Record<string, string>).description };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function isHttpMethod(value: string) {
|
||||
return ["get", "post", "put", "patch", "delete", "options", "head"].includes(value);
|
||||
}
|
||||
|
||||
export default {
|
||||
generateSpecs,
|
||||
getEntityEndpoints,
|
||||
};
|
||||
@@ -0,0 +1,101 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@toolctrl/db/logs.server", () => ({
|
||||
createLegacyEntityWebhookLog: vi.fn(async (data) => data),
|
||||
createLegacyRowLog: vi.fn(async () => ({ id: "log_1" })),
|
||||
getLegacyEntityWebhooksByAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("~/utils/url.server", () => ({
|
||||
getBaseURL: vi.fn((request: Request) => new URL(request.url).origin),
|
||||
}));
|
||||
|
||||
vi.mock("~/utils/helpers/ApiHelper", () => ({
|
||||
default: {
|
||||
getApiFormat: vi.fn(() => ({ formatted: true })),
|
||||
},
|
||||
}));
|
||||
|
||||
import { createLegacyEntityWebhookLog, createLegacyRowLog, getLegacyEntityWebhooksByAction } from "@toolctrl/db/logs.server";
|
||||
import { createApiRowLog } from "./RowLogService.server";
|
||||
|
||||
describe("RowLogService webhook delivery", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("fetch", vi.fn(async () => new Response(null, { status: 202 })));
|
||||
});
|
||||
|
||||
it("resolves relative webhook URLs against the request base before validation", async () => {
|
||||
vi.mocked(getLegacyEntityWebhooksByAction).mockResolvedValueOnce([{ id: "webhook_relative", method: "POST", endpoint: "/webhooks/row" }]);
|
||||
|
||||
await createApiRowLog(new Request("https://tenant.example.com/api/rows"), {
|
||||
tenantId: "tenant_1",
|
||||
action: "created",
|
||||
entity: { id: "entity_1" },
|
||||
item: { id: "row_1" },
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"https://tenant.example.com/webhooks/row",
|
||||
expect.objectContaining({ method: "POST", redirect: "manual", signal: expect.any(AbortSignal) })
|
||||
);
|
||||
expect(createLegacyEntityWebhookLog).toHaveBeenCalledWith({ webhookId: "webhook_relative", logId: "log_1", status: 202 });
|
||||
});
|
||||
|
||||
it("follows safe redirects after validating the redirect target", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response(null, { status: 307, headers: { location: "https://hooks.example.com/row-next" } }))
|
||||
.mockResolvedValueOnce(new Response(null, { status: 202 }))
|
||||
);
|
||||
vi.mocked(getLegacyEntityWebhooksByAction).mockResolvedValueOnce([{ id: "webhook_redirect", method: "POST", endpoint: "https://hooks.example.com/row" }]);
|
||||
|
||||
await createApiRowLog(new Request("https://tenant.example.com/api/rows"), {
|
||||
tenantId: "tenant_1",
|
||||
action: "created",
|
||||
entity: { id: "entity_1" },
|
||||
item: { id: "row_1" },
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenNthCalledWith(1, "https://hooks.example.com/row", expect.objectContaining({ method: "POST", redirect: "manual" }));
|
||||
expect(fetch).toHaveBeenNthCalledWith(2, "https://hooks.example.com/row-next", expect.objectContaining({ method: "POST", redirect: "manual" }));
|
||||
expect(createLegacyEntityWebhookLog).toHaveBeenCalledWith({ webhookId: "webhook_redirect", logId: "log_1", status: 202 });
|
||||
});
|
||||
|
||||
it("blocks unsafe redirect targets without following them", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(async () => new Response(null, { status: 302, headers: { location: "http://127.0.0.1/webhook" } })));
|
||||
vi.mocked(getLegacyEntityWebhooksByAction).mockResolvedValueOnce([{ id: "webhook_redirect_blocked", method: "POST", endpoint: "https://hooks.example.com/row" }]);
|
||||
|
||||
await createApiRowLog(new Request("https://tenant.example.com/api/rows"), {
|
||||
tenantId: "tenant_1",
|
||||
action: "created",
|
||||
entity: { id: "entity_1" },
|
||||
item: { id: "row_1" },
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(fetch).toHaveBeenCalledWith("https://hooks.example.com/row", expect.objectContaining({ redirect: "manual" }));
|
||||
expect(createLegacyEntityWebhookLog).toHaveBeenCalledWith({ webhookId: "webhook_redirect_blocked", logId: "log_1", status: 0 });
|
||||
});
|
||||
|
||||
it("blocks unsafe webhook URLs and records failed delivery status without fetching", async () => {
|
||||
vi.mocked(getLegacyEntityWebhooksByAction).mockResolvedValueOnce([
|
||||
{ id: "webhook_loopback", method: "POST", endpoint: "http://127.0.0.1/webhook" },
|
||||
{ id: "webhook_relative_localhost", method: "POST", endpoint: "/webhooks/row" },
|
||||
]);
|
||||
|
||||
await createApiRowLog(new Request("https://localhost/api/rows"), {
|
||||
tenantId: "tenant_1",
|
||||
action: "created",
|
||||
entity: { id: "entity_1" },
|
||||
item: { id: "row_1" },
|
||||
});
|
||||
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(createLegacyRowLog).toHaveBeenCalledTimes(1);
|
||||
expect(createLegacyEntityWebhookLog).toHaveBeenCalledWith({ webhookId: "webhook_loopback", logId: "log_1", status: 0 });
|
||||
expect(createLegacyEntityWebhookLog).toHaveBeenCalledWith({ webhookId: "webhook_relative_localhost", logId: "log_1", status: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,285 @@
|
||||
import { createLegacyEntityWebhookLog, createLegacyRowLog, getLegacyEntityWebhooksByAction } from "@toolctrl/db/logs.server";
|
||||
import { getBaseURL } from "~/utils/url.server";
|
||||
import ApiHelper from "~/utils/helpers/ApiHelper";
|
||||
|
||||
const LEGACY_WEBHOOK_FETCH_TIMEOUT_MS = 10_000;
|
||||
const BLOCKED_WEBHOOK_STATUS = 0;
|
||||
const LEGACY_WEBHOOK_REDIRECT_LIMIT = 5;
|
||||
const INTERNAL_WEBHOOK_HOST_SUFFIXES = [".localhost", ".local", ".internal", ".lan", ".home", ".corp", ".intranet", ".svc"] as const;
|
||||
|
||||
type ApiRowLogEntity = {
|
||||
id: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type ApiRowLogItem = {
|
||||
id: string;
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
|
||||
export async function createApiRowLog(
|
||||
request: Request,
|
||||
data: {
|
||||
tenantId: string | null;
|
||||
createdByUserId?: string | null;
|
||||
createdByApiKey?: string | null;
|
||||
action: string;
|
||||
entity: ApiRowLogEntity;
|
||||
item: ApiRowLogItem;
|
||||
commentId?: string | null;
|
||||
details?: string;
|
||||
},
|
||||
) {
|
||||
const log = await createLegacyRowLog({
|
||||
tenantId: data.tenantId,
|
||||
userId: data.createdByUserId ?? null,
|
||||
apiKeyId: data.createdByApiKey ?? null,
|
||||
url: new URL(request.url).pathname,
|
||||
rowId: data.item?.id ?? null,
|
||||
action: data.action,
|
||||
details: data.details ?? null,
|
||||
commentId: data.commentId ?? null,
|
||||
});
|
||||
|
||||
const apiFormat = ApiHelper.getApiFormat(data.entity as never, data.item as never);
|
||||
await callApiEntityWebhooks({
|
||||
logId: log.id,
|
||||
entityId: data.entity.id,
|
||||
action: data.action,
|
||||
body: apiFormat,
|
||||
request,
|
||||
});
|
||||
|
||||
return log;
|
||||
}
|
||||
|
||||
async function callApiEntityWebhooks({
|
||||
logId,
|
||||
entityId,
|
||||
action,
|
||||
body,
|
||||
request,
|
||||
}: {
|
||||
logId: string;
|
||||
entityId: string;
|
||||
action: string;
|
||||
body: string;
|
||||
request: Request;
|
||||
}) {
|
||||
const webhooks = await getLegacyEntityWebhooksByAction(entityId, action);
|
||||
return Promise.all(
|
||||
webhooks.map(async (webhook) => {
|
||||
if (!webhook.endpoint) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const status = await fetchLegacyWebhookStatus(webhook.endpoint, request, {
|
||||
method: webhook.method,
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
return createLegacyEntityWebhookLog({
|
||||
webhookId: webhook.id,
|
||||
logId,
|
||||
status,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveWebhookEndpoint(endpoint: string, request: Request): string {
|
||||
const resolvedEndpoint = endpoint.startsWith("/") ? getBaseURL(request) + endpoint : endpoint;
|
||||
return validateOutboundWebhookUrl(resolvedEndpoint);
|
||||
}
|
||||
|
||||
async function fetchLegacyWebhookStatus(endpoint: string, request: Request, init: RequestInit): Promise<number> {
|
||||
try {
|
||||
const response = await fetchWebhookWithRedirects(resolveWebhookEndpoint(endpoint, request), init);
|
||||
return response.status;
|
||||
} catch {
|
||||
return BLOCKED_WEBHOOK_STATUS;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(input: Parameters<typeof fetch>[0], init: RequestInit): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), LEGACY_WEBHOOK_FETCH_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
return await fetch(input, { ...init, redirect: "manual", signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWebhookWithRedirects(input: string, init: RequestInit): Promise<Response> {
|
||||
let currentUrl = input;
|
||||
let currentInit = init;
|
||||
|
||||
for (let redirects = 0; redirects <= LEGACY_WEBHOOK_REDIRECT_LIMIT; redirects += 1) {
|
||||
const response = await fetchWithTimeout(currentUrl, currentInit);
|
||||
if (!isRedirectResponse(response.status)) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const location = response.headers.get("location");
|
||||
if (!location) {
|
||||
return response;
|
||||
}
|
||||
|
||||
if (redirects === LEGACY_WEBHOOK_REDIRECT_LIMIT) {
|
||||
throw new Error("Webhook redirect limit exceeded.");
|
||||
}
|
||||
|
||||
currentUrl = resolveOutboundWebhookRedirect(currentUrl, location);
|
||||
currentInit = buildRedirectRequestInit(currentInit, response.status);
|
||||
}
|
||||
|
||||
throw new Error("Webhook redirect limit exceeded.");
|
||||
}
|
||||
|
||||
function resolveOutboundWebhookRedirect(currentUrl: string, location: string): string {
|
||||
let redirectUrl: URL;
|
||||
try {
|
||||
redirectUrl = new URL(location, currentUrl);
|
||||
} catch {
|
||||
throw new Error("Webhook redirect location must be a valid URL.");
|
||||
}
|
||||
return validateOutboundWebhookUrl(redirectUrl.toString());
|
||||
}
|
||||
|
||||
function isRedirectResponse(status: number): boolean {
|
||||
return status >= 300 && status < 400;
|
||||
}
|
||||
|
||||
function buildRedirectRequestInit(init: RequestInit, status: number): RequestInit {
|
||||
const method = typeof init.method === "string" ? init.method.toUpperCase() : undefined;
|
||||
if (status === 303 || ((status === 301 || status === 302) && method === "POST")) {
|
||||
const headers = new Headers(init.headers);
|
||||
headers.delete("content-type");
|
||||
headers.delete("content-length");
|
||||
return { ...init, method: "GET", body: undefined, headers };
|
||||
}
|
||||
return init;
|
||||
}
|
||||
|
||||
function validateOutboundWebhookUrl(endpoint: string): string {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(endpoint);
|
||||
} catch {
|
||||
throw new Error("Webhook endpoint must be an absolute URL.");
|
||||
}
|
||||
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
throw new Error("Webhook endpoint must use http or https.");
|
||||
}
|
||||
|
||||
if (isBlockedWebhookHostname(url.hostname)) {
|
||||
throw new Error("Webhook endpoint host is not allowed.");
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function isBlockedWebhookHostname(hostname: string): boolean {
|
||||
const normalizedHostname = hostname.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "");
|
||||
if (!normalizedHostname) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const ipv4 = parseIpv4Address(normalizedHostname);
|
||||
if (ipv4) {
|
||||
return isBlockedIpv4Address(ipv4);
|
||||
}
|
||||
|
||||
const ipv6 = parseIpv6Address(normalizedHostname);
|
||||
if (ipv6) {
|
||||
return isBlockedIpv6Address(ipv6);
|
||||
}
|
||||
|
||||
return (
|
||||
normalizedHostname === "localhost" ||
|
||||
!normalizedHostname.includes(".") ||
|
||||
INTERNAL_WEBHOOK_HOST_SUFFIXES.some((suffix) => normalizedHostname.endsWith(suffix))
|
||||
);
|
||||
}
|
||||
|
||||
function parseIpv4Address(hostname: string): [number, number, number, number] | undefined {
|
||||
const parts = hostname.split(".");
|
||||
if (parts.length !== 4) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const octets = parts.map((part) => Number(part));
|
||||
if (octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return octets as [number, number, number, number];
|
||||
}
|
||||
|
||||
function isBlockedIpv4Address([first, second]: [number, number, number, number]): boolean {
|
||||
return (
|
||||
first === 0 ||
|
||||
first === 10 ||
|
||||
first === 127 ||
|
||||
first >= 224 ||
|
||||
(first === 100 && second >= 64 && second <= 127) ||
|
||||
(first === 169 && second === 254) ||
|
||||
(first === 172 && second >= 16 && second <= 31) ||
|
||||
(first === 192 && second === 168) ||
|
||||
(first === 198 && (second === 18 || second === 19))
|
||||
);
|
||||
}
|
||||
|
||||
function parseIpv6Address(hostname: string): number[] | undefined {
|
||||
if (!hostname.includes(":")) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const [head = "", tail = "", extra] = hostname.split("::");
|
||||
if (extra !== undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const headParts = head ? head.split(":") : [];
|
||||
const tailParts = tail ? tail.split(":") : [];
|
||||
const missingPartCount = 8 - headParts.length - tailParts.length;
|
||||
if (missingPartCount < 0 || (!hostname.includes("::") && missingPartCount !== 0)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parts = [...headParts, ...Array(missingPartCount).fill("0"), ...tailParts];
|
||||
if (parts.length !== 8) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const hextets = parts.map((part) => Number.parseInt(part, 16));
|
||||
if (hextets.some((part) => !Number.isInteger(part) || part < 0 || part > 0xffff)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return hextets;
|
||||
}
|
||||
|
||||
function isBlockedIpv6Address(hextets: number[]): boolean {
|
||||
const first = hextets[0] ?? 0;
|
||||
const isUnspecified = hextets.every((part) => part === 0);
|
||||
const isLoopback = hextets.slice(0, 7).every((part) => part === 0) && hextets[7] === 1;
|
||||
const isUniqueLocal = (first & 0xfe00) === 0xfc00;
|
||||
const isLinkLocal = (first & 0xffc0) === 0xfe80;
|
||||
const isIpv4Mapped = hextets.slice(0, 5).every((part) => part === 0) && hextets[5] === 0xffff;
|
||||
const isIpv4Compatible = hextets.slice(0, 6).every((part) => part === 0);
|
||||
|
||||
if (isUnspecified || isLoopback || isUniqueLocal || isLinkLocal) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isIpv4Mapped || isIpv4Compatible) {
|
||||
const ipv4: [number, number, number, number] = [hextets[6] >> 8, hextets[6] & 0xff, hextets[7] >> 8, hextets[7] & 0xff];
|
||||
return isBlockedIpv4Address(ipv4);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Prisma } from "@toolctrl/db/prisma-browser";
|
||||
import ApiKeyLogsConstants from "./ApiKeyLogsConstants";
|
||||
|
||||
function getGroupByValues(searchParams: URLSearchParams) {
|
||||
const groupByValues = searchParams
|
||||
.getAll("groupBy")
|
||||
.filter((x) => x)
|
||||
.sort();
|
||||
const groupBy: Prisma.ApiKeyLogScalarFieldEnum[] = [];
|
||||
for (const param of groupByValues) {
|
||||
if (Object.keys(Prisma.ApiKeyLogScalarFieldEnum).includes(param)) {
|
||||
groupBy.push(param as Prisma.ApiKeyLogScalarFieldEnum);
|
||||
}
|
||||
}
|
||||
return groupBy.length > 0 ? groupBy.map((x) => x.toString()) : ApiKeyLogsConstants.DEFAULT_GROUP_BY;
|
||||
}
|
||||
|
||||
export default {
|
||||
getGroupByValues,
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
const DEFAULT_GROUP_BY = ["method", "endpoint"];
|
||||
|
||||
export default {
|
||||
DEFAULT_GROUP_BY,
|
||||
};
|
||||
Reference in new issue
Block a user