refactor(ui): type overview action results as ApiResult (#12287)

This commit is contained in:
Alejandro Bailo
2026-08-04 13:41:44 +02:00
committed by GitHub
parent f9dbb0eee9
commit c74eac1369
20 changed files with 164 additions and 21 deletions
@@ -1,3 +1,5 @@
import type { ApiResult } from "@/types/server-actions";
import { AttackSurfaceOverview, AttackSurfaceOverviewResponse } from "./types";
const ATTACK_SURFACE_IDS = {
@@ -49,7 +51,7 @@ function mapAttackSurfaceItem(item: AttackSurfaceOverview): AttackSurfaceItem {
* @returns An array of AttackSurfaceItem objects sorted by the predefined order
*/
export function adaptAttackSurfaceOverview(
response: AttackSurfaceOverviewResponse | undefined,
response: ApiResult<AttackSurfaceOverviewResponse> | undefined,
): AttackSurfaceItem[] {
if (!response?.data || response.data.length === 0) {
return [];
@@ -3,6 +3,7 @@
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
import { handleApiResponse } from "@/lib/server-actions-helper";
import type { ApiResult } from "@/types/server-actions";
import { AttackSurfaceOverviewResponse } from "./types";
@@ -10,7 +11,7 @@ export const getAttackSurfaceOverview = async ({
filters = {},
}: {
filters?: Record<string, string | string[] | undefined>;
} = {}): Promise<AttackSurfaceOverviewResponse | undefined> => {
} = {}): Promise<ApiResult<AttackSurfaceOverviewResponse> | undefined> => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/overviews/attack-surfaces`);
@@ -1,5 +1,6 @@
import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance";
import { formatLabel } from "@/lib/categories";
import type { ApiResult } from "@/types/server-actions";
import { ComplianceWatchlistResponse } from "./compliance-watchlist.types";
@@ -36,7 +37,7 @@ function formatComplianceLabel(complianceId: string): string {
}
export function adaptComplianceWatchlistResponse(
response: ComplianceWatchlistResponse | undefined,
response: ApiResult<ComplianceWatchlistResponse> | undefined,
): EnrichedComplianceWatchlistItem[] {
if (!response?.data) {
return [];
@@ -3,6 +3,7 @@
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
import { handleApiResponse } from "@/lib/server-actions-helper";
import type { ApiResult } from "@/types/server-actions";
import { ComplianceWatchlistResponse } from "./compliance-watchlist.types";
@@ -10,7 +11,7 @@ export const getComplianceWatchlist = async ({
filters = {},
}: {
filters?: Record<string, string | string[] | undefined>;
} = {}): Promise<ComplianceWatchlistResponse | undefined> => {
} = {}): Promise<ApiResult<ComplianceWatchlistResponse> | undefined> => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/overviews/compliance-watchlist`);
+7 -3
View File
@@ -5,8 +5,12 @@ import { redirect } from "next/navigation";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
import { handleApiResponse } from "@/lib/server-actions-helper";
import type { ApiResult } from "@/types/server-actions";
import { FindingsSeverityOverviewResponse } from "./types";
import {
FindingsSeverityOverviewResponse,
FindingsStatusOverviewResponse,
} from "./types";
export const getFindingsByStatus = async ({
page = 1,
@@ -18,7 +22,7 @@ export const getFindingsByStatus = async ({
query?: string;
sort?: string;
filters?: Record<string, string | string[] | undefined>;
} = {}) => {
} = {}): Promise<ApiResult<FindingsStatusOverviewResponse> | undefined> => {
const headers = await getAuthHeaders({ contentType: false });
if (isNaN(Number(page)) || page < 1) redirect("/");
@@ -49,7 +53,7 @@ export const getFindingsBySeverity = async ({
filters = {},
}: {
filters?: Record<string, string | string[] | undefined>;
} = {}): Promise<FindingsSeverityOverviewResponse | undefined> => {
} = {}): Promise<ApiResult<FindingsSeverityOverviewResponse> | undefined> => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/overviews/findings_severity`);
@@ -5,6 +5,34 @@ interface OverviewResponseMeta {
version: string;
}
// Corresponds to the /overviews/findings endpoint (OverviewFindingSerializer)
export interface FindingsStatusAttributes {
new: number;
changed: number;
unchanged: number;
fail_new: number;
fail_changed: number;
pass_new: number;
pass_changed: number;
muted_new: number;
muted_changed: number;
total: number;
pass: number;
fail: number;
muted: number;
}
export interface FindingsStatusOverview {
type: "findings-overview";
id: string;
attributes: FindingsStatusAttributes;
}
export interface FindingsStatusOverviewResponse {
data: FindingsStatusOverview;
meta: OverviewResponseMeta;
}
export interface FindingsSeverityAttributes {
critical: number;
high: number;
+2 -1
View File
@@ -5,6 +5,7 @@ import { redirect } from "next/navigation";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
import { handleApiResponse } from "@/lib/server-actions-helper";
import type { ApiResult } from "@/types/server-actions";
import { ProvidersOverviewResponse } from "./types";
@@ -18,7 +19,7 @@ export const getProvidersOverview = async ({
query?: string;
sort?: string;
filters?: Record<string, string | string[] | undefined>;
} = {}): Promise<ProvidersOverviewResponse | undefined> => {
} = {}): Promise<ApiResult<ProvidersOverviewResponse> | undefined> => {
const headers = await getAuthHeaders({ contentType: false });
if (isNaN(Number(page)) || page < 1) redirect("/providers-overview");
+2 -1
View File
@@ -3,6 +3,7 @@
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
import { handleApiResponse } from "@/lib/server-actions-helper";
import type { ApiResult } from "@/types/server-actions";
import { RegionsOverviewResponse } from "./types";
@@ -10,7 +11,7 @@ export const getRegionsOverview = async ({
filters = {},
}: {
filters?: Record<string, string | string[] | undefined>;
} = {}): Promise<RegionsOverviewResponse | undefined> => {
} = {}): Promise<ApiResult<RegionsOverviewResponse> | undefined> => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/overviews/regions`);
@@ -1,4 +1,5 @@
import { getProviderDisplayName } from "@/types/providers";
import type { ApiResult } from "@/types/server-actions";
import { RegionsOverviewResponse } from "./types";
@@ -366,7 +367,7 @@ function formatRegionName(providerType: string, region: string): string {
* Adapts regions overview API response to threat map format.
*/
export function adaptRegionsOverviewToThreatMap(
regionsResponse: RegionsOverviewResponse | undefined,
regionsResponse: ApiResult<RegionsOverviewResponse> | undefined,
): ThreatMapData {
if (!regionsResponse?.data || regionsResponse.data.length === 0) {
return {
@@ -19,6 +19,8 @@ import {
Webhook,
} from "lucide-react";
import type { ApiResult } from "@/types/server-actions";
import {
ResourceGroupOverview,
ResourceGroupOverviewResponse,
@@ -191,7 +193,7 @@ function formatResourceGroupLabel(id: string): string {
* @returns An array of ResourceInventoryItem objects sorted by the predefined order
*/
export function adaptResourceGroupOverview(
response: ResourceGroupOverviewResponse | undefined,
response: ApiResult<ResourceGroupOverviewResponse> | undefined,
): ResourceInventoryItem[] {
if (!response?.data || response.data.length === 0) {
return [];
@@ -3,6 +3,7 @@
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
import { handleApiResponse } from "@/lib/server-actions-helper";
import type { ApiResult } from "@/types/server-actions";
import { ResourceGroupOverviewResponse } from "./types";
@@ -10,7 +11,7 @@ export const getResourceGroupOverview = async ({
filters = {},
}: {
filters?: Record<string, string | string[] | undefined>;
} = {}): Promise<ResourceGroupOverviewResponse | undefined> => {
} = {}): Promise<ApiResult<ResourceGroupOverviewResponse> | undefined> => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/overviews/resource-groups`);
@@ -1,5 +1,6 @@
import type { RadarDataPoint } from "@/components/graphs/types";
import { getCategoryLabel } from "@/lib/categories";
import type { ApiResult } from "@/types/server-actions";
import { CategoryOverview, CategoryOverviewResponse } from "./types";
@@ -44,7 +45,7 @@ function mapCategoryToRadarPoint(item: CategoryOverview): RadarDataPoint {
* @returns An array of RadarDataPoint objects for the radar chart
*/
export function adaptCategoryOverviewToRadarData(
response: CategoryOverviewResponse | undefined,
response: ApiResult<CategoryOverviewResponse> | undefined,
): RadarDataPoint[] {
if (!response?.data || response.data.length === 0) {
return [];
+2 -1
View File
@@ -3,6 +3,7 @@
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
import { handleApiResponse } from "@/lib/server-actions-helper";
import type { ApiResult } from "@/types/server-actions";
import { CategoryOverviewResponse } from "./types";
@@ -10,7 +11,7 @@ export const getCategoryOverview = async ({
filters = {},
}: {
filters?: Record<string, string | string[] | undefined>;
} = {}): Promise<CategoryOverviewResponse | undefined> => {
} = {}): Promise<ApiResult<CategoryOverviewResponse> | undefined> => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/overviews/categories`);
+2 -1
View File
@@ -3,6 +3,7 @@
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
import { handleApiResponse } from "@/lib/server-actions-helper";
import type { ApiResult } from "@/types/server-actions";
import { ServicesOverviewResponse } from "./types";
@@ -10,7 +11,7 @@ export const getServicesOverview = async ({
filters = {},
}: {
filters?: Record<string, string | string[] | undefined>;
} = {}): Promise<ServicesOverviewResponse | undefined> => {
} = {}): Promise<ApiResult<ServicesOverviewResponse> | undefined> => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/overviews/services`);
@@ -0,0 +1,61 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { fetchMock, getAuthHeadersMock, handleApiResponseMock } = vi.hoisted(
() => ({
fetchMock: vi.fn(),
getAuthHeadersMock: vi.fn(),
handleApiResponseMock: vi.fn(),
}),
);
vi.mock("@/lib", () => ({
apiBaseUrl: "https://api.example.com/api/v1",
getAuthHeaders: getAuthHeadersMock,
}));
vi.mock("@/lib/server-actions-helper", () => ({
handleApiResponse: handleApiResponseMock,
}));
import { getFindingsSeverityTrends } from "./severity-trends";
describe("getFindingsSeverityTrends", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", fetchMock);
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
fetchMock.mockResolvedValue(new Response(null, { status: 200 }));
});
it("returns an error status on a 4xx response shape", async () => {
// handleApiResponse resolves truthy {error, status} objects for 4xx.
handleApiResponseMock.mockResolvedValueOnce({
error: "Invalid filter",
status: 400,
});
const result = await getFindingsSeverityTrends();
expect(result).toEqual({ status: "error" });
});
it("returns an empty status on a no-content response shape", async () => {
// handleApiResponse resolves {success, status} for 204 and empty bodies.
handleApiResponseMock.mockResolvedValueOnce({
success: true,
status: 204,
});
const result = await getFindingsSeverityTrends();
expect(result).toEqual({ status: "empty" });
});
it("returns an empty status when the trend list has no entries", async () => {
handleApiResponseMock.mockResolvedValueOnce({ data: [] });
const result = await getFindingsSeverityTrends();
expect(result).toEqual({ status: "empty" });
});
});
@@ -7,6 +7,7 @@ import {
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
import { handleApiResponse } from "@/lib/server-actions-helper";
import type { ApiResult } from "@/types/server-actions";
import { adaptSeverityTrendsResponse } from "./severity-trends.adapter";
import {
@@ -35,10 +36,16 @@ const getFindingsSeverityTrends = async ({
headers,
});
const apiResponse: FindingsSeverityOverTimeResponse | undefined =
const apiResponse: ApiResult<FindingsSeverityOverTimeResponse> | undefined =
await handleApiResponse(response);
if (!apiResponse?.data || !Array.isArray(apiResponse.data)) {
// 4xx resolves a truthy {error, status} shape — surface it as an error
// instead of misreporting the trend as empty.
if (!apiResponse || "error" in apiResponse) {
return { status: "error" };
}
if (!apiResponse.data || !Array.isArray(apiResponse.data)) {
return { status: "empty" };
}
@@ -3,12 +3,15 @@
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
import { handleApiResponse } from "@/lib/server-actions-helper";
import type { ApiResult } from "@/types/server-actions";
import type { ThreatScoreResponse } from "./types";
export const getThreatScore = async ({
filters = {},
}: {
filters?: Record<string, string | string[] | undefined>;
} = {}) => {
} = {}): Promise<ApiResult<ThreatScoreResponse> | undefined> => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/overviews/threatscore`);
@@ -54,7 +54,7 @@ describe("ThreatScoreSSR", () => {
},
},
],
});
} as unknown as Awaited<ReturnType<typeof getThreatScore>>);
render(await ThreatScoreSSR({ searchParams: {} }));
@@ -91,7 +91,7 @@ describe("ThreatScoreSSR", () => {
},
},
],
});
} as unknown as Awaited<ReturnType<typeof getThreatScore>>);
render(await ThreatScoreSSR({ searchParams: {} }));
@@ -267,7 +267,13 @@ export default async function ComplianceDetail({
const snapshot = threatScoreResponse.data[0];
threatScoreData = {
overallScore: parseFloat(snapshot.attributes.overall_score),
sectionScores: snapshot.attributes.section_scores,
// The multi-provider aggregation branch serializes section scores as
// decimal strings.
sectionScores: Object.fromEntries(
Object.entries(snapshot.attributes.section_scores).map(
([name, value]) => [name, Number(value)],
),
),
};
}
}
+20
View File
@@ -15,3 +15,23 @@ export interface ServerActionFailure {
export type ServerActionResult<TData> =
| ServerActionSuccess<TData>
| ServerActionFailure;
export interface ApiErrorResult extends ServerActionFailure {
status: number;
data?: never;
}
export interface ApiNoContentResult {
success: true;
status: number;
data?: never;
}
// handleApiResponse resolves the raw JSON:API payload on success, but also
// resolves truthy no-data shapes: {error, status} for 4xx and
// {success, status} for 204/empty bodies. `data?: never` on those members
// keeps `result?.data?.attributes` guards compiling and narrowing correctly.
export type ApiResult<TResponse> =
| TResponse
| ApiErrorResult
| ApiNoContentResult;