Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/cloudflare/internal/images-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,9 @@ interface ServiceEntrypointStub {
options?: ImageUploadOptions
): Promise<ImageMetadata>;
list(options?: ImageListOptions): Promise<ImageList>;
createDirectUpload(
options?: ImageDirectUploadOptions
): Promise<ImageDirectUploadResult>;
}

class HostedImagesBindingImpl implements HostedImagesBinding {
Expand All @@ -274,6 +277,12 @@ class HostedImagesBindingImpl implements HostedImagesBinding {
async list(options?: ImageListOptions): Promise<ImageList> {
return this.#fetcher.list(options);
}

async createDirectUpload(
options?: ImageDirectUploadOptions
): Promise<ImageDirectUploadResult> {
return this.#fetcher.createDirectUpload(options);
}
}

class ImagesBindingImpl implements ImagesBinding {
Expand Down
58 changes: 58 additions & 0 deletions src/cloudflare/internal/images.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,11 +140,50 @@ interface ImageUpdateOptions {
creator?: string;
}

type ImageMetadataFilterOperators = {
eq?: string | number | boolean;
in?: string[] | number[];
gt?: number;
gte?: number;
lt?: number;
lte?: number;
};

type ImageMetadataFilterValue =
| string
| number
| boolean
| ImageMetadataFilterOperators;

interface ImageListFilter {
metadata?: Record<string, ImageMetadataFilterValue>;
}

interface ImageListOptions {
limit?: number;
cursor?: string;
sortOrder?: 'asc' | 'desc';
creator?: string;
filter?: ImageListFilter;
}

interface ImageSignedUrlOptions {
variant: string;
expiresIn?: number;
keyName?: string;
}

interface ImageDirectUploadOptions {
id?: string;
requireSignedURLs?: boolean;
metadata?: Record<string, unknown>;
creator?: string;
expiresIn?: number;
}

interface ImageDirectUploadResult {
id: string;
uploadURL: string;
}

interface ImageList {
Expand All @@ -166,6 +205,14 @@ interface ImageHandle {
*/
bytes(): Promise<ReadableStream<Uint8Array> | null>;

/**
* Generate a signed delivery URL for this hosted image.
* @param options Signing configuration
* @returns A signed image delivery URL
* @throws {@link ImagesError} if signing fails
*/
signedUrl(options: ImageSignedUrlOptions): Promise<string>;

/**
* Update hosted image metadata
* @param options Properties to update
Expand Down Expand Up @@ -208,6 +255,17 @@ interface HostedImagesBinding {
* @throws {@link ImagesError} if list fails
*/
list(options?: ImageListOptions): Promise<ImageList>;

/**
* Create a Direct Creator Upload link, letting an end user upload an
* image straight to Cloudflare without exposing an API token
* @param options Upload link configuration
* @returns The new image ID and the upload URL to hand to the end user
* @throws {@link ImagesError} if creation fails
*/
createDirectUpload(
options?: ImageDirectUploadOptions
): Promise<ImageDirectUploadResult>;
}

interface ImagesBinding {
Expand Down
71 changes: 71 additions & 0 deletions src/cloudflare/internal/test/images/images-api-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,23 @@ export const test_images_getImage_not_found = {
},
};

export const test_images_signed_url = {
/**
* @param {unknown} _
* @param {Env} env
*/
async test(_, env) {
const url = await env.images.hosted
.image('test-image-id')
.signedUrl({ variant: 'private' });

assert.equal(
url,
'https://imagedelivery.example/test-image-id/private?sig=mock-signature'
);
},
};

// UPLOAD
export const test_images_upload_with_options = {
/**
Expand Down Expand Up @@ -645,6 +662,27 @@ export const test_images_list_with_options = {
},
};

export const test_images_list_metadata_filter_forwards_correctly = {
/**
* @param {unknown} _
* @param {Env} env
*/
async test(_, env) {
const result = await env.images.hosted.list({
filter: {
metadata: {
status: 'active',
priority: { gte: 1, lte: 3 },
'config.region': 'eu-west',
},
},
});

assert.equal(result.images.length, 1);
assert.equal(result.images[0].id, 'image-1');
},
};

// UPLOAD with base64 encoding
export const test_images_upload_base64_stream = {
/**
Expand Down Expand Up @@ -687,3 +725,36 @@ export const test_images_upload_base64_arraybuffer = {
assert.notEqual(metadata.id, null);
},
};

// CREATE DIRECT UPLOAD
export const test_images_create_direct_upload_default = {
/**
* @param {unknown} _
* @param {Env} env
*/
async test(_, env) {
const result = await env.images.hosted.createDirectUpload();

assert.notEqual(result.id, null);
assert.equal(result.uploadURL.includes(result.id), true);
},
};

export const test_images_create_direct_upload_with_options = {
/**
* @param {unknown} _
* @param {Env} env
*/
async test(_, env) {
const result = await env.images.hosted.createDirectUpload({
id: 'custom-upload-id',
requireSignedURLs: true,
metadata: { userId: 'abc123' },
creator: 'direct-upload-creator',
expiresIn: 600,
});

assert.equal(result.id, 'custom-upload-id');
assert.equal(result.uploadURL.includes('custom-upload-id'), true);
},
};
81 changes: 78 additions & 3 deletions src/cloudflare/internal/test/images/images-upstream-mock.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,57 @@ async function imageAsString(blob) {
return blob.text();
}

function resolveMetaPath(obj, path) {
return path
.split('.')
.reduce(
(acc, key) => (acc && typeof acc === 'object' ? acc[key] : undefined),
obj
);
}

function matchesCondition(actual, condition) {
if (
condition === null ||
typeof condition !== 'object' ||
Array.isArray(condition)
) {
return actual === condition;
}

return Object.entries(condition).every(([op, expected]) => {
switch (op) {
case 'eq':
return actual === expected;
case 'in':
return (
Array.isArray(expected) &&
expected.some((candidate) => candidate === actual)
);
case 'gt':
return typeof actual === 'number' && actual > expected;
case 'gte':
return typeof actual === 'number' && actual >= expected;
case 'lt':
return typeof actual === 'number' && actual < expected;
case 'lte':
return typeof actual === 'number' && actual <= expected;
default:
return false;
}
});
}

function matchesMetadataFilters(image, filters) {
if (!filters) {
return true;
}

return Object.entries(filters).every(([field, condition]) =>
matchesCondition(resolveMetaPath(image.meta ?? {}, field), condition)
);
}

class ImageHandleMock extends RpcTarget {
/** @type {string} */
#imageId;
Expand Down Expand Up @@ -56,6 +107,10 @@ class ImageHandleMock extends RpcTarget {
return new Blob([mockData]).stream();
}

async signedUrl(options) {
return `https://imagedelivery.example/${this.#imageId}/${options.variant}?sig=mock-signature`;
}

/**
* @param {ImageUpdateOptions} body
* @returns {Promise<ImageMetadata>}
Expand Down Expand Up @@ -133,7 +188,7 @@ export class ServiceEntrypoint extends WorkerEntrypoint {
uploaded: '2024-01-01T00:00:00Z',
requireSignedURLs: false,
variants: ['public'],
meta: {},
meta: { status: 'active', priority: 1, config: { region: 'eu-west' } },
creator: 'test-creator',
},
{
Expand All @@ -142,20 +197,40 @@ export class ServiceEntrypoint extends WorkerEntrypoint {
uploaded: '2024-01-02T00:00:00Z',
requireSignedURLs: false,
variants: ['public'],
meta: {},
meta: {
status: 'archived',
priority: 5,
config: { region: 'us-east' },
},
creator: 'test-creator',
},
];

const filtered = images.filter((image) =>
matchesMetadataFilters(image, options?.filter?.metadata)
);

const limit = options?.limit || 50;
const slicedImages = images.slice(0, limit);
const slicedImages = filtered.slice(0, limit);

return {
images: slicedImages,
listComplete: true,
};
}

/**
* @param {ImageDirectUploadOptions} [options]
* @returns {Promise<ImageDirectUploadResult>}
*/
async createDirectUpload(options) {
const id = options?.id || 'generated-upload-id';
return {
id,
uploadURL: `https://upload.imagedelivery.example/${id}`,
};
}

/**
* Handle HTTP requests for info and transform operations.
* In production these go to a separate transformation service,
Expand Down
Loading
Loading