shoc-frontend-new/src/api/api.ts
Arthur Bassi 32466c01d2 fix(work-orders): open uplifts from the slide-over and send evidence (SH-388)
Dispatchers need the design surface from the column and the detail,
and an optional evidence file is sent only after the scan passes.
2026-09-24 14:30:24 -03:00

188 lines
5.7 KiB
TypeScript

import ky, { type KyInstance, type Options } from "ky";
import { env } from "@/lib/env";
import { resolveApiPrefixUrl } from "@/lib/resolve-api-prefix-url";
import { API_PATHS } from "@/api/api-paths";
import { mapHttpStatusToMessage, normalizeApiRequestError } from "@/api/api-error";
import { getAccessToken } from "@/lib/auth/auth-storage";
import { expireSessionAndRedirectToLogin } from "@/lib/auth/expire-session";
import { traceHttpOperation } from "@/observability/http-transaction";
function isAuthLoginRequest(request: Request): boolean {
return request.url.includes(API_PATHS.authentication.login);
}
/** Ky's beforeRequest sees request.body as a stream; check options.body for FormData. */
export function shouldStripJsonContentType(body: unknown): body is FormData {
return body instanceof FormData;
}
export const api: KyInstance = ky.create({
prefix: resolveApiPrefixUrl(env.apiUrl),
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
hooks: {
beforeRequest: [
({ request, options }) => {
const token = getAccessToken();
if (token) {
request.headers.set("Authorization", token);
}
// why: the instance default is application/json. Deleting that header after
// Request construction drops the multipart boundary, so the API never sees `file`.
// Rebuild from the original FormData with no Content-Type so the runtime sets
// multipart/form-data including the boundary.
if (shouldStripJsonContentType(options.body)) {
request.headers.delete("Content-Type");
return new Request(request, { body: options.body });
}
},
],
beforeError: [({ error }) => normalizeApiRequestError(error)],
afterResponse: [
({ request, response }) => {
if (response.status !== 401 || isAuthLoginRequest(request)) {
return response;
}
expireSessionAndRedirectToLogin();
return response;
},
],
},
});
export type ApiRawMethod = "get" | "post" | "put" | "patch" | "delete";
export async function apiRequestRaw(
method: ApiRawMethod,
url: string,
options?: Options,
entryPoint?: string,
): Promise<Response> {
return traceHttpOperation(
{
method: method.toUpperCase(),
url,
entryPoint: entryPoint ?? `api.${method}`,
},
async ({ reportStatus }) => {
const response = await api[method](url, options);
reportStatus(response.status);
return response;
},
);
}
export async function apiGet<T>(url: string, options?: Options): Promise<T> {
return traceHttpOperation(
{ method: "GET", url, entryPoint: "api.apiGet" },
async ({ reportStatus }) => {
const response = await api.get(url, options);
reportStatus(response.status);
return response.json<T>();
},
);
}
export async function apiPost<T>(url: string, body?: unknown, options?: Options): Promise<T> {
return traceHttpOperation(
{ method: "POST", url, entryPoint: "api.apiPost" },
async ({ reportStatus }) => {
const response = await api.post(url, { ...options, json: body });
reportStatus(response.status);
return response.json<T>();
},
);
}
export async function apiPostForm<T>(url: string, body: FormData, options?: Options): Promise<T> {
return traceHttpOperation(
{ method: "POST", url, entryPoint: "api.apiPostForm" },
async ({ reportStatus }) => {
const response = await api.post(url, { ...options, body });
reportStatus(response.status);
return response.json<T>();
},
);
}
export async function apiPut<T>(url: string, body?: unknown, options?: Options): Promise<T> {
return traceHttpOperation(
{ method: "PUT", url, entryPoint: "api.apiPut" },
async ({ reportStatus }) => {
const response = await api.put(url, { ...options, json: body });
reportStatus(response.status);
return response.json<T>();
},
);
}
export async function apiPatch<T>(url: string, body?: unknown, options?: Options): Promise<T> {
return traceHttpOperation(
{ method: "PATCH", url, entryPoint: "api.apiPatch" },
async ({ reportStatus }) => {
const response = await api.patch(url, { ...options, json: body });
reportStatus(response.status);
return response.json<T>();
},
);
}
export async function apiDelete<T>(url: string, options?: Options): Promise<T> {
return traceHttpOperation(
{ method: "DELETE", url, entryPoint: "api.apiDelete" },
async ({ reportStatus }) => {
const response = await api.delete(url, options);
reportStatus(response.status);
return response.json<T>();
},
);
}
export async function apiPostNoContent(
url: string,
body?: unknown,
options?: Options,
): Promise<void> {
return traceHttpOperation(
{ method: "POST", url, entryPoint: "api.apiPostNoContent" },
async ({ reportStatus }) => {
const response = await api.post(url, { ...options, json: body });
reportStatus(response.status);
if (response.status === 204) {
return;
}
const text = await response.text();
if (!text.trim()) {
return;
}
JSON.parse(text);
},
);
}
export async function apiDeleteNoContent(url: string, options?: Options): Promise<void> {
return traceHttpOperation(
{ method: "DELETE", url, entryPoint: "api.apiDeleteNoContent" },
async ({ reportStatus }) => {
const response = await api.delete(url, options);
reportStatus(response.status);
if (response.status === 204) {
return;
}
const text = await response.text();
if (!text.trim()) {
return;
}
JSON.parse(text);
},
);
}
export { mapHttpStatusToMessage };