|
| 1 | +/** |
| 2 | + * The attachments service: the files attached to an asset, as part of |
| 3 | + * changing that asset like any other attribute. The metadata lives on an |
| 4 | + * Attachment asset (api/asset); the bytes live behind attachment.img |
| 5 | + * (wire/attachment). Consumers never call either directly. |
| 6 | + * |
| 7 | + * Two ways in for the bytes, chosen by size: |
| 8 | + * - inline: one api/asset create with Content as Base64. One request, |
| 9 | + * what agility-rooms does; the JSON is a third larger than the file. |
| 10 | + * - two-step, as the docs prescribe for anything not tiny: create the |
| 11 | + * Attachment with empty Content, then POST the raw bytes. A failed |
| 12 | + * upload leaves an empty Attachment, which is reported, not hidden. |
| 13 | + * |
| 14 | + * Listing never selects Content: across many attachments that would Base64 |
| 15 | + * every file into one response. Reading one file's bytes goes through |
| 16 | + * attachment.img. |
| 17 | + */ |
| 18 | +import { ApiError } from "../core/errors"; |
| 19 | +import type { Transport } from "../core/transport"; |
| 20 | +import type { Oid } from "../types/asset"; |
| 21 | +import { momentless } from "../types/oid"; |
| 22 | +import * as assetApi from "../wire/assetApi"; |
| 23 | +import { getBytes, numericId, putBytes, toBase64, toUint8Array, type Bytes } from "../wire/attachment"; |
| 24 | + |
| 25 | +/** Files up to this size go inline by default; larger ones in two steps. */ |
| 26 | +export const INLINE_LIMIT = 1_000_000; |
| 27 | + |
| 28 | +export interface AttachmentInfo { |
| 29 | + oid: Oid; |
| 30 | + /** The numeric id attachment.img addresses it by. */ |
| 31 | + id: string; |
| 32 | + name: string | null; |
| 33 | + filename: string | null; |
| 34 | + contentType: string | null; |
| 35 | + description: string | null; |
| 36 | + createDate: string | null; |
| 37 | + createdBy: string | null; |
| 38 | +} |
| 39 | + |
| 40 | +type Row = { _oid: string; Name?: string | null; Filename?: string | null; ContentType?: string | null; Description?: string | null; CreateDate?: string | null; "CreatedBy.Name"?: string | null }; |
| 41 | +const SELECT = ["Name", "Filename", "ContentType", "Description", "CreateDate", "CreatedBy.Name"]; |
| 42 | +const infoOf = (r: Row): AttachmentInfo => { |
| 43 | + const oid = momentless(r._oid); |
| 44 | + return { oid, id: numericId(oid), name: r.Name ?? null, filename: r.Filename ?? null, contentType: r.ContentType ?? null, description: r.Description ?? null, createDate: r.CreateDate ?? null, createdBy: r["CreatedBy.Name"] ?? null }; |
| 45 | +}; |
| 46 | + |
| 47 | +/** The files attached to an asset, newest first, metadata only. */ |
| 48 | +export async function list(transport: Transport, asset: Oid, options: { page?: { size: number; start: number } } = {}): Promise<AttachmentInfo[]> { |
| 49 | + const rows = await assetApi.query(transport, { from: "Attachment", select: SELECT, filter: ["Asset=$asset"], with: { $asset: momentless(asset) }, sort: ["-CreateDate"], page: options.page ?? { size: 50, start: 0 } }); |
| 50 | + return rows.map((r) => infoOf(r as unknown as Row)); |
| 51 | +} |
| 52 | + |
| 53 | +/** One attachment's metadata, or null when there is none with that id. */ |
| 54 | +export async function info(transport: Transport, id: Oid | string | number): Promise<AttachmentInfo | null> { |
| 55 | + const [row] = await assetApi.query(transport, { from: "Attachment", select: SELECT, filter: ["ID=$id"], with: { $id: `Attachment:${numericId(id)}` }, page: { size: 1, start: 0 } }); |
| 56 | + return row ? infoOf(row as unknown as Row) : null; |
| 57 | +} |
| 58 | + |
| 59 | +/** One attachment: its metadata and its bytes. */ |
| 60 | +export async function get(transport: Transport, id: Oid | string | number): Promise<AttachmentInfo & { bytes: Uint8Array }> { |
| 61 | + const meta = await info(transport, id); |
| 62 | + if (!meta) throw new ApiError(`attachment ${numericId(id)}: not found`, 404, "NOT_FOUND"); |
| 63 | + return { ...meta, bytes: await getBytes(transport, meta.id) }; |
| 64 | +} |
| 65 | + |
| 66 | +export interface AddOptions { |
| 67 | + filename: string; |
| 68 | + /** Defaults to the Blob's type, then application/octet-stream. */ |
| 69 | + contentType?: string; |
| 70 | + /** Defaults to the filename. */ |
| 71 | + name?: string; |
| 72 | + description?: string; |
| 73 | + /** Inline (one request) up to this many bytes, two-step above. Default INLINE_LIMIT. */ |
| 74 | + inlineLimit?: number; |
| 75 | +} |
| 76 | + |
| 77 | +/** An Attachment was created but its bytes could not be uploaded: it exists, empty. Remove it or upload again with `put`. */ |
| 78 | +export class AttachmentUploadError extends Error { |
| 79 | + constructor(readonly attachment: AttachmentInfo, cause: unknown) { |
| 80 | + super(`${attachment.oid} was created on its asset with no content, and uploading the file failed: ${cause instanceof Error ? cause.message : String(cause)}`, { cause }); |
| 81 | + this.name = "AttachmentUploadError"; |
| 82 | + } |
| 83 | +} |
| 84 | + |
| 85 | +/** Attach a file to an asset. Inline up to `inlineLimit` bytes, two-step above it. */ |
| 86 | +export async function add(transport: Transport, asset: Oid, data: Bytes, options: AddOptions): Promise<AttachmentInfo> { |
| 87 | + const bytes = await toUint8Array(data); |
| 88 | + const contentType = options.contentType ?? (typeof Blob !== "undefined" && data instanceof Blob && data.type ? data.type : "application/octet-stream"); |
| 89 | + const inline = bytes.length <= (options.inlineLimit ?? INLINE_LIMIT); |
| 90 | + const create: assetApi.CreateBody = { AssetType: "Attachment", Asset: momentless(asset), Name: options.name ?? options.filename, Filename: options.filename, ContentType: contentType, Content: inline ? toBase64(bytes) : "" }; |
| 91 | + if (options.description) create.Description = options.description; |
| 92 | + const oid = assetApi.outcomeOf(await assetApi.send(transport, [create], { throwOnFailure: true })).created[0]!; |
| 93 | + const created: AttachmentInfo = { oid, id: numericId(oid), name: String(create.Name), filename: options.filename, contentType, description: options.description ?? null, createDate: null, createdBy: null }; |
| 94 | + if (!inline) { |
| 95 | + try { |
| 96 | + await putBytes(transport, created.id, bytes, contentType); |
| 97 | + } catch (error) { |
| 98 | + throw new AttachmentUploadError(created, error); |
| 99 | + } |
| 100 | + } |
| 101 | + return created; |
| 102 | +} |
| 103 | + |
| 104 | +/** Replace an attachment's bytes. */ |
| 105 | +export async function put(transport: Transport, id: Oid | string | number, data: Bytes, contentType?: string): Promise<void> { |
| 106 | + await putBytes(transport, id, data, contentType); |
| 107 | +} |
| 108 | + |
| 109 | +/** Delete an attachment. */ |
| 110 | +export async function remove(transport: Transport, id: Oid | string | number): Promise<void> { |
| 111 | + await assetApi.send(transport, [{ from: `Attachment:${numericId(id)}`, execute: "Delete" }], { throwOnFailure: true }); |
| 112 | +} |
0 commit comments