From 98dd4f37cc2de34fcd12f395575b1637e6b5f7bd Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 27 May 2025 16:46:50 +0200 Subject: [PATCH] add update endpoint --- config/routes.oas.json | 167 +++++++++++++++++++- modules/handlers.ts | 335 +++++++++++++++++++++++++++++------------ modules/storage.ts | 2 + modules/types.ts | 1 + 4 files changed, 405 insertions(+), 100 deletions(-) diff --git a/config/routes.oas.json b/config/routes.oas.json index af59b2d..4919b88 100644 --- a/config/routes.oas.json +++ b/config/routes.oas.json @@ -14,10 +14,28 @@ "201": { "content": { "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the mock bin" + }, + "url": { + "type": "string", + "description": "URL to invoke the mock" + }, + "secret": { + "type": "string", + "description": "Secret ID required for updating this mock" + } + } + }, "examples": [ { - "binId": "n6zbnzSHBVTdmd05CLrNc", - "url": "https://api.mockbin.io/n6zbnzSHBVTdmd05CLrNc" + "id": "n6zbnzSHBVTdmd05CLrNc", + "url": "https://api.mockbin.io/n6zbnzSHBVTdmd05CLrNc", + "secret": "a1b2c3d4e5f6g7h8i9j0" } ] } @@ -82,7 +100,55 @@ }, "post": { "summary": "Create OpenAPI bin", - "description": "Lorem ipsum dolor sit amet, **consectetur adipiscing** elit, sed do `eiusmod tempor` incididunt ut labore et dolore magna aliqua.", + "description": "Creates a new mock bin from an OpenAPI specification file", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "file": { + "type": "string", + "format": "binary", + "description": "OpenAPI specification file (JSON or YAML)" + } + } + } + } + } + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the mock bin" + }, + "url": { + "type": "string", + "description": "URL to invoke the mock" + }, + "secret": { + "type": "string", + "description": "Secret ID required for updating this mock" + } + } + }, + "examples": [ + { + "id": "n6zbnzSHBVTdmd05CLrNc_oas", + "url": "https://api.mockbin.io/n6zbnzSHBVTdmd05CLrNc_oas", + "secret": "a1b2c3d4e5f6g7h8i9j0" + } + ] + } + } + } + }, "x-zuplo-route": { "corsPolicy": "anything-goes", "handler": { @@ -122,6 +188,101 @@ } }, "operationId": "2ae802d2-c8e1-40ba-ac2e-f45a9b4eafab" + }, + "put": { + "summary": "Update bin", + "description": "Updates an existing mock bin using the secret ID for authentication", + "parameters": [ + { + "name": "x-mockbin-secret", + "in": "header", + "required": true, + "schema": { + "type": "string" + }, + "description": "Secret ID required for updating the mock" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "response": { + "type": "object", + "additionalProperties": false, + "required": [ + "status" + ], + "properties": { + "status": { + "type": "number" + }, + "statusText": { + "type": "string" + }, + "headers": { + "type": "object" + }, + "body": { + "type": "string" + } + } + } + } + } + }, + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "file": { + "type": "string", + "format": "binary", + "description": "OpenAPI specification file (JSON or YAML)" + } + } + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "examples": [ + { + "id": "n6zbnzSHBVTdmd05CLrNc", + "url": "https://api.mockbin.io/n6zbnzSHBVTdmd05CLrNc" + } + ] + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing secret" + }, + "404": { + "description": "Mock not found" + } + }, + "x-zuplo-route": { + "corsPolicy": "anything-goes", + "handler": { + "export": "updateMockResponse", + "module": "$import(./modules/handlers)", + "options": {} + }, + "policies": { + "inbound": [ + "block-bad-bins", + "rate-limit-inbound" + ] + } + }, + "operationId": "update-bin-response" } }, "/v1/bins/{binId}/requests": { diff --git a/modules/handlers.ts b/modules/handlers.ts index 7047644..27c7121 100644 --- a/modules/handlers.ts +++ b/modules/handlers.ts @@ -15,87 +15,48 @@ import { default as yaml } from "./third-party/yaml/index"; const MAX_SIZE = 1_048_576; -export async function createMockResponse(request, context) { - const url = new URL(request.url); - let binId = crypto.randomUUID().replaceAll("-", ""); - const storage = storageClient(context.log); - const contentType = request.headers.get("content-type") ?? ""; - let isOpenApi = false; - - try { - let responseData; - - if (contentType.includes("application/json")) { - // Handle standard mock - responseData = await handleStandardMock( - request, - context, - binId, - storage, - url, - ); - } else if (contentType.startsWith("multipart/form-data")) { - isOpenApi = true; - // Handle OpenAPI mock - binId += "_oas"; - responseData = await handleOpenApiMock( - request, - context, - binId, - storage, - url, - ); - } else { - return HttpProblems.badRequest(request, context, { - detail: `Invalid content-type '${contentType}'`, - }); - } - - context.log.debug({ message: "bin_created", binId }); - context.waitUntil( - logAnalytics(isOpenApi ? "openapi_bin_created" : "bin_created", { - binId, - }), - ); +// Common types for operation context +interface MockOperationContext { + binId: string; + secretId: string; + storage: any; + url: URL; + isUpdate: boolean; +} - return new Response(JSON.stringify(responseData, null, 2), { - status: 201, - statusText: "Created", - }); - } catch (err) { - context.log.error(err); - return HttpProblems.internalServerError(request, context, { - detail: err.message, - }); - } +interface ParsedOpenApiContent { + parsedContent: any; + isYaml: boolean; + body: string; } -async function handleStandardMock(request, context, binId, storage, url) { - const body = await request.text(); - const size = new TextEncoder().encode(body).length; +// Common helper functions +function validateContentSize(content: string): void { + const size = new TextEncoder().encode(content).length; if (size > MAX_SIZE) { throw new Error(`Mock size cannot be larger than ${MAX_SIZE} bytes`); } +} - await storage.uploadObject(`${binId}.json`, body); - context.log.info({ binId }); - - const mockUrl = getInvokeBinUrl(url, binId); +function createMockMetadata(secretId: string, isUpdate: boolean, existingMetadata?: any) { + const now = new Date().toISOString(); + return { + secretId, + createdAt: isUpdate ? (existingMetadata?.createdAt || now) : now, + ...(isUpdate && { updatedAt: now }), + }; +} +function createMockResponseData(context: MockOperationContext, includeSecret = true) { + const mockUrl = getInvokeBinUrl(context.url, context.binId); return { - id: binId, + id: context.binId, url: mockUrl.href, + ...(includeSecret && { secret: context.secretId }), }; } -async function handleOpenApiMock(request, context, binId, storage, url) { - const formData = await request.formData(); - const body = await readFirstFileInFormData(formData, context); - - if (body === undefined) { - throw new Error("No file attachment found"); - } - +async function parseOpenApiContent(body: string, context: ZuploContext): Promise { let isYaml = false; let parsedContent; @@ -122,50 +83,230 @@ async function handleOpenApiMock(request, context, binId, storage, url) { throw new Error(`OpenAPI validation error: ${validationError.message}`); } - let originalYamlUrl: string | undefined; + return { parsedContent, isYaml, body }; +} + +async function handleYamlOriginal( + context: MockOperationContext, + body: string, + parsedContent: any +): Promise { + // Save the original YAML file + const yamlBinId = `${context.binId}_YAML_original`; + await context.storage.uploadObject(`${yamlBinId}.yaml`, body); + const yamlUrl = getInvokeBinUrl(context.url, yamlBinId); + + // Add x-mockbin-original-url to the parsed content + parsedContent["x-mockbin-original-url"] = yamlUrl.href; +} + +async function readFirstFileInFormData( + formData: FormData, + context: ZuploContext, +) { + for (const [name, value] of formData.entries()) { + // Check if the value is a file (Blob) and not a regular string field + if (value instanceof File) { + return await value.text(); // Read file contents as a string + } + } + return undefined; +} + +// Unified mock handlers +async function handleStandardMock( + request: any, + context: ZuploContext, + operationContext: MockOperationContext +) { + const body = await request.text(); + validateContentSize(body); + + let existingMetadata; + if (operationContext.isUpdate) { + const existingBinResult = await operationContext.storage.getObject(`${operationContext.binId}.json`); + existingMetadata = existingBinResult.metadata; + } + + const metadata = createMockMetadata(operationContext.secretId, operationContext.isUpdate, existingMetadata); + await operationContext.storage.uploadObject(`${operationContext.binId}.json`, body, metadata); + + const logData: any = { binId: operationContext.binId }; + if (operationContext.isUpdate) logData.action = "updated"; + context.log.info(logData); + + return createMockResponseData(operationContext, !operationContext.isUpdate); +} + +async function handleOpenApiMock( + request: any, + context: ZuploContext, + operationContext: MockOperationContext +) { + const formData = await request.formData(); + const body = await readFirstFileInFormData(formData, context); + + if (body === undefined) { + throw new Error("No file attachment found"); + } + + const { parsedContent, isYaml } = await parseOpenApiContent(body, context); + if (isYaml) { - // Save the original YAML file - const yamlBinId = `${binId}_YAML_original`; - await storage.uploadObject(`${yamlBinId}.yaml`, body); - const yamlUrl = getInvokeBinUrl(url, yamlBinId); - originalYamlUrl = yamlUrl.href; - - // Add x-mockbin-original-url to the parsed content - parsedContent["x-mockbin-original-url"] = originalYamlUrl; + await handleYamlOriginal(operationContext, body, parsedContent); } // Convert the parsed content to a JSON string const jsonBody = JSON.stringify(parsedContent, null, 2); + validateContentSize(jsonBody); - // Check the size of the JSON body - const size = new TextEncoder().encode(jsonBody).length; - if (size > MAX_SIZE) { - throw new Error(`Mock size cannot be larger than ${MAX_SIZE} bytes`); + let existingMetadata; + if (operationContext.isUpdate) { + const existingBinResult = await operationContext.storage.getObject(`${operationContext.binId}.json`); + existingMetadata = existingBinResult.metadata; } - // Save the JSON file - await storage.uploadObject(`${binId}.json`, jsonBody); - context.log.info({ binId }); + const metadata = createMockMetadata(operationContext.secretId, operationContext.isUpdate, existingMetadata); + await operationContext.storage.uploadObject(`${operationContext.binId}.json`, jsonBody, metadata); + + const logData: any = { binId: operationContext.binId }; + if (operationContext.isUpdate) logData.action = "updated"; + context.log.info(logData); - const mockUrl = getInvokeBinUrl(url, binId); + return createMockResponseData(operationContext, !operationContext.isUpdate); +} - return { - id: binId, - url: mockUrl.href, - }; +// Main endpoint handlers +export async function createMockResponse(request, context) { + const url = new URL(request.url); + let binId = crypto.randomUUID().replaceAll("-", ""); + const secretId = crypto.randomUUID().replaceAll("-", ""); + const storage = storageClient(context.log); + const contentType = request.headers.get("content-type") ?? ""; + let isOpenApi = false; + + try { + let responseData; + + if (contentType.startsWith("multipart/form-data")) { + isOpenApi = true; + binId += "_oas"; + } + + const operationContext: MockOperationContext = { + binId, + secretId, + storage, + url, + isUpdate: false, + }; + + if (contentType.includes("application/json")) { + responseData = await handleStandardMock(request, context, operationContext); + } else if (contentType.startsWith("multipart/form-data")) { + responseData = await handleOpenApiMock(request, context, operationContext); + } else { + return HttpProblems.badRequest(request, context, { + detail: `Invalid content-type '${contentType}'`, + }); + } + + context.log.debug({ message: "bin_created", binId }); + context.waitUntil( + logAnalytics(isOpenApi ? "openapi_bin_created" : "bin_created", { + binId, + }), + ); + + return new Response(JSON.stringify(responseData, null, 2), { + status: 201, + statusText: "Created", + }); + } catch (err) { + context.log.error(err); + return HttpProblems.internalServerError(request, context, { + detail: err.message, + }); + } } -async function readFirstFileInFormData( - formData: FormData, +export async function updateMockResponse( + request: ZuploRequest, context: ZuploContext, ) { - let fileContents; - for (const [name, value] of formData.entries()) { - // Check if the value is a file (Blob) and not a regular string field - if (value instanceof File) { - fileContents = await value.text(); // Read file contents as a string - return fileContents; // Exit loop after finding the first file + const url = new URL(request.url); + const { binId } = request.params; + + if (!validateBinId(binId)) { + return HttpProblems.badRequest(request, context, { + detail: "Invalid binId", + }); + } + + const providedSecret = request.headers.get("x-mockbin-secret"); + if (!providedSecret) { + return HttpProblems.unauthorized(request, context, { + detail: "Missing x-mockbin-secret header", + }); + } + + const storage = storageClient(context.log); + const contentType = request.headers.get("content-type") ?? ""; + let isOpenApi = false; + + try { + // First, check if the bin exists and validate the secret + let existingBinResult: GetObjectResult; + try { + existingBinResult = await storage.getObject(`${binId}.json`); + } catch (err) { + return getProblemFromStorageError(err, request, context); + } + + // Validate the secret from metadata + if (!existingBinResult.metadata?.secretId || existingBinResult.metadata.secretId !== providedSecret) { + return HttpProblems.unauthorized(request, context, { + detail: "Invalid secret", + }); + } + + const operationContext: MockOperationContext = { + binId, + secretId: providedSecret, + storage, + url, + isUpdate: true, + }; + + let responseData; + + if (contentType.includes("application/json")) { + responseData = await handleStandardMock(request, context, operationContext); + } else if (contentType.startsWith("multipart/form-data")) { + isOpenApi = true; + responseData = await handleOpenApiMock(request, context, operationContext); + } else { + return HttpProblems.badRequest(request, context, { + detail: `Invalid content-type '${contentType}'`, + }); } + + context.log.debug({ message: "bin_updated", binId }); + context.waitUntil( + logAnalytics(isOpenApi ? "openapi_bin_updated" : "bin_updated", { + binId, + }), + ); + + return new Response(JSON.stringify(responseData, null, 2), { + status: 200, + statusText: "OK", + }); + } catch (err) { + context.log.error(err); + return HttpProblems.internalServerError(request, context, { + detail: err.message, + }); } } diff --git a/modules/storage.ts b/modules/storage.ts index 670c9da..df98e73 100644 --- a/modules/storage.ts +++ b/modules/storage.ts @@ -26,6 +26,7 @@ export interface GetObjectResult { contentEncoding: string | undefined; contentDisposition: string | undefined; lastModified: Date | undefined; + metadata: Record | undefined; } export class StorageError extends Error { @@ -101,6 +102,7 @@ export class StorageClient { contentDisposition: response.ContentDisposition, contentEncoding: response.ContentDisposition, lastModified: response.LastModified, + metadata: response.Metadata, }; } diff --git a/modules/types.ts b/modules/types.ts index 6875937..acd81ca 100644 --- a/modules/types.ts +++ b/modules/types.ts @@ -1,5 +1,6 @@ export interface BinResponse { url: string; + secret: string; response?: { status: number; statusText?: string;