Build image / build-and-push (push) Successful in 6s
Adds the mobile-first UI for the seed-packet capture backend (live since #94): a capability-gated "Scan a packet" entry in the Plants catalog opens a camera/upload → editable proposal → confirm flow that creates a plant (new or matched) + a seed lot. Frontend only. Closes #102 — the last open child of epic #96. Co-authored-by: Steve Dudenhoeffer <[email protected]>
158 lines
5.7 KiB
TypeScript
158 lines
5.7 KiB
TypeScript
// Typed fetch wrapper for the pansy JSON API under /api/v1.
|
|
//
|
|
// Every non-2xx response throws an ApiError carrying the HTTP status and the
|
|
// parsed response body. That contract matters beyond this issue: the optimistic
|
|
// editor (per DESIGN.md § Sync) sends each PATCH/DELETE with a row `version` and,
|
|
// on a 409, reads the *current* row back out of `ApiError.body` to reconcile.
|
|
|
|
/** Base path for the versioned JSON API; also used to build server-nav links. */
|
|
export const API_BASE = '/api/v1'
|
|
|
|
/** Error thrown for any non-2xx response (or a network failure, status 0). */
|
|
export class ApiError extends Error {
|
|
readonly status: number
|
|
/** Parsed response body (JSON object, string, or null). On a 409 this is the current server row. */
|
|
readonly body: unknown
|
|
|
|
constructor(message: string, status: number, body: unknown) {
|
|
super(message)
|
|
this.name = 'ApiError'
|
|
this.status = status
|
|
this.body = body
|
|
}
|
|
|
|
/** A version conflict (DESIGN.md § Sync); `body` holds the current server row. */
|
|
get isConflict(): boolean {
|
|
return this.status === 409
|
|
}
|
|
|
|
/** No authenticated session; callers typically redirect to /login. */
|
|
get isUnauthorized(): boolean {
|
|
return this.status === 401
|
|
}
|
|
|
|
/** Resource is gone or masked (pansy returns 404 for no-access, not 403). */
|
|
get isNotFound(): boolean {
|
|
return this.status === 404
|
|
}
|
|
}
|
|
|
|
type ParamValue = string | number | boolean | undefined | null
|
|
export type Params = Record<string, ParamValue>
|
|
|
|
export interface RequestOptions {
|
|
method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'
|
|
/** Request body. A `FormData` goes out as multipart (a file upload — the
|
|
* seed-packet scan); anything else is serialized as JSON. */
|
|
body?: unknown
|
|
/** Query-string parameters; undefined/null/'' entries are omitted. */
|
|
params?: Params
|
|
signal?: AbortSignal
|
|
}
|
|
|
|
function buildUrl(path: string, params?: Params): string {
|
|
const url = API_BASE + path
|
|
if (!params) return url
|
|
const qs = new URLSearchParams()
|
|
for (const [k, v] of Object.entries(params)) {
|
|
if (v === undefined || v === null || v === '') continue
|
|
qs.set(k, String(v))
|
|
}
|
|
const s = qs.toString()
|
|
return s ? `${url}?${s}` : url
|
|
}
|
|
|
|
async function parseBody(res: Response): Promise<unknown> {
|
|
if (res.status === 204) return null
|
|
const text = await res.text()
|
|
if (!text) return null
|
|
const contentType = res.headers.get('content-type') ?? ''
|
|
if (contentType.includes('application/json')) {
|
|
try {
|
|
return JSON.parse(text)
|
|
} catch {
|
|
return text
|
|
}
|
|
}
|
|
return text
|
|
}
|
|
|
|
function messageFrom(body: unknown, status: number): string {
|
|
if (body && typeof body === 'object') {
|
|
const err = (body as { error?: { message?: string } }).error
|
|
if (err?.message) return err.message
|
|
const msg = (body as { message?: string }).message
|
|
if (msg) return msg
|
|
}
|
|
return `Request failed (${status})`
|
|
}
|
|
|
|
/** Perform a request against /api/v1 and return the parsed JSON body as T. */
|
|
export async function apiFetch<T>(path: string, opts: RequestOptions = {}): Promise<T> {
|
|
const { method = 'GET', body, params, signal } = opts
|
|
|
|
// FormData must go out as multipart with a browser-generated boundary, so it's
|
|
// sent as-is with NO content-type header (the browser sets it, boundary
|
|
// included). Everything else is JSON — serialized before the try so a
|
|
// JSON.stringify failure (e.g. a circular value) surfaces as itself, not as a
|
|
// misleading "cannot reach the server" error.
|
|
const isForm = typeof FormData !== 'undefined' && body instanceof FormData
|
|
const requestBody = body === undefined ? undefined : isForm ? body : JSON.stringify(body)
|
|
|
|
let res: Response
|
|
try {
|
|
res = await fetch(buildUrl(path, params), {
|
|
method,
|
|
signal,
|
|
credentials: 'same-origin', // send the HttpOnly session cookie
|
|
headers: {
|
|
accept: 'application/json',
|
|
...(body !== undefined && !isForm ? { 'content-type': 'application/json' } : {}),
|
|
},
|
|
body: requestBody as BodyInit | undefined,
|
|
})
|
|
} catch (err) {
|
|
if ((err as Error)?.name === 'AbortError') throw err
|
|
throw new ApiError('Cannot reach the pansy server.', 0, null)
|
|
}
|
|
|
|
const payload = await parseBody(res)
|
|
if (!res.ok) {
|
|
throw new ApiError(messageFrom(payload, res.status), res.status, payload)
|
|
}
|
|
return payload as T
|
|
}
|
|
|
|
/** The server error code from an ApiError body ({error:{code,message}}), if any. */
|
|
export function apiErrorCode(err: unknown): string | null {
|
|
if (err instanceof ApiError && err.body && typeof err.body === 'object') {
|
|
const e = (err.body as { error?: { code?: unknown } }).error
|
|
if (e && typeof e.code === 'string') return e.code
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* A user-facing message for a caught request error. An ApiError carries the
|
|
* server's own message (already user-friendly), so that wins; `fallback` covers
|
|
* everything else (unexpected throws).
|
|
*/
|
|
export function errorMessage(err: unknown, fallback = 'Something went wrong.'): string {
|
|
if (err instanceof ApiError) return err.message
|
|
return fallback
|
|
}
|
|
|
|
/** Convenience verbs over apiFetch. */
|
|
export const api = {
|
|
get: <T>(path: string, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
|
|
apiFetch<T>(path, { ...opts, method: 'GET' }),
|
|
post: <T>(path: string, body?: unknown, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
|
|
apiFetch<T>(path, { ...opts, method: 'POST', body }),
|
|
postForm: <T>(path: string, form: FormData, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
|
|
apiFetch<T>(path, { ...opts, method: 'POST', body: form }),
|
|
patch: <T>(path: string, body?: unknown, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
|
|
apiFetch<T>(path, { ...opts, method: 'PATCH', body }),
|
|
delete: <T>(path: string, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
|
|
apiFetch<T>(path, { ...opts, method: 'DELETE' }),
|
|
}
|