314 lines
12 KiB
JavaScript
314 lines
12 KiB
JavaScript
/**
|
|
* HTTP transport for the entity API.
|
|
*
|
|
* This is the module that replaces `store.js`. It exposes the same
|
|
* `createEntity(name)` factory with the same six methods and the same
|
|
* signatures, so `base44Client.js` swaps one import and nothing above it
|
|
* changes — not a hook, not a page, not a component.
|
|
*
|
|
* Everything here is a faithful translation of what `store.js` did locally into
|
|
* what `docs/api-contract.md` specifies over the wire. Where the two could
|
|
* differ, the local behaviour wins, because the callers were written against
|
|
* it:
|
|
*
|
|
* - `list`/`filter` return a bare array; `get`/`create`/`update` return a bare
|
|
* object; `delete` returns `{ id }`. The API's `{ data, meta }` envelope is
|
|
* unwrapped here and never seen above.
|
|
* - A failure throws an `Error` whose `message` is the server's message,
|
|
* because `store.js` threw and several callers depend on the throw
|
|
* (`useQuery`'s `isError`, and half a dozen `.catch(() => …)` fallbacks).
|
|
* - The default `sort` and `limit` on every method are the ones `store.js`
|
|
* declared, so a call site that passes neither still gets what it always
|
|
* got.
|
|
*
|
|
* The one thing that is genuinely new is the failure mode. A local store could
|
|
* not be unreachable; an API can, and "Failed to fetch" names neither the
|
|
* problem nor the fix. `request` turns that into a message that says which URL
|
|
* did not answer.
|
|
*/
|
|
|
|
/**
|
|
* Where the API lives.
|
|
*
|
|
* A same-origin path, not a host. The browser asks its own origin for
|
|
* `/api/v1/...`; in development the Vite proxy forwards that to the Go API (see
|
|
* `vite.config.js`), and in production the same path is served by the same
|
|
* origin as the app (see `nginx.conf`).
|
|
*
|
|
* This is a requirement of the session cookie rather than a preference. The
|
|
* cookie is HttpOnly with SameSite=Lax, and a Lax cookie is not sent on a
|
|
* cross-site request — so a page on `localhost:5173` fetching
|
|
* `http://127.0.0.1:8080` would authenticate once at login and then be a
|
|
* stranger on every request after it.
|
|
*
|
|
* VITE_API_BASE_URL can still point somewhere else, and `credentials` below is
|
|
* set so that it works, but the cross-origin path needs CORS credentials
|
|
* configured on the server and is not the supported arrangement.
|
|
*/
|
|
const DEFAULT_BASE_URL = '/api/v1';
|
|
|
|
export const API_BASE_URL = String(
|
|
import.meta.env?.VITE_API_BASE_URL || DEFAULT_BASE_URL
|
|
).replace(/\/+$/, '');
|
|
|
|
/**
|
|
* Shout if the API has been pointed at another origin.
|
|
*
|
|
* This exists because the failure it catches is silent and misleading. Set
|
|
* `VITE_API_BASE_URL` to `http://127.0.0.1:8080/api/v1` and the browser sends a
|
|
* preflight, the server answers it `204`, and then the real request is never
|
|
* dispatched at all — because `credentials: 'include'` obliges the browser to
|
|
* require `Access-Control-Allow-Credentials: true` on that preflight, and the
|
|
* API does not send it. The server log shows an OPTIONS and nothing else; the
|
|
* page shows a request that never completes. Nothing names the cause.
|
|
*
|
|
* Even if CORS were opened up, the session cookie is `SameSite=Lax` and would
|
|
* not be sent on a cross-site request, so login would appear to succeed once
|
|
* and then every subsequent request would arrive as a stranger.
|
|
*
|
|
* Development only: `import.meta.env.DEV` is statically replaced at build time,
|
|
* so this whole block is dropped from the production bundle.
|
|
*/
|
|
if (import.meta.env?.DEV && /^https?:\/\//i.test(API_BASE_URL)) {
|
|
const sameOrigin =
|
|
typeof window !== 'undefined' && API_BASE_URL.startsWith(window.location.origin);
|
|
if (!sameOrigin) {
|
|
console.error(
|
|
`[krow] VITE_API_BASE_URL is "${API_BASE_URL}", which is a different origin ` +
|
|
`from ${typeof window !== 'undefined' ? window.location.origin : 'this page'}. ` +
|
|
'The session cookie will not work: the login POST is blocked at the CORS ' +
|
|
'preflight, and a SameSite=Lax cookie would not be sent cross-site anyway. ' +
|
|
'Set VITE_API_BASE_URL=/api/v1 in .env and restart the dev server so requests ' +
|
|
'go through the Vite proxy.'
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Entity name → the contract's resource path (§1: kebab-case plural, mass nouns
|
|
* singular).
|
|
*
|
|
* Declared rather than derived. A rule that turns `AIInterview` into
|
|
* `ai-interviews` and `Staff` into `staff` and `UserActivity` into
|
|
* `user-activity` is three special cases wearing a trench coat, and a wrong
|
|
* guess here is a 404 at runtime instead of a mistake anyone can see.
|
|
*/
|
|
const RESOURCE_PATHS = {
|
|
JobPosting: 'job-postings',
|
|
JobApplication: 'job-applications',
|
|
AIInterview: 'ai-interviews',
|
|
Staff: 'staff',
|
|
WorkerProfile: 'worker-profiles',
|
|
Course: 'courses',
|
|
Badge: 'badges',
|
|
LearningPath: 'learning-paths',
|
|
Certification: 'certifications',
|
|
RoleCategory: 'role-categories',
|
|
UserActivity: 'user-activity',
|
|
Evidence: 'evidence',
|
|
User: 'users',
|
|
Assignment: 'assignments',
|
|
ShiftRecord: 'shift-records',
|
|
/* Authored agents and skills. These are the registry the BACKEND runs from:
|
|
an agent saved here is one Owliver can be asked to run, which is the whole
|
|
difference between this and the preferences blob these used to live in. */
|
|
AgentDefinition: 'agent-definitions',
|
|
SkillDefinition: 'skill-definitions',
|
|
};
|
|
|
|
/* ── Request ────────────────────────────────────────────────────────────── */
|
|
|
|
/**
|
|
* Builds a query string with the contract's filter encoding (§6).
|
|
*
|
|
* An array value becomes a repeated parameter — `?status=applied&status=hired`
|
|
* — which the server reads as `col = ANY(...)`, matching `store.js`'s
|
|
* `want.includes(got)`. `undefined` is omitted entirely: `store.js` compared
|
|
* `got === undefined` against real values and matched nothing, and sending the
|
|
* string "undefined" would be a filter on a value no column holds.
|
|
*/
|
|
function queryString(params) {
|
|
const search = new URLSearchParams();
|
|
for (const [key, value] of Object.entries(params)) {
|
|
if (value === undefined) continue;
|
|
if (Array.isArray(value)) {
|
|
for (const item of value) {
|
|
if (item !== undefined) search.append(key, String(item));
|
|
}
|
|
continue;
|
|
}
|
|
search.append(key, String(value));
|
|
}
|
|
const encoded = search.toString();
|
|
return encoded ? `?${encoded}` : '';
|
|
}
|
|
|
|
/**
|
|
* The error a non-2xx becomes.
|
|
*
|
|
* `message` is the server's message verbatim, because §5.1 makes it
|
|
* load-bearing: `store.js` threw `"<Entity> <id> not found"` and the API
|
|
* reproduces that string exactly. The code, HTTP status and per-field details
|
|
* ride along as properties — new information a local store never had, and
|
|
* additive, so nothing that only reads `.message` notices.
|
|
*/
|
|
function apiError(status, payload) {
|
|
const body = payload?.error;
|
|
const error = new Error(body?.message || `Request failed with status ${status}`);
|
|
error.name = 'KrowApiError';
|
|
error.status = status;
|
|
error.code = body?.code || 'internal';
|
|
error.details = body?.details || {};
|
|
return error;
|
|
}
|
|
|
|
/**
|
|
* One request, unwrapped.
|
|
*
|
|
* Returns `payload.data`, so every caller above works in bare records exactly
|
|
* as it did against the local store. `meta` is deliberately dropped: nothing
|
|
* reads it (§4.2), and surfacing it would mean changing what the six methods
|
|
* return, which is the one thing Phase 2D must not do.
|
|
*/
|
|
async function request(method, path, { query, body } = {}) {
|
|
const url = `${API_BASE_URL}${path}${query ? queryString(query) : ''}`;
|
|
|
|
let response;
|
|
try {
|
|
response = await fetch(url, {
|
|
method,
|
|
// The session cookie is HttpOnly: this code cannot read it, attach it by
|
|
// hand, or store it. `credentials` is the only lever there is, and
|
|
// without it `fetch` omits cookies on cross-origin requests entirely.
|
|
// Same-origin — the supported arrangement — would send them anyway;
|
|
// saying so explicitly means the one line that makes authentication work
|
|
// is visible rather than implied.
|
|
credentials: 'include',
|
|
headers: body === undefined
|
|
? { Accept: 'application/json' }
|
|
: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
});
|
|
} catch (cause) {
|
|
// A transport failure, not an API response: no status, no envelope. The
|
|
// browser's own message for this is "Failed to fetch", which says nothing
|
|
// about which server or why, and it is nearly always the same cause — the
|
|
// API is not running.
|
|
const error = new Error(
|
|
`Cannot reach the Krow API at ${API_BASE_URL}. Is the Go API running on ` +
|
|
`127.0.0.1:8080, and is the Vite dev server proxying /api to it? ` +
|
|
`(${method} ${path})`
|
|
);
|
|
error.name = 'KrowApiError';
|
|
error.status = 0;
|
|
error.code = 'unreachable';
|
|
error.details = {};
|
|
error.cause = cause;
|
|
throw error;
|
|
}
|
|
|
|
const text = await response.text();
|
|
let payload = null;
|
|
if (text) {
|
|
try {
|
|
payload = JSON.parse(text);
|
|
} catch {
|
|
payload = null;
|
|
}
|
|
}
|
|
|
|
if (!response.ok) throw apiError(response.status, payload);
|
|
|
|
if (payload === null) {
|
|
const error = new Error(`${method} ${path} returned no JSON body`);
|
|
error.name = 'KrowApiError';
|
|
error.status = response.status;
|
|
error.code = 'internal';
|
|
error.details = {};
|
|
throw error;
|
|
}
|
|
|
|
return payload.data;
|
|
}
|
|
|
|
/** The request helper, for the `auth` surface in `base44Client.js`. */
|
|
export { request };
|
|
|
|
/**
|
|
* True when an error is the API saying "you are not signed in".
|
|
*
|
|
* A 401 is an ordinary, expected answer here — it is what every request gets
|
|
* before the first login and after a session expires — so callers need to tell
|
|
* it apart from a real failure rather than treating both as "something broke".
|
|
*/
|
|
export function isUnauthenticated(error) {
|
|
return Boolean(error) && (error.status === 401 || error.code === 'unauthorized');
|
|
}
|
|
|
|
/* ── Entity API ─────────────────────────────────────────────────────────── */
|
|
|
|
/**
|
|
* Builds the client surface for one entity.
|
|
*
|
|
* Signature-compatible with `store.js`'s `createEntity`, defaults included. The
|
|
* defaults matter more than they look: `store.js` declared
|
|
* `list(sort = '-created_date', limit = 100)`, and several call sites rely on
|
|
* them rather than passing their own.
|
|
*/
|
|
export function createEntity(name) {
|
|
const path = RESOURCE_PATHS[name];
|
|
if (!path) throw new Error(`No API resource path is declared for entity ${name}`);
|
|
const base = `/${path}`;
|
|
|
|
return {
|
|
entityName: name,
|
|
|
|
/**
|
|
* `sort` is always sent, even when empty. `?sort=` is not the same as
|
|
* omitting it: the contract reads an explicit empty value as "no ordering",
|
|
* which is what `applySort` did with a falsy sort, while omitting it would
|
|
* apply the endpoint's default.
|
|
*/
|
|
async list(sort = '-created_date', limit = 100) {
|
|
return request('GET', base, { query: { sort, limit } });
|
|
},
|
|
|
|
async filter(query = {}, sort = '-created_date', limit = 100) {
|
|
// Spread first so a field genuinely named `sort`, `limit` or `offset`
|
|
// could never shadow the reserved parameters (§1 records that no column
|
|
// collides with them today; this keeps that true if one ever does).
|
|
return request('GET', base, { query: { ...query, sort, limit } });
|
|
},
|
|
|
|
async get(id) {
|
|
return request('GET', `${base}/${encodeURIComponent(id)}`);
|
|
},
|
|
|
|
async create(data) {
|
|
return request('POST', base, { body: data });
|
|
},
|
|
|
|
async update(id, data) {
|
|
return request('PATCH', `${base}/${encodeURIComponent(id)}`, { body: data });
|
|
},
|
|
|
|
async delete(id) {
|
|
return request('DELETE', `${base}/${encodeURIComponent(id)}`);
|
|
},
|
|
|
|
/**
|
|
* Sequential creates, exactly as `store.js` did it.
|
|
*
|
|
* Not a batch endpoint and not `Promise.all`: the contract has no bulk
|
|
* write (§12.1), and doing them one at a time keeps the failure behaviour
|
|
* identical — the first rejection stops the run and the records before it
|
|
* are already written.
|
|
*/
|
|
async bulkCreate(records = []) {
|
|
const created = [];
|
|
for (const record of records) created.push(await this.create(record));
|
|
return created;
|
|
},
|
|
};
|
|
}
|