diff --git a/.changes/foundation-sim-logger.md b/.changes/foundation-sim-logger.md new file mode 100644 index 00000000..20a89e80 --- /dev/null +++ b/.changes/foundation-sim-logger.md @@ -0,0 +1,5 @@ +--- +"@simulacrum/foundation-simulator": minor:feat +--- + +All routes now add a log to the simulation state on every visit. This assists in tracking hits on each simulation route. diff --git a/.changes/foundation-simulator-route-list.md b/.changes/foundation-simulator-route-list.md new file mode 100644 index 00000000..8b55d4e1 --- /dev/null +++ b/.changes/foundation-simulator-route-list.md @@ -0,0 +1,5 @@ +--- +"@simulacrum/foundation-simulator": minor:feat +--- + +To improve transparency and flexibility, we now include a page at the root that lists all of the routes, and the ability to signal which response to return. diff --git a/README.md b/README.md index 1523b7dd..10afaf00 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Simulacrum removes these constraints from your process by allowing you to simula * [auth0](packages/auth0) - [@simulacrum/auth0-simulator](https://www.npmjs.com/package/@simulacrum/auth0-simulator) * [ldap](packages/ldap) - [@simulacrum/ldap-simulator](https://www.npmjs.com/package/@simulacrum/ldap-simulator) +* [github-api](packages/github-api) - [@simulacrum/github-api-simulator](https://www.npmjs.com/package/@simulacrum/github-api-simulator) ## Usage diff --git a/package-lock.json b/package-lock.json index 826cd44f..3c50a5f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4791,7 +4791,9 @@ }, "node_modules/cors": { "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", "dependencies": { "object-assign": "^4", "vary": "^1" @@ -9477,6 +9479,7 @@ "license": "MIT", "dependencies": { "ajv-formats": "^3.0.1", + "cors": "^2.8.5", "express": "^4.19.2", "fdir": "^6.2.0", "http-proxy-middleware": "^3.0.0", @@ -12024,6 +12027,7 @@ "requires": { "@types/cors": "^2.8.17", "ajv-formats": "^3.0.1", + "cors": "^2.8.5", "express": "^4.19.2", "fdir": "^6.2.0", "http-proxy-middleware": "^3.0.0", @@ -13298,6 +13302,7 @@ }, "cors": { "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", "requires": { "object-assign": "^4", diff --git a/packages/foundation/example/extensiveServer/openapi.ts b/packages/foundation/example/extensiveServer/openapi.ts index d25bd46c..7ee48661 100644 --- a/packages/foundation/example/extensiveServer/openapi.ts +++ b/packages/foundation/example/extensiveServer/openapi.ts @@ -16,6 +16,9 @@ const openapiSchemaFromRealEndpoint = { 200: { description: "All of the dogs", }, + 404: { + description: "The dogs have gone missing!", + }, }, }, }, @@ -127,11 +130,15 @@ function handlers( simulationStore: ExtendedSimulationStore ): SimulationHandlers { return { - getDogs: (_c, _r, response) => { + getDogs: (_c, request, response, _next, routeMetadata) => { let dogs = simulationStore.schema.dogs.select( simulationStore.store.getState() ); - response.status(200).json({ dogs }); + if (routeMetadata.defaultCode === 200) { + response.status(200).json({ dogs }); + } else { + response.sendStatus(routeMetadata.defaultCode); + } }, putDogs: (c, req, response) => { simulationStore.store.dispatch( diff --git a/packages/foundation/example/extensiveServer/store.ts b/packages/foundation/example/extensiveServer/store.ts index 558c22f3..e8761804 100644 --- a/packages/foundation/example/extensiveServer/store.ts +++ b/packages/foundation/example/extensiveServer/store.ts @@ -54,6 +54,7 @@ const inputSelectors = ({ }; export const extendStore = { + logs: false, actions: inputActions, selectors: inputSelectors, schema: inputSchema, diff --git a/packages/foundation/example/singleFileServer/index.ts b/packages/foundation/example/singleFileServer/index.ts index d474bd35..3618c916 100644 --- a/packages/foundation/example/singleFileServer/index.ts +++ b/packages/foundation/example/singleFileServer/index.ts @@ -93,6 +93,7 @@ export const simulation = createFoundationSimulationServer({ }, ], extendStore: { + logs: false, actions: ({ thunks, schema }) => { // TODO attempt to remove this type as a requirement let upsertTest = thunks.create( diff --git a/packages/foundation/package.json b/packages/foundation/package.json index c1cd54f5..4aca746e 100644 --- a/packages/foundation/package.json +++ b/packages/foundation/package.json @@ -39,6 +39,7 @@ }, "dependencies": { "ajv-formats": "^3.0.1", + "cors": "^2.8.5", "express": "^4.19.2", "fdir": "^6.2.0", "http-proxy-middleware": "^3.0.0", diff --git a/packages/foundation/src/index.ts b/packages/foundation/src/index.ts index 7e2fad4c..7247e05e 100644 --- a/packages/foundation/src/index.ts +++ b/packages/foundation/src/index.ts @@ -1,8 +1,11 @@ import express from "express"; +import cors from "cors"; import type { Request as ExpressRequest, Response as ExpressResponse, + NextFunction as ExpressNextFunction, } from "express"; +import type { ILayer, IRoute } from "express-serve-static-core"; import { fdir } from "fdir"; import fs from "node:fs"; import path from "node:path"; @@ -26,14 +29,18 @@ import type { import type { ExtendSimulationSchemaInput, ExtendSimulationSchema, + SimulationRoute, } from "./store/schema"; import type { RecursivePartial } from "./store/types"; import { apiProxy } from "./middleware/proxy"; +import { generateRoutesHTML } from "./routeTemplate"; type SimulationHandlerFunctions = ( context: OpenAPIBackendContext, request: ExpressRequest, - response: ExpressResponse + response: ExpressResponse, + next: ExpressNextFunction, + routeMetadata: SimulationRoute ) => void; export type SimulationHandlers = Record; export type { @@ -95,13 +102,61 @@ export function createFoundationSimulationServer< return () => { let app = express(); - if (process.env.SIM_PROXY || proxyAndSave) { + if (proxyAndSave) { app.use(apiProxy(proxyAndSave)); } + app.use(cors()); app.use(express.json()); + app.use(express.urlencoded({ extended: false })); + let simulationStore = createSimulationStore(extendStore); + app.use((req, res, next) => { + // add each response to the internal log + simulationStore.store.dispatch( + simulationStore.actions.simulationLog({ + method: req.method, + url: req.url, + query: req.query, + body: req.body, + }) + ); + next(); + }); + + if (extendRouter) { + extendRouter(app, simulationStore); + + if (app?._router?.stack) { + const layers: IRoute[] = app._router.stack + .map((stack: ILayer) => stack.route) + .filter(Boolean); + + const simulationRoutes = []; + for (let layer of layers) { + for (let stack of layer.stack) { + simulationRoutes.push( + simulationStore.schema.simulationRoutes.add({ + [`${stack.method}:${layer.path}`]: { + type: "JSON", + url: layer.path, + method: stack.method as SimulationRoute["method"], + calls: 0, + defaultCode: 200, + responses: [200], + }, + }) + ); + } + } + + simulationStore.store.dispatch( + simulationStore.actions.batchUpdater(simulationRoutes) + ); + } + } + if (serveJsonFiles) { const jsonFiles = new fdir() .filter((path, _isDirectory) => path.endsWith(".json")) @@ -111,19 +166,33 @@ export function createFoundationSimulationServer< .sync(); if (jsonFiles.length > 0) { + const simulationRoutes = []; for (let jsonFile of jsonFiles) { - const route = jsonFile.slice(0, jsonFile.length - 5); + const route = `/${jsonFile.slice(0, jsonFile.length - 5)}`; const filename = path.join(serveJsonFiles, jsonFile); - app.get(`/${route}`, (_req, res) => { + app.get(route, function staticJson(_req, res) { res.setHeader("content-type", "application/json"); fs.createReadStream(filename).pipe(res); }); + + simulationRoutes.push( + simulationStore.schema.simulationRoutes.add({ + [`get:${route}`]: { + type: "JSON", + url: route, + method: "get", + calls: 0, + defaultCode: 200, + responses: [200], + }, + }) + ); } - } - } - if (extendRouter) { - extendRouter(app, simulationStore); + simulationStore.store.dispatch( + simulationStore.actions.batchUpdater(simulationRoutes) + ); + } } if (openapi) { @@ -173,13 +242,79 @@ export function createFoundationSimulationServer< }); // initalize the backend - api.init(); - app.use((req, res, next) => - api.handleRequest(req as Request, req, res, next) - ); + api.init().then((init) => { + const router = init.router; + const operations = router.getOperations(); + const simulationRoutes = operations.reduce((routes, operation) => { + const url = `${router.apiRoot === "/" ? "" : router.apiRoot}${ + operation.path + }`; + routes[`${operation.method}:${url}`] = { + type: "OpenAPI", + url, + method: operation.method as SimulationRoute["method"], + calls: 0, + defaultCode: 200, + responses: Object.keys(operation.responses ?? {}).map((key) => + parseInt(key) + ), + }; + return routes; + }, {} as Record); + simulationStore.store.dispatch( + simulationStore.actions.batchUpdater([ + simulationStore.schema.simulationRoutes.add(simulationRoutes), + ]) + ); + return init; + }); + app.use((req, res, next) => { + const routeId = `${req.method.toLowerCase()}:${req.path}`; + const routeMetadata = + simulationStore.schema.simulationRoutes.selectById( + simulationStore.store.getState(), + { + id: routeId, + } + ); + return api.handleRequest( + req as Request, + req, + res, + next, + routeMetadata + ); + }); } } + // return simulation helper page + app.get("/", (req, res) => { + let routes = simulationStore.schema.simulationRoutes.selectTableAsList( + simulationStore.store.getState() + ); + let logs = simulationStore.schema.simulationLogs.selectTableAsList( + simulationStore.store.getState() + ); + if (routes.length === 0) { + res.sendStatus(404); + } else { + res.status(200).send(generateRoutesHTML(routes, logs)); + } + }); + app.post("/", (req, res) => { + const formValue = req.body; + const entries = {} as Record>; + for (let [key, value] of Object.entries(formValue)) { + entries[key] = { defaultCode: parseInt(value as string) }; + } + simulationStore.store.dispatch( + simulationStore.actions.batchUpdater([ + simulationStore.schema.simulationRoutes.patch(entries), + ]) + ); + res.redirect("/"); + }); // if no extendRouter routes or openapi routes handle this, return 404 app.all("*", (req, res) => res.status(404).json({ error: "not found" })); @@ -196,6 +331,7 @@ export function createFoundationSimulationServer< return { server, + simulationStore, ensureClose: async () => { await new Promise((resolve) => { server.once("close", resolve); diff --git a/packages/foundation/src/routeTemplate.ts b/packages/foundation/src/routeTemplate.ts new file mode 100644 index 00000000..efdfe6ca --- /dev/null +++ b/packages/foundation/src/routeTemplate.ts @@ -0,0 +1,98 @@ +import type { SimulationLog, SimulationRoute } from "./store/schema"; + +const responseSubmit = (routeId: string, response: number) => /* HTML */ `
+ +
`; +const routeToId = (route: SimulationRoute) => `${route.method}:${route.url}`; + +export const generateRoutesHTML = ( + routes: SimulationRoute[], + logs: SimulationLog[] +) => { + return /* HTML */ ` + + + + Simulation Server Routes + + + +
+

Simulation

+

Routes

+
+ Method + URL + Status + Metrics + Response Options + ${routes + .map( + (route) => + `${route.method.toUpperCase()}${route.url}code ${ + route.defaultCode + }${ + route.calls + } calls
${route.responses + .map((response) => + responseSubmit(routeToId(route), response) + ) + .join("")}
` + ) + .join("\n")} +
+

Logs

+
+ ${logs.map((log) => `
${log.message}
`).join("")} +
+
+ + `; +}; diff --git a/packages/foundation/src/store/index.ts b/packages/foundation/src/store/index.ts index 92c1806c..a7a09ca1 100644 --- a/packages/foundation/src/store/index.ts +++ b/packages/foundation/src/store/index.ts @@ -1,7 +1,7 @@ import { generateSchemaWithInputSlices } from "./schema"; import type { ExtendSimulationSchemaInput } from "./schema"; import type { AnyState, StoreUpdater, Callable } from "starfx"; -import { parallel, take, createStore, createSelector } from "starfx"; +import { parallel, take, select, createStore, createSelector } from "starfx"; import { updateStore, createThunks, mdw } from "starfx"; type StoreThunks = ReturnType; @@ -74,6 +74,38 @@ export function createSimulationStore< yield* next(); } ); + let simulationLog = thunks.create<{ + method: string; + url: string; + query: Record; + body: any; + }>("simulationLog", function* (ctx, next) { + const { method, url, query, body } = ctx.payload; + const timestamp = Date.now(); + + yield* schema.update( + schema.simulationLogs.add({ + [timestamp]: { + timestamp, + level: "info", + message: `${method} ${url}`, + meta: { method, url, query, body }, + }, + }) + ); + + // attempt to increment `route.calls` + const id = `${method.toLowerCase()}:${url}`; + const route = yield* select(schema.simulationRoutes.selectById, { + id, + }); + if (route.url !== "") + yield* schema.update( + schema.simulationRoutes.merge({ [id]: { calls: route.calls + 1 } }) + ); + + yield* next(); + }); let additionalTasks = [thunks.bootup]; @@ -86,6 +118,7 @@ export function createSimulationStore< let inputedActions = inputActions({ thunks, store, schema }); let actions = { + simulationLog, batchUpdater, ...inputedActions, }; diff --git a/packages/foundation/src/store/schema.ts b/packages/foundation/src/store/schema.ts index 4d148756..b8e4c887 100644 --- a/packages/foundation/src/store/schema.ts +++ b/packages/foundation/src/store/schema.ts @@ -8,6 +8,22 @@ export type ExtendSimulationSchemaInput = ({ slice, }: ExtendSimulationSchema) => T; +export interface SimulationLog { + timestamp: number; + level: "debug" | "info" | "error"; + message: string; + meta?: Record; +} + +export interface SimulationRoute { + type: "JSON" | "OpenAPI" | "Explicit"; + url: string; + method: "get" | "post" | "delete" | "patch"; + calls: number; + defaultCode: number; + responses: number[]; +} + export function generateSchemaWithInputSlices( inputSchema: ExtendSimulationSchemaInput ) { @@ -16,6 +32,17 @@ export function generateSchemaWithInputSlices( let schemaAndInitialState = createSchema({ cache: immerSlice.table({ empty: {} }), loaders: immerSlice.loaders(), + simulationLogs: immerSlice.table(), + simulationRoutes: immerSlice.table({ + empty: { + type: "Explicit", + url: "", + method: "get", + calls: 0, + defaultCode: 200, + responses: [200], + }, + }), ...slices, });