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()}
flowers` - console.log(msg) + const msg = `HostName: ${os.hostname()}
ClientIP: ${clientIP}
DateTime: ${new Date()}
flowers`; + 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); +});