diff --git a/api-expressjs-vm/index-logging.js b/api-expressjs-vm/index-logging.js
index c7cf0e77..ad54b3b2 100644
--- a/api-expressjs-vm/index-logging.js
+++ b/api-expressjs-vm/index-logging.js
@@ -1,6 +1,7 @@
const express = require('express')
const app = express()
const os = require('os');
+const crypto = require('crypto');
// console.log(JSON.stringify(process.env));
@@ -26,17 +27,36 @@ AppInsights.setup(process.env.APPINSIGHTS_INSTRUMENTATIONKEY)
const AppInsightsClient = AppInsights.defaultClient;
+// Correlation ID + request duration middleware
+app.use((req, res, next) => {
+ const id = req.headers['x-correlation-id'];
+ req.correlationId = Array.isArray(id) ? id[0] : id || crypto.randomUUID();
+ req.startTime = Date.now();
+ res.setHeader('x-correlation-id', req.correlationId);
+ res.on('finish', () => {
+ const durationMs = Date.now() - req.startTime;
+ console.log(JSON.stringify({
+ method: req.method,
+ path: req.originalUrl,
+ statusCode: res.statusCode,
+ durationMs,
+ correlationId: req.correlationId
+ }));
+ });
+ next();
+});
+
app.get('/trace', (req, res) => {
const clientIP = req.headers['x-forwarded-for'];
const msg = `trace route ${os.hostname()} ${clientIP} ${new Date()}`;
- console.log(msg)
+ console.log(`[${req.correlationId}] ${msg}`)
if (process.env.APPINSIGHTS_INSTRUMENTATIONKEY) {
AppInsightsClient.trackPageView();
- AppInsightsClient.trackTrace({ message: msg })
+ AppInsightsClient.trackTrace({ message: `[${req.correlationId}] ${msg}` })
AppInsightsClient.flush();
} else {
msg += ' AppInsights not configured';
@@ -50,7 +70,7 @@ app.get('/', function (req, res) {
const clientIP = req.headers['x-forwarded-for'];
const msg = `root route ${os.hostname()} ${clientIP} ${new Date()}`
- console.log(msg)
+ console.log(`[${req.correlationId}] ${msg}`)
res.send(msg)
diff --git a/api-expressjs-vm/index.js b/api-expressjs-vm/index.js
index 064a7159..18afbd54 100644
--- a/api-expressjs-vm/index.js
+++ b/api-expressjs-vm/index.js
@@ -1,13 +1,33 @@
const os = require('os');
-const express = require('express')
-const app = express()
+const crypto = require('crypto');
+const express = require('express');
+const app = express();
+
+// Correlation ID + request duration middleware
+app.use((req, res, next) => {
+ const id = req.headers['x-correlation-id'];
+ req.correlationId = Array.isArray(id) ? id[0] : id || crypto.randomUUID();
+ req.startTime = Date.now();
+ res.setHeader('x-correlation-id', req.correlationId);
+ res.on('finish', () => {
+ const durationMs = Date.now() - req.startTime;
+ console.log(JSON.stringify({
+ method: req.method,
+ path: req.originalUrl,
+ statusCode: res.statusCode,
+ durationMs,
+ correlationId: req.correlationId
+ }));
+ });
+ next();
+});
app.use('/public', express.static('public'))
app.get('/', function (req, res) {
const clientIP = req.headers['x-forwarded-for'];
- const msg = `HostName: ${os.hostname()}
ClientIP: ${clientIP}
DateTime: ${new Date()}
`
- console.log(msg)
+ const msg = `HostName: ${os.hostname()}
ClientIP: ${clientIP}
DateTime: ${new Date()}
`;
+ console.log(`[${req.correlationId}] ${msg}`);
res.send(msg)
})
diff --git a/api-inmemory/src/functions/todo.ts b/api-inmemory/src/functions/todo.ts
index f2d3a414..ebc5b993 100644
--- a/api-inmemory/src/functions/todo.ts
+++ b/api-inmemory/src/functions/todo.ts
@@ -1,70 +1,154 @@
-// @ts-nocheck
-
-//create a todo, view a todo, modify a todo, list all todos, delete all todos
-
import { app, HttpRequest, HttpResponseInit, InvocationContext } from "@azure/functions";
-// import { MongoClient } from 'mongodb';
+import { TodoItem, BulkCreateResponse, ValidationError } from "../models";
+import * as todoService from "../services/todoService";
-let nextId = 2;
+// ── helpers ──────────────────────────────────────────────
-type Todo = {
- title: string;
+/** Read or generate a correlation ID from the incoming request. */
+function correlationId(request: HttpRequest): string {
+ return request.headers.get("x-correlation-id") ?? crypto.randomUUID();
}
-let todos = {
- "1": "Say hello"
-};
-
+// ── GET /api/todo ────────────────────────────────────────
-app.get('getAll', {
+app.get("getAll", {
route: "todo",
- authLevel: 'anonymous',
- handler: async function getAll(request: HttpRequest, context: InvocationContext): Promise {
+ authLevel: "anonymous",
+ handler: async function getAll(
+ request: HttpRequest,
+ context: InvocationContext
+ ): Promise {
+ return { jsonBody: todoService.getAll() };
+ },
+});
+
+// ── POST /api/todo (single create) ─────────────────────
+
+app.post("addOne", {
+ route: "todo",
+ authLevel: "anonymous",
+ handler: async function addOne(
+ request: HttpRequest,
+ context: InvocationContext
+ ): Promise {
+ const body = (await request.json()) as Record;
+ context.log("addOne body", body);
+
+ if (body && typeof body.title === "string" && body.title.trim().length > 0) {
+ const id = todoService.addOne(body.title.trim());
+ return { jsonBody: { id, title: body.title.trim() } };
+ }
- return { jsonBody: todos }
- }
-})
+ return {
+ status: 400,
+ jsonBody: { error: "Request body must include a non-empty 'title' string." },
+ };
+ },
+});
+
+// ── POST /api/todo/bulk (bulk create) ──────────────────
+
+app.post("bulkCreate", {
+ route: "todo/bulk",
+ authLevel: "anonymous",
+ handler: async function bulkCreate(
+ request: HttpRequest,
+ context: InvocationContext
+ ): Promise {
+ const cid = correlationId(request);
+
+ // ── Parse body ────────────────────────────────────
+ let body: unknown;
+ try {
+ body = await request.json();
+ } catch {
+ return {
+ status: 400,
+ headers: { "x-correlation-id": cid },
+ jsonBody: { correlationId: cid, error: "Request body must be valid JSON." },
+ };
+ }
-// curl --location 'http://localhost:7071/api/users?user=mike' \
-// --header 'Content-Type: application/json' \
-// --data '{
-// "name": "dina",
-// "age": "21"
-// }'
+ // ── Validate top-level shape ──────────────────────
+ if (!body || !Array.isArray((body as any).items)) {
+ return {
+ status: 400,
+ headers: { "x-correlation-id": cid },
+ jsonBody: {
+ correlationId: cid,
+ error: "Request body must be an object with an 'items' array. Example: { \"items\": [{ \"title\": \"...\" }] }",
+ },
+ };
+ }
-app.post('addOne', {
- route: "todo",
- authLevel: 'anonymous',
- handler: async function addOne(request: HttpRequest, context: InvocationContext): Promise {
+ const items: unknown[] = (body as any).items;
- const body = await request.json();
- console.log(body)
+ if (items.length === 0) {
+ return {
+ status: 400,
+ headers: { "x-correlation-id": cid },
+ jsonBody: { correlationId: cid, error: "'items' array must not be empty." },
+ };
+ }
- if(body && body.title){
- todos[nextId++] = body.title;
+ // ── Per-item pre-validation ───────────────────────
+ const errors: ValidationError[] = [];
+ const validItems: TodoItem[] = [];
- console.log(todos);
-
- return {
- jsonBody: body.title
+ for (let i = 0; i < items.length; i++) {
+ const item = items[i] as Record;
+ if (!item || typeof item !== "object" || typeof item.title !== "string" || (item.title as string).trim().length === 0) {
+ errors.push({ index: i, message: "Each item must be an object with a non-empty 'title' string." });
+ } else {
+ validItems.push({ title: (item.title as string).trim() });
}
}
- return { status: 404 }
- }
-})
+ if (errors.length > 0) {
+ return {
+ status: 400,
+ headers: { "x-correlation-id": cid },
+ jsonBody: {
+ correlationId: cid,
+ error: "Validation failed for one or more items.",
+ validationErrors: errors,
+ },
+ };
+ }
+ // ── Batched insert via service ────────────────────
+ const results = todoService.bulkCreate(validItems);
+ const succeeded = results.filter((r) => r.success).length;
+ const failed = results.length - succeeded;
-app.deleteRequest('deleteAll', {
- route: "todo",
- authLevel: 'anonymous',
- handler: async function deleteAll(request: HttpRequest, context: InvocationContext): Promise {
+ context.log(`[${cid}] bulkCreate: ${succeeded} succeeded, ${failed} failed out of ${results.length}`);
- todos = {};
- nextId=1;
+ const response: BulkCreateResponse = {
+ correlationId: cid,
+ total: results.length,
+ succeeded,
+ failed,
+ results,
+ };
return {
- jsonBody: todos
- }
- }
-})
+ status: failed > 0 ? 207 : 201,
+ headers: { "x-correlation-id": cid },
+ jsonBody: response,
+ };
+ },
+});
+
+// ── DELETE /api/todo ─────────────────────────────────────
+
+app.deleteRequest("deleteAll", {
+ route: "todo",
+ authLevel: "anonymous",
+ handler: async function deleteAll(
+ request: HttpRequest,
+ context: InvocationContext
+ ): Promise {
+ todoService.deleteAll();
+ return { jsonBody: todoService.getAll() };
+ },
+});
diff --git a/api-inmemory/src/models.ts b/api-inmemory/src/models.ts
new file mode 100644
index 00000000..6546d0f9
--- /dev/null
+++ b/api-inmemory/src/models.ts
@@ -0,0 +1,24 @@
+export interface TodoItem {
+ title: string;
+}
+
+export interface BulkItemResult {
+ index: number;
+ success: boolean;
+ id?: string;
+ title?: string;
+ error?: string;
+}
+
+export interface BulkCreateResponse {
+ correlationId: string;
+ total: number;
+ succeeded: number;
+ failed: number;
+ results: BulkItemResult[];
+}
+
+export interface ValidationError {
+ index: number;
+ message: string;
+}
diff --git a/api-inmemory/src/services/todoService.ts b/api-inmemory/src/services/todoService.ts
new file mode 100644
index 00000000..cb740d9b
--- /dev/null
+++ b/api-inmemory/src/services/todoService.ts
@@ -0,0 +1,43 @@
+import { TodoItem, BulkItemResult } from "../models";
+
+// ── In-memory store ──────────────────────────────────────
+let nextId = 2;
+let todos: Record = { "1": "Say hello" };
+
+// ── Single-item operations ───────────────────────────────
+export function getAll(): Record {
+ return todos;
+}
+
+export function addOne(title: string): string {
+ const id = String(nextId++);
+ todos[id] = title;
+ return id;
+}
+
+export function deleteAll(): void {
+ todos = {};
+ nextId = 1;
+}
+
+// ── Batched bulk insert ──────────────────────────────────
+// Performs a single pass over the array — no per-item async work.
+export function bulkCreate(items: TodoItem[]): BulkItemResult[] {
+ const results: BulkItemResult[] = [];
+
+ for (let i = 0; i < items.length; i++) {
+ const item = items[i];
+ try {
+ if (!item.title || typeof item.title !== "string" || item.title.trim().length === 0) {
+ results.push({ index: i, success: false, error: "title must be a non-empty string" });
+ continue;
+ }
+ const id = addOne(item.title.trim());
+ results.push({ index: i, success: true, id, title: item.title.trim() });
+ } catch (err) {
+ results.push({ index: i, success: false, error: String(err) });
+ }
+ }
+
+ return results;
+}
diff --git a/test-api-functions-v4/tests/todo-bulk.spec.ts b/test-api-functions-v4/tests/todo-bulk.spec.ts
new file mode 100644
index 00000000..76e51c62
--- /dev/null
+++ b/test-api-functions-v4/tests/todo-bulk.spec.ts
@@ -0,0 +1,200 @@
+/* eslint-disable notice/notice */
+
+/**
+ * Integration tests for POST /api/todo/bulk
+ *
+ * Requires the api-inmemory Functions host running locally:
+ * cd api-inmemory && npm start
+ *
+ * Then from this project:
+ * npx playwright test tests/todo-bulk.spec.ts
+ */
+
+import { test, expect } from "@playwright/test";
+
+test.use({
+ baseURL: "http://localhost:7071",
+});
+
+// Reset the in-memory store before each test so results are deterministic.
+test.beforeEach(async ({ request }) => {
+ await request.delete("/api/todo");
+});
+
+// ── 1. Happy path: 3 valid items ─────────────────────────
+
+test("bulk create with 3 valid items returns 201 with per-item results and correlationId", async ({
+ request,
+}) => {
+ const correlationId = "test-corr-happy-path";
+
+ const response = await request.post("/api/todo/bulk", {
+ headers: { "x-correlation-id": correlationId },
+ data: {
+ items: [
+ { title: "Buy milk" },
+ { title: "Read docs" },
+ { title: "Ship feature" },
+ ],
+ },
+ });
+
+ expect(response.status()).toBe(201);
+
+ // Correlation ID echoed in response header
+ expect(response.headers()["x-correlation-id"]).toBe(correlationId);
+
+ const body = await response.json();
+
+ // Correlation ID in body
+ expect(body.correlationId).toBe(correlationId);
+
+ // Counts
+ expect(body.total).toBe(3);
+ expect(body.succeeded).toBe(3);
+ expect(body.failed).toBe(0);
+
+ // Per-item results
+ expect(body.results).toHaveLength(3);
+ for (let i = 0; i < 3; i++) {
+ expect(body.results[i].index).toBe(i);
+ expect(body.results[i].success).toBe(true);
+ expect(body.results[i].id).toBeDefined();
+ expect(body.results[i].title).toBeDefined();
+ }
+
+ expect(body.results[0].title).toBe("Buy milk");
+ expect(body.results[1].title).toBe("Read docs");
+ expect(body.results[2].title).toBe("Ship feature");
+
+ // Verify they actually exist via GET /api/todo
+ const getResp = await request.get("/api/todo");
+ const todos = await getResp.json();
+ const titles = Object.values(todos);
+ expect(titles).toContain("Buy milk");
+ expect(titles).toContain("Read docs");
+ expect(titles).toContain("Ship feature");
+});
+
+test("bulk create generates correlationId when header is not provided", async ({
+ request,
+}) => {
+ const response = await request.post("/api/todo/bulk", {
+ data: { items: [{ title: "Auto-corr test" }] },
+ });
+
+ expect(response.status()).toBe(201);
+
+ // A correlation ID should still be present
+ const cid = response.headers()["x-correlation-id"];
+ expect(cid).toBeDefined();
+ expect(cid!.length).toBeGreaterThan(0);
+
+ const body = await response.json();
+ expect(body.correlationId).toBe(cid);
+});
+
+// ── 2. Validation error: body is not an array ────────────
+
+test("returns 400 when body has no items array", async ({ request }) => {
+ const response = await request.post("/api/todo/bulk", {
+ data: { notItems: "oops" },
+ });
+
+ expect(response.status()).toBe(400);
+
+ const body = await response.json();
+ expect(body.correlationId).toBeDefined();
+ expect(body.error).toContain("items");
+});
+
+test("returns 400 when body is a plain array instead of { items: [...] }", async ({
+ request,
+}) => {
+ const response = await request.post("/api/todo/bulk", {
+ data: [{ title: "wrong shape" }],
+ });
+
+ expect(response.status()).toBe(400);
+
+ const body = await response.json();
+ expect(body.error).toContain("items");
+});
+
+// ── 3. Validation error: items array is empty ────────────
+
+test("returns 400 when items array is empty", async ({ request }) => {
+ const response = await request.post("/api/todo/bulk", {
+ headers: { "x-correlation-id": "test-empty" },
+ data: { items: [] },
+ });
+
+ expect(response.status()).toBe(400);
+
+ const body = await response.json();
+ expect(body.correlationId).toBe("test-empty");
+ expect(body.error).toContain("empty");
+});
+
+// ── 4. Validation error: missing required field(s) ───────
+
+test("returns 400 with per-item errors when title is missing", async ({
+ request,
+}) => {
+ const response = await request.post("/api/todo/bulk", {
+ headers: { "x-correlation-id": "test-missing-field" },
+ data: {
+ items: [
+ { title: "Valid item" },
+ { description: "no title here" }, // missing title
+ {}, // empty object
+ ],
+ },
+ });
+
+ expect(response.status()).toBe(400);
+
+ const body = await response.json();
+ expect(body.correlationId).toBe("test-missing-field");
+ expect(body.error).toContain("Validation failed");
+ expect(body.validationErrors).toBeDefined();
+ expect(body.validationErrors).toHaveLength(2);
+
+ // Errors reference the correct indices
+ const errorIndices = body.validationErrors.map((e: { index: number }) => e.index);
+ expect(errorIndices).toContain(1);
+ expect(errorIndices).toContain(2);
+
+ // Each error has a message
+ for (const err of body.validationErrors) {
+ expect(err.message).toBeDefined();
+ expect(err.message.length).toBeGreaterThan(0);
+ }
+});
+
+test("returns 400 when title is empty string", async ({ request }) => {
+ const response = await request.post("/api/todo/bulk", {
+ data: {
+ items: [{ title: "" }],
+ },
+ });
+
+ expect(response.status()).toBe(400);
+
+ const body = await response.json();
+ expect(body.validationErrors).toHaveLength(1);
+ expect(body.validationErrors[0].index).toBe(0);
+});
+
+test("returns 400 when title is non-string type", async ({ request }) => {
+ const response = await request.post("/api/todo/bulk", {
+ data: {
+ items: [{ title: 12345 }],
+ },
+ });
+
+ expect(response.status()).toBe(400);
+
+ const body = await response.json();
+ expect(body.validationErrors).toHaveLength(1);
+});