Skip to content
Merged
47 changes: 35 additions & 12 deletions docs/spec.md

Large diffs are not rendered by default.

45 changes: 40 additions & 5 deletions packages/adapter-api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,12 +326,13 @@ export class APIAdapter implements StackAdapter {
return new Uint8Array(await res.arrayBuffer());
}

/** POST /attachments always returns the created _attachment@1 record (#106) — see putAttachmentWithMetadata() below. */
private async uploadBinary(
path: string,
data: Uint8Array,
mimeType: string,
filename?: string,
): Promise<Record<string, unknown>> {
): Promise<WireRecord> {
const url = `${this.baseUrl}${path}`;
const headers: Record<string, string> = { 'Content-Type': mimeType };
if (this.token) headers['Authorization'] = `Bearer ${this.token}`;
Expand All @@ -348,7 +349,7 @@ export class APIAdapter implements StackAdapter {

if (res.status === 401) throw new APIAdapterAuthError();
if (!res.ok) throw await this.errorForResponse(res, 'POST', path);
return res.json() as Promise<Record<string, unknown>>;
return res.json() as Promise<WireRecord>;
}

// -------------------------------------------------------
Expand Down Expand Up @@ -567,9 +568,43 @@ export class APIAdapter implements StackAdapter {
// Attachments
// -------------------------------------------------------

async putAttachment(data: Uint8Array): Promise<FileId> {
const result = await this.uploadBinary('/attachments', data, 'application/octet-stream');
return result.fileId as string;
/**
* Unsupported over the wire — always throws. POST /attachments is the
* only upload endpoint, and it always creates the accompanying
* _attachment@1 record (#106): the whole point of the endpoint is that
* the record's fileId comes from bytes the server received in the same
* request, so there is no bytes-only wire mode for this method to map
* to. Implementing it anyway would silently mint a record with a default
* mimeType and no filename — a bytes-only upload that isn't. Bytes-only
* storage is a local-adapter primitive with no public Stack surface
* (spec §Attachments); Stack.putAttachment() never reaches this method
* on this adapter (it takes the putAttachmentWithMetadata() path), so
* this throw is a guard against direct adapter-level callers, not a
* reachable Stack code path.
*/
async putAttachment(_data: Uint8Array): Promise<FileId> {
throw new APIAdapterError(
'Bytes-only upload is not supported over the wire: POST /attachments always creates ' +
'an _attachment@1 record (#106). Use Stack.putAttachment(data, mimeType, filename?).',
);
}

/**
* StackAdapter's optional atomic-upload capability: store bytes and
* create the _attachment@1 record in one POST /attachments request — the
* wire counterpart of Stack.putAttachment() (#106). This adapter is the
* one implementation that can offer it, because bytes and records live
* behind the same boundary here (the server). Not an efficiency
* shortcut: the record's fileId is established from bytes the server
* received in *this* request, which is what makes the operation safe for
* a non-owner requester — see StackAdapter.putAttachmentWithMetadata.
*/
async putAttachmentWithMetadata(
data: Uint8Array,
mimeType: string,
filename?: string,
): Promise<StackRecord> {
return parseRecord(await this.uploadBinary('/attachments', data, mimeType, filename));
}

async getAttachment(fileId: FileId): Promise<Uint8Array> {
Expand Down
60 changes: 53 additions & 7 deletions packages/adapter-api/tests/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -776,19 +776,65 @@ describe('listTypes', () => {
// Attachments
// -------------------------------------------------------

// POST /attachments always creates the _attachment@1 record now (#106) —
// the response is a full WireRecord, not { fileId }.
const attachmentRecordResponse = (overrides: Partial<Record<string, unknown>> = {}) => ({
id: 'rec-attachment-1',
typeId: '_attachment@1',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
content: { fileId: 'file-xyz', mimeType: 'application/octet-stream', size: 4 },
version: 1,
...overrides,
});

describe('putAttachment', () => {
test('sends POST /attachments with binary body and Content-Type: application/octet-stream', async () => {
// Bytes-only upload has no wire mode: POST /attachments always creates
// the _attachment@1 record (#106), so implementing this method would
// silently mint a default-mimeType record while claiming "no record
// created". It must throw — without ever reaching the network — rather
// than approximately honor the contract.
test('throws APIAdapterError and never issues a request', async () => {
const adapter = await openAdapter();
mockFetch.mockResolvedValueOnce(jsonResponse({ fileId: 'file-xyz' }));
mockFetch.mockClear();
const data = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
await expect(adapter.putAttachment(data)).rejects.toThrow(APIAdapterError);
await expect(adapter.putAttachment(data)).rejects.toThrow(/not supported over the wire/);
expect(mockFetch).not.toHaveBeenCalled();
});
});

describe('putAttachmentWithMetadata', () => {
test('sends POST /attachments with the given Content-Type and Content-Disposition, returns the parsed record', async () => {
const adapter = await openAdapter();
mockFetch.mockResolvedValueOnce(
jsonResponse(
attachmentRecordResponse({
content: { fileId: 'file-xyz', mimeType: 'image/png', size: 4, filename: 'photo.png' },
}),
),
);
const data = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
const fileId = await adapter.putAttachment(data);
expect(fileId).toBe('file-xyz');
const record = await adapter.putAttachmentWithMetadata(data, 'image/png', 'photo.png');

expect(record.id).toBe('rec-attachment-1');
expect(record.content).toMatchObject({ fileId: 'file-xyz', mimeType: 'image/png' });

const [url, init] = mockFetch.mock.lastCall as [string, RequestInit];
expect(url).toBe(`${BASE_URL}/attachments`);
expect(init.method).toBe('POST');
expect((init.headers as Record<string, string>)['Content-Type']).toBe(
'application/octet-stream',
);
const headers = init.headers as Record<string, string>;
expect(headers['Content-Type']).toBe('image/png');
expect(headers['Content-Disposition']).toBe("attachment; filename*=UTF-8''photo.png");
});

test('omits Content-Disposition when no filename is given', async () => {
const adapter = await openAdapter();
mockFetch.mockResolvedValueOnce(jsonResponse(attachmentRecordResponse()));
await adapter.putAttachmentWithMetadata(new Uint8Array([1]), 'application/octet-stream');

const [, init] = mockFetch.mock.lastCall as [string, RequestInit];
expect((init.headers as Record<string, string>)['Content-Disposition']).toBeUndefined();
});
});

Expand Down
48 changes: 48 additions & 0 deletions packages/adapter-api/tests/conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
restoreVersionFixtures,
commitMigrationFixtures,
errorResponseFixtures,
attachmentUploadFixtures,
} from '@haverstack/conformance-fixtures';
import type { Association } from '@haverstack/core';
import {
Expand Down Expand Up @@ -284,3 +285,50 @@ describe('error response fixtures', () => {
});
}
});

// -------------------------------------------------------
// Attachment upload fixtures (#106) — POST /attachments carries Content-Type
// and creates the record. Only fixtures with a Content-Type header are
// dispatched here: putAttachmentWithMetadata()'s mimeType is required, so
// there is no way to drive the "header omitted" fixture through it — that
// one documents the raw wire contract for non-SDK callers only, same as
// attachmentDownloadFixtures aren't all exercised via APIAdapter either.
// -------------------------------------------------------

describe('attachment upload fixtures', () => {
for (const fixture of attachmentUploadFixtures) {
const contentType = fixture.requestHeaders['Content-Type'];
if (!contentType) continue;

test(fixture.name, async () => {
const adapter = await openAdapter();
mockFetch.mockResolvedValueOnce(jsonResponse(fixture.responseBody, fixture.responseStatus));

const disposition = fixture.requestHeaders['Content-Disposition'];
const filenameMatch = disposition?.match(/filename\*=UTF-8''(.+)$/);
const filename = filenameMatch ? decodeURIComponent(filenameMatch[1]) : undefined;
const data = new Uint8Array(fixture.requestBodyBytes);

const dispatch = () => adapter.putAttachmentWithMetadata(data, contentType, filename);

if (fixture.responseStatus >= 400) {
const code = (
fixture.responseBody as { error: { code: keyof typeof ERROR_CLASS_FOR_CODE } }
).error.code;
await expect(dispatch()).rejects.toThrow(ERROR_CLASS_FOR_CODE[code]);
} else {
const record = await dispatch();
const expectedFileId = (fixture.responseBody as unknown as { content: { fileId: string } })
.content.fileId;
expect(record.content.fileId).toBe(expectedFileId);
}

const [url, init] = mockFetch.mock.lastCall as [string, RequestInit];
expect(url).toBe(`${BASE_URL}/attachments`);
expect(init.method).toBe('POST');
const headers = init.headers as Record<string, string>;
expect(headers['Content-Type']).toBe(contentType);
if (disposition) expect(headers['Content-Disposition']).toBe(disposition);
});
}
});
Loading
Loading