refactor: enforce frontend view model boundaries
This commit is contained in:
@@ -16,6 +16,7 @@ const useSingleQuery = () =>
|
||||
useAuthenticatedQuery(
|
||||
apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts"),
|
||||
);
|
||||
|
||||
const useMultipleQueries = () =>
|
||||
useAuthenticatedQueries([
|
||||
apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts"),
|
||||
@@ -29,6 +30,7 @@ describe("useAuthenticatedQuery", () => {
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts", () => {
|
||||
requests();
|
||||
|
||||
return HttpResponse.json(
|
||||
resource([
|
||||
{
|
||||
|
||||
@@ -16,6 +16,7 @@ function useAuthenticatedQueries<TData, TError, TQueryKey extends QueryKey>(
|
||||
queries: UseQueryOptions<TData, TError, TData, TQueryKey>[],
|
||||
) {
|
||||
const hasSession = useHasAuthenticatedSession();
|
||||
|
||||
return useQueries({
|
||||
queries: queries.map(
|
||||
(options): UseQueryOptions<TData, TError, TData, TQueryKey> => ({
|
||||
|
||||
@@ -9,14 +9,18 @@ import { apiClient, configureAuthClient } from "./client";
|
||||
describe("apiClient authentication", () => {
|
||||
it("refreshes once and retries an unauthorized protected request", async () => {
|
||||
let token = "expired";
|
||||
|
||||
const refresh = vi.fn(() => {
|
||||
token = "fresh";
|
||||
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
|
||||
const authorization: string[] = [];
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
|
||||
authorization.push(request.headers.get("Authorization") ?? "");
|
||||
|
||||
return authorization.length === 1
|
||||
? HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 })
|
||||
: HttpResponse.json(resource([]));
|
||||
@@ -50,10 +54,14 @@ describe("apiClient authentication", () => {
|
||||
});
|
||||
|
||||
it("does not attach Bearer auth to authentication endpoints", async () => {
|
||||
let authorization: null | string = /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null;
|
||||
let authorization: null | string
|
||||
// eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract.
|
||||
= null;
|
||||
|
||||
server.use(
|
||||
http.post("*/api/v1/auth/refresh", ({ request }) => {
|
||||
authorization = request.headers.get("Authorization");
|
||||
|
||||
return HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 });
|
||||
}),
|
||||
);
|
||||
@@ -71,12 +79,15 @@ describe("apiClient authentication", () => {
|
||||
let generation = 0;
|
||||
let calls = 0;
|
||||
|
||||
const { promise: pending, resolve: release } = Promise.withResolvers<void>();
|
||||
const { promise: pending, resolve: release }
|
||||
= Promise.withResolvers<void>();
|
||||
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts", async () => {
|
||||
if (++calls === 1)
|
||||
return HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 });
|
||||
await pending;
|
||||
|
||||
return HttpResponse.json(resource([{ id: "previous-user-account" }]));
|
||||
}),
|
||||
);
|
||||
@@ -86,10 +97,12 @@ describe("apiClient authentication", () => {
|
||||
generation: () => generation,
|
||||
refresh: () => Promise.resolve(true),
|
||||
});
|
||||
|
||||
// eslint-disable-next-line vitest/valid-expect -- Attach rejection handling before releasing the request; the assertion is awaited below.
|
||||
const response = expect(
|
||||
apiClient.GET("/api/v1/brokerage-accounts"),
|
||||
).rejects.toMatchObject({ name: "AbortError" });
|
||||
|
||||
await vi.waitFor(() => expect(calls).toBe(2));
|
||||
generation += 1;
|
||||
release();
|
||||
|
||||
@@ -8,13 +8,16 @@ import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details";
|
||||
const HTTP_UNAUTHORIZED = 401;
|
||||
|
||||
const configuredBaseUrl: unknown = import.meta.env.VITE_API_BASE_URL;
|
||||
|
||||
const apiBaseUrl
|
||||
= typeof configuredBaseUrl === "string"
|
||||
? configuredBaseUrl
|
||||
: "http://localhost:3000";
|
||||
|
||||
type AuthHandlers = {
|
||||
accessToken: () => null | string | undefined;
|
||||
accessToken: () => null
|
||||
| string
|
||||
| undefined;
|
||||
clear: () => void;
|
||||
generation?: () => number;
|
||||
refresh: () => Promise<boolean>;
|
||||
@@ -22,12 +25,16 @@ type AuthHandlers = {
|
||||
|
||||
const inFlight = new Set<{ invalidated: boolean; pathname: string }>();
|
||||
const latestReads = new Map<string, { pending: number; sequence: number }>();
|
||||
|
||||
function invalidateApiRequests(isMatches: (pathname: string) => boolean) {
|
||||
for (const request of inFlight)
|
||||
if (isMatches(request.pathname)) request.invalidated = true;
|
||||
}
|
||||
|
||||
const sessionState: { handlers?: AuthHandlers; hasSession: boolean } = { hasSession: false };
|
||||
const sessionState: { handlers?: AuthHandlers; hasSession: boolean } = {
|
||||
hasSession: false,
|
||||
};
|
||||
|
||||
const authSessionListeners = new Set<() => void>();
|
||||
|
||||
async function authenticatedFetch(request: Request): Promise<Response> {
|
||||
@@ -36,17 +43,21 @@ async function authenticatedFetch(request: Request): Promise<Response> {
|
||||
url.searchParams.sort();
|
||||
const key = `${request.method} ${url}`;
|
||||
const ticket = { invalidated: false, pathname: url.pathname };
|
||||
|
||||
const read
|
||||
= request.method === "GET"
|
||||
? (latestReads.get(key) ?? { pending: 0, sequence: 0 })
|
||||
: undefined;
|
||||
|
||||
if (read) {
|
||||
read.sequence += 1;
|
||||
read.pending += 1;
|
||||
latestReads.set(key, read);
|
||||
}
|
||||
|
||||
const sequence = read?.sequence;
|
||||
inFlight.add(ticket);
|
||||
|
||||
try {
|
||||
return await fetchCurrent(
|
||||
request,
|
||||
@@ -72,47 +83,65 @@ async function fetchCurrent(
|
||||
const isAuthRequest = new URL(request.url).pathname.startsWith(
|
||||
"/api/v1/auth/",
|
||||
);
|
||||
|
||||
const session = sessionState.handlers;
|
||||
const generation = session?.generation?.();
|
||||
|
||||
const isCurrent = () =>
|
||||
(session?.generation
|
||||
? generation === session.generation()
|
||||
&& generation === sessionState.handlers?.generation?.()
|
||||
: session === sessionState.handlers) && isLatest();
|
||||
|
||||
const token = session?.accessToken();
|
||||
|
||||
const withToken = (source: Request, value: string) => {
|
||||
const headers = new Headers(source.headers);
|
||||
headers.set("Authorization", `Bearer ${value}`);
|
||||
|
||||
return new Request(source, { headers });
|
||||
};
|
||||
|
||||
const authorized
|
||||
= token && !isAuthRequest ? withToken(request, token) : request;
|
||||
|
||||
const retryRequest = authorized.clone();
|
||||
const response = await fetch(authorized);
|
||||
await response.clone().arrayBuffer();
|
||||
if (!isAuthRequest && !isCurrent())
|
||||
throw new DOMException("Session changed", "AbortError");
|
||||
if (isAuthRequest || !sessionState.handlers || response.status !== HTTP_UNAUTHORIZED)
|
||||
if (
|
||||
isAuthRequest
|
||||
|| !sessionState.handlers
|
||||
|| response.status !== HTTP_UNAUTHORIZED
|
||||
)
|
||||
return response;
|
||||
let problem: unknown;
|
||||
|
||||
try {
|
||||
problem = await response.clone().json();
|
||||
}
|
||||
catch {
|
||||
return response;
|
||||
}
|
||||
if (!isApiErrorCode(apiErrorCode(problem), ApiErrorCode.Unauthorized)) return response;
|
||||
|
||||
if (!isApiErrorCode(apiErrorCode(problem), ApiErrorCode.Unauthorized))
|
||||
return response;
|
||||
if (!isCurrent()) throw new DOMException("Session changed", "AbortError");
|
||||
|
||||
if (!(await sessionState.handlers.refresh())) {
|
||||
if (isCurrent()) sessionState.handlers.clear();
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
if (!isCurrent()) throw new DOMException("Session changed", "AbortError");
|
||||
const refreshedToken = sessionState.handlers.accessToken();
|
||||
if (!refreshedToken) return response;
|
||||
const retried = await fetch(withToken(retryRequest, refreshedToken));
|
||||
await retried.clone().arrayBuffer();
|
||||
if (!isCurrent()) throw new DOMException("Session changed", "AbortError");
|
||||
|
||||
return retried;
|
||||
}
|
||||
|
||||
@@ -128,6 +157,7 @@ function setAuthenticatedSession(hasSession: boolean) {
|
||||
|
||||
function subscribeAuthenticatedSession(listener: () => void) {
|
||||
authSessionListeners.add(listener);
|
||||
|
||||
return () => authSessionListeners.delete(listener);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,12 @@ import { expect, it } from "vitest";
|
||||
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details";
|
||||
|
||||
it("matches known codes and preserves unknown wire codes for fallback handling", () => {
|
||||
expect(isApiErrorCode(apiErrorCode({ code: "UNAUTHORIZED" }), ApiErrorCode.Unauthorized)).toBe(true);
|
||||
expect(
|
||||
isApiErrorCode(
|
||||
apiErrorCode({ code: "UNAUTHORIZED" }),
|
||||
ApiErrorCode.Unauthorized,
|
||||
),
|
||||
).toBe(true);
|
||||
const unknownCode = apiErrorCode({ code: "FUTURE_PROVIDER_ERROR" });
|
||||
expect(unknownCode).toBe("FUTURE_PROVIDER_ERROR");
|
||||
expect(isApiErrorCode(unknownCode, ApiErrorCode.Unauthorized)).toBe(false);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { type QueryClient,
|
||||
import {
|
||||
type QueryClient,
|
||||
type QueryFunctionContext,
|
||||
type QueryKey,
|
||||
useQueryClient,
|
||||
type UseQueryOptions,
|
||||
type UseQueryResult } from "@tanstack/react-query";
|
||||
type UseQueryResult,
|
||||
} from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import type { components } from "./generated/schema";
|
||||
@@ -18,6 +20,7 @@ type Resource = {
|
||||
data: unknown;
|
||||
meta: components["schemas"]["InvestmentResourceMetaDto"];
|
||||
};
|
||||
|
||||
function invalidateResourceAccess(
|
||||
client: QueryClient,
|
||||
group: string,
|
||||
@@ -36,10 +39,12 @@ function invalidateResourceAccess(
|
||||
const epochs = accessEpochs.get(client) ?? new Map<string, number>();
|
||||
accessEpochs.set(client, epochs);
|
||||
epochs.set(group, (epochs.get(group) ?? 0) + 1);
|
||||
|
||||
const affected = {
|
||||
predicate: (cached: { meta?: Record<string, unknown> }) =>
|
||||
cached.meta?.accessGroup === group,
|
||||
};
|
||||
|
||||
void client.cancelQueries(affected);
|
||||
for (const cached of client.getQueryCache().findAll(affected))
|
||||
cached.setState({
|
||||
@@ -57,9 +62,11 @@ function unwrapResourceResult<T extends Resource, E>(
|
||||
const isMissing = result.isSuccess && result.data.data === null;
|
||||
const data: T["data"] | undefined = result.data?.data;
|
||||
const meta: T["meta"] | undefined = result.data?.meta;
|
||||
|
||||
const status: UseQueryResult<T, E>["status"] = isMissing
|
||||
? "pending"
|
||||
: result.status;
|
||||
|
||||
// Project resource fields; refetch still returns the transport envelope.
|
||||
return {
|
||||
...result,
|
||||
@@ -81,17 +88,22 @@ function useResourceQuery<T extends Resource, E, K extends QueryKey>(
|
||||
options: UseQueryOptions<T, E, T, K>,
|
||||
) {
|
||||
const client = useQueryClient();
|
||||
|
||||
const group
|
||||
= typeof options.meta?.accessGroup === "string"
|
||||
? options.meta.accessGroup
|
||||
: undefined;
|
||||
|
||||
let epochs = accessEpochs.get(client);
|
||||
|
||||
if (!epochs) {
|
||||
epochs = new Map();
|
||||
accessEpochs.set(client, epochs);
|
||||
}
|
||||
|
||||
const groupEpochs = epochs;
|
||||
const queryFunction = options.queryFn;
|
||||
|
||||
const guarded
|
||||
= typeof queryFunction === "function"
|
||||
? async (context: QueryFunctionContext<K>) => {
|
||||
@@ -99,13 +111,16 @@ function useResourceQuery<T extends Resource, E, K extends QueryKey>(
|
||||
const result = await queryFunction(context);
|
||||
if (group && epoch !== (groupEpochs.get(group) ?? 0))
|
||||
throw new DOMException("Access changed", "AbortError");
|
||||
|
||||
return result;
|
||||
}
|
||||
: queryFunction;
|
||||
|
||||
const query = useAuthenticatedQuery({ ...options, queryFn: guarded });
|
||||
useEffect(() => {
|
||||
if (group) invalidateResourceAccess(client, group, query.error);
|
||||
}, [client, group, query.error]);
|
||||
|
||||
const observe = async (expectedStateVersion?: string) => {
|
||||
const queryFunction = guarded;
|
||||
if (
|
||||
@@ -116,13 +131,16 @@ function useResourceQuery<T extends Resource, E, K extends QueryKey>(
|
||||
return;
|
||||
// Cancellation is synchronous; start the replacement in the same boundary.
|
||||
void client.cancelQueries({ exact: true, queryKey: options.queryKey });
|
||||
|
||||
return client.query({
|
||||
...options,
|
||||
queryFn: async (context: QueryFunctionContext<K>) => {
|
||||
const [method, path, input] = context.queryKey;
|
||||
|
||||
const init = input as
|
||||
| undefined
|
||||
undefined
|
||||
| { params?: { query?: Record<string, unknown> } };
|
||||
|
||||
const queryKey = [
|
||||
method,
|
||||
path,
|
||||
@@ -134,7 +152,9 @@ function useResourceQuery<T extends Resource, E, K extends QueryKey>(
|
||||
},
|
||||
},
|
||||
] as unknown as K;
|
||||
|
||||
const response = await queryFunction({ ...context, queryKey });
|
||||
|
||||
if (
|
||||
expectedStateVersion
|
||||
&& response.meta.stateVersion !== expectedStateVersion
|
||||
@@ -143,8 +163,10 @@ function useResourceQuery<T extends Resource, E, K extends QueryKey>(
|
||||
const current = await queryFunction({ ...context, queryKey });
|
||||
if (current.meta.stateVersion !== expectedStateVersion)
|
||||
throw new DOMException("Resource changed", "AbortError");
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
retry: false,
|
||||
@@ -153,6 +175,7 @@ function useResourceQuery<T extends Resource, E, K extends QueryKey>(
|
||||
staleTime: 0,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
...unwrapResourceResult(query),
|
||||
observe,
|
||||
|
||||
Reference in New Issue
Block a user