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
71 changes: 71 additions & 0 deletions kits/storage-resize-images/src/event-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import type { StorageEvent } from "firebase-functions/v2/storage";

/**
* The 1st gen `EventContext` shape that the extension published inside its
* `onCompletion` payload.
*/
export interface EventContext {
eventId: string;
timestamp: string;
eventType: string;
resource: {
service: string;
name: string;
type?: string;
};
params: Record<string, string>;
}

/**
* The extension's only trigger was `onFinalize`, so subscribers matching on
* `context.eventType` keep matching this value.
*/
const STORAGE_FINALIZE_EVENT_TYPE = "google.storage.object.finalize";

const STORAGE_SERVICE = "storage.googleapis.com";

/**
* The extension's timestamps had millisecond precision with trailing zeros
* trimmed (`...36.06Z`), while 2nd gen event times carry microseconds.
*/
function toLegacyTimestamp(time: string | undefined): string {
return new Date(time ?? Date.now()).toISOString().replace(/\.?0+Z$/, "Z");
}

/**
* Rebuilds the 1st gen `EventContext` from a 2nd gen `StorageEvent`, so the
* `onCompletion` payload keeps the shape the extension published and
* `resource.name` still names the uploaded object.
*
* `params` is always empty: the extension registered
* `functions.storage.object()`, whose trigger path has no wildcards.
*/
export function toEventContext(event: StorageEvent): EventContext {
return {
eventId: event.id,
timestamp: toLegacyTimestamp(event.time),
eventType: STORAGE_FINALIZE_EVENT_TYPE,
resource: {
service: STORAGE_SERVICE,
name: `projects/_/buckets/${event.data.bucket}/objects/${event.data.name}`,
type: event.data.kind,
},
params: {},
};
}
Comment thread
IzaakGough marked this conversation as resolved.
Resolved
10 changes: 2 additions & 8 deletions kits/storage-resize-images/src/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type { File } from "@google-cloud/storage";
import type * as admin from "firebase-admin";
import type { StorageEvent } from "firebase-functions/v2/storage";
import { checkImageContent } from "./content-filter";
import { toEventContext } from "./event-context";
import * as events from "./events";
import { DELETE_IMAGE, type ResolvedResizeImagesConfig } from "./export-config";
import {
Expand Down Expand Up @@ -48,14 +49,7 @@ export async function handleObjectFinalized(
event.data as unknown as StorageObjectMetadata,
ctx
);
await events.recordCompletionEvent({
context: {
eventId: event.id,
eventType: event.type,
resource: event.source,
timestamp: event.time,
},
});
await events.recordCompletionEvent({ context: toEventContext(event) });
}

export async function generateResizedImageHandler(
Expand Down
127 changes: 127 additions & 0 deletions kits/storage-resize-images/tests/event-context.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/**
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { describe, expect, test } from "vitest";
import type { StorageEvent } from "firebase-functions/v2/storage";
import { toEventContext } from "../src/event-context";

const BUCKET = "demo-bucket";
const OBJECT_NAME = "images/nested/photo.jpg";

const event = {
id: "event-1",
type: "google.cloud.storage.object.v1.finalized",
source: `//storage.googleapis.com/projects/_/buckets/${BUCKET}`,
subject: `objects/${OBJECT_NAME}`,
time: "2026-01-01T00:00:36.898303Z",
specversion: "1.0",
bucket: BUCKET,
data: {
kind: "storage#object",
bucket: BUCKET,
name: OBJECT_NAME,
contentType: "image/jpeg",
},
} as unknown as StorageEvent;

describe("toEventContext", () => {
test("rebuilds the 1st gen event context from a 2nd gen event", () => {
expect(toEventContext(event)).toEqual({
eventId: "event-1",
timestamp: "2026-01-01T00:00:36.898Z",
eventType: "google.storage.object.finalize",
resource: {
service: "storage.googleapis.com",
name: `projects/_/buckets/${BUCKET}/objects/${OBJECT_NAME}`,
type: "storage#object",
},
params: {},
});
});

test.each([
["2026-01-01T00:00:36.898303Z", "2026-01-01T00:00:36.898Z"],
["2026-01-01T00:00:36.060999Z", "2026-01-01T00:00:36.06Z"],
["2026-01-01T00:00:36.000400Z", "2026-01-01T00:00:36Z"],
])(
"formats event time %s as the extension's timestamp %s",
(time, timestamp) => {
expect(toEventContext({ ...event, time }).timestamp).toBe(timestamp);
}
);
});

/**
* One upload of `in/img-7.jpg` captured from both sides: the 2nd gen finalize
* event the kit receives, and the `context` storage-resize-images 0.3.6
* published. The extension took `eventId` and `timestamp` from a separate
* delivery of the notification, so only their format can match.
*/
const CAPTURED_STORAGE_EVENT = {
id: "22480302742407839",
type: "google.cloud.storage.object.v1.finalized",
source: "//storage.googleapis.com/projects/_/buckets/izaak-testing-pairc-ext",
subject: "objects/in/img-7.jpg",
time: "2026-10-26T15:20:21.630831Z",
specversion: "1.0",
bucket: "izaak-testing-pairc-ext",
data: {
kind: "storage#object",
bucket: "izaak-testing-pairc-ext",
name: "in/img-7.jpg",
contentType: "image/jpeg",
},
} as unknown as StorageEvent;

const CAPTURED_EXTENSION_CONTEXT = {
eventId: "22480386067619997",
eventType: "google.storage.object.finalize",
resource: {
name: "projects/_/buckets/izaak-testing-pairc-ext/objects/in/img-7.jpg",
service: "storage.googleapis.com",
type: "storage#object",
},
timestamp: "2026-10-26T15:20:21.676Z",
params: {},
};

const LEGACY_TIMESTAMP = /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d{0,2}[1-9])?Z$/;
const NUMERIC_EVENT_ID = /^\d+$/;

describe("the context the extension actually published", () => {
test("matches the extension's eventType, resource and params exactly", () => {
const { eventType, resource, params } = toEventContext(
CAPTURED_STORAGE_EVENT
);

expect({ eventType, resource, params }).toEqual({
eventType: CAPTURED_EXTENSION_CONTEXT.eventType,
resource: CAPTURED_EXTENSION_CONTEXT.resource,
params: CAPTURED_EXTENSION_CONTEXT.params,
});
});

test("matches the format of the extension's eventId and timestamp", () => {
const { eventId, timestamp } = toEventContext(CAPTURED_STORAGE_EVENT);

expect(eventId).toBe("22480302742407839");
expect(timestamp).toBe("2026-10-26T15:20:21.63Z");
expect(CAPTURED_EXTENSION_CONTEXT.eventId).toMatch(NUMERIC_EVENT_ID);
expect(eventId).toMatch(NUMERIC_EVENT_ID);
expect(CAPTURED_EXTENSION_CONTEXT.timestamp).toMatch(LEGACY_TIMESTAMP);
expect(timestamp).toMatch(LEGACY_TIMESTAMP);
});
});
15 changes: 10 additions & 5 deletions kits/storage-resize-images/tests/handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -506,19 +506,24 @@ describe("handleObjectFinalized", () => {
type: "google.cloud.storage.object.v1.finalized",
source: "//storage.googleapis.com/projects/_/buckets/demo-bucket",
time: "2026-01-01T00:00:00.000Z",
data: mockObject,
data: { ...mockObject, kind: "storage#object" },
} as unknown as Parameters<typeof handleObjectFinalized>[0];

test("brackets the resize with start and completion events", async () => {
await handleObjectFinalized(storageEvent, makeCtx());

expect(events.recordStartEvent).toHaveBeenCalledWith(mockObject);
expect(events.recordStartEvent).toHaveBeenCalledWith(storageEvent.data);
expect(events.recordCompletionEvent).toHaveBeenCalledWith({
context: {
eventId: "event-1",
eventType: "google.cloud.storage.object.v1.finalized",
resource: "//storage.googleapis.com/projects/_/buckets/demo-bucket",
timestamp: "2026-01-01T00:00:00.000Z",
timestamp: "2026-01-01T00:00:00Z",
eventType: "google.storage.object.finalize",
resource: {
service: "storage.googleapis.com",
name: "projects/_/buckets/demo-bucket/objects/images/test.jpg",
type: "storage#object",
},
params: {},
},
});
});
Expand Down
Loading