diff --git a/.changeset/pass-openapi-document-to-context.md b/.changeset/pass-openapi-document-to-context.md new file mode 100644 index 000000000..16efada94 --- /dev/null +++ b/.changeset/pass-openapi-document-to-context.md @@ -0,0 +1,16 @@ +--- +"counterfact": minor +--- + +Pass the OpenAPI document to the Context class constructor in `_.context.ts`. + +The `Context` constructor now receives an `openApiDocument` property alongside the existing `loadContext` and `readJson` helpers: + +```ts +// routes/_.context.ts +export class Context { + constructor({ openApiDocument, loadContext, readJson }) { + this.openApiDocument = openApiDocument; + } +} +``` diff --git a/src/app.ts b/src/app.ts index b5f479b94..dd5e5ce8d 100644 --- a/src/app.ts +++ b/src/app.ts @@ -72,6 +72,7 @@ export async function createMswHandlers( compiledPathsDirectory, registry, contextRegistry, + openApiDocument, ); await moduleLoader.load(); const routes = registry.routes; @@ -142,6 +143,7 @@ export async function counterfact(config: Config) { compiledPathsDirectory, registry, contextRegistry, + openApiDocument, ); const middleware = koaMiddleware(dispatcher, config); @@ -159,6 +161,17 @@ export async function counterfact(config: Config) { if (config.openApiPath !== "_" && (watch.routes || watch.types)) { await codeGenerator.watch(); + + codeGenerator.addEventListener("generate", () => { + void (async () => { + const newDoc = await loadOpenApiDocument(config.openApiPath); + + if (newDoc !== undefined) { + moduleLoader.setOpenApiDocument(newDoc); + dispatcher.openApiDocument = newDoc; + } + })(); + }); } let httpTerminator: HttpTerminator | undefined; diff --git a/src/server/dispatcher.ts b/src/server/dispatcher.ts index c62fd5b82..704b73a83 100644 --- a/src/server/dispatcher.ts +++ b/src/server/dispatcher.ts @@ -58,7 +58,7 @@ interface ParameterTypes { export interface OpenApiDocument { basePath?: string; - paths: { + paths?: { [key: string]: { [key in Lowercase]?: OpenApiOperation; }; diff --git a/src/server/module-loader.ts b/src/server/module-loader.ts index a7adc7c38..e164bfe41 100644 --- a/src/server/module-loader.ts +++ b/src/server/module-loader.ts @@ -8,6 +8,7 @@ import createDebug from "debug"; import { CHOKIDAR_OPTIONS } from "./constants.js"; import { ContextRegistry } from "./context-registry.js"; import { determineModuleKind } from "./determine-module-kind.js"; +import type { OpenApiDocument } from "./dispatcher.js"; import { FileDiscovery } from "./file-discovery.js"; import { type ContextModule, @@ -32,6 +33,10 @@ export class ModuleLoader extends EventTarget { private readonly contextRegistry: ContextRegistry; + private openApiDocumentRef: OpenApiDocument; + + private readonly openApiDocumentProxy: OpenApiDocument; + private readonly dependencyGraph = new ModuleDependencyGraph(); private readonly fileDiscovery: FileDiscovery; @@ -45,14 +50,49 @@ export class ModuleLoader extends EventTarget { basePath: string, registry: Registry, contextRegistry = new ContextRegistry(), + openApiDocument: OpenApiDocument = {}, ) { super(); this.basePath = basePath.replaceAll("\\", "/"); this.registry = registry; this.contextRegistry = contextRegistry; + this.openApiDocumentRef = openApiDocument; + + // eslint-disable-next-line @typescript-eslint/no-this-alias + const self = this; + + this.openApiDocumentProxy = new Proxy({} as OpenApiDocument, { + deleteProperty() { + return false; + }, + + get(_target, prop) { + return Reflect.get(self.openApiDocumentRef as object, prop); + }, + + getOwnPropertyDescriptor(_target, prop) { + return Reflect.getOwnPropertyDescriptor(self.openApiDocumentRef, prop); + }, + + has(_target, prop) { + return Reflect.has(self.openApiDocumentRef, prop); + }, + + ownKeys() { + return Reflect.ownKeys(self.openApiDocumentRef); + }, + + set() { + return false; + }, + }); this.fileDiscovery = new FileDiscovery(this.basePath); } + public setOpenApiDocument(newDoc: OpenApiDocument): void { + this.openApiDocumentRef = newDoc; + } + public async watch(): Promise { this.watcher = watch(this.basePath, CHOKIDAR_OPTIONS).on( "all", @@ -218,6 +258,7 @@ export class ModuleLoader extends EventTarget { new endpoint.Context({ loadContext, + openApiDocument: this.openApiDocumentProxy, readJson, }), ); diff --git a/test/server/module-loader.test.ts b/test/server/module-loader.test.ts index 6b85174d7..bf2c87f93 100644 --- a/test/server/module-loader.test.ts +++ b/test/server/module-loader.test.ts @@ -363,6 +363,125 @@ describe("a module loader", () => { }); }); + it("passes openApiDocument to the Context constructor", async () => { + await usingTemporaryFiles(async ($) => { + await $.add( + "_.context.js", + "export class Context { constructor({ openApiDocument }) { this.openApiDocument = openApiDocument; } }", + ); + await $.add("package.json", '{ "type": "module" }'); + + const registry: Registry = new Registry(); + const contextRegistry: ContextRegistry = new ContextRegistry(); + const openApiDocument = { paths: { "/hello": {} } }; + + const loader: ModuleLoader = new ModuleLoader( + $.path("."), + registry, + contextRegistry, + openApiDocument, + ); + + await loader.load(); + + const rootContext = contextRegistry.find("/") as any; + + expect(rootContext?.openApiDocument.paths).toEqual({ "/hello": {} }); + }); + }); + + it("defaults openApiDocument to an empty object when none is provided", async () => { + await usingTemporaryFiles(async ($) => { + await $.add( + "_.context.js", + "export class Context { constructor({ openApiDocument }) { this.openApiDocument = openApiDocument; } }", + ); + await $.add("package.json", '{ "type": "module" }'); + + const registry: Registry = new Registry(); + const contextRegistry: ContextRegistry = new ContextRegistry(); + + const loader: ModuleLoader = new ModuleLoader( + $.path("."), + registry, + contextRegistry, + ); + + await loader.load(); + + const rootContext = contextRegistry.find("/") as any; + + expect(rootContext?.openApiDocument).toBeDefined(); + expect(typeof rootContext?.openApiDocument).toBe("object"); + }); + }); + + it("setOpenApiDocument works even when no initial document was provided", async () => { + await usingTemporaryFiles(async ($) => { + await $.add( + "_.context.js", + "export class Context { constructor({ openApiDocument }) { this.openApiDocument = openApiDocument; } }", + ); + await $.add("package.json", '{ "type": "module" }'); + + const registry: Registry = new Registry(); + const contextRegistry: ContextRegistry = new ContextRegistry(); + + const loader: ModuleLoader = new ModuleLoader( + $.path("."), + registry, + contextRegistry, + ); + + await loader.load(); + + const rootContext = contextRegistry.find("/") as any; + const capturedReference = rootContext?.openApiDocument; + + loader.setOpenApiDocument({ paths: { "/added": {} } }); + + expect(rootContext?.openApiDocument).toBe(capturedReference); + expect(rootContext?.openApiDocument.paths).toEqual({ "/added": {} }); + }); + }); + + it("updates the openApiDocument reference in-place when setOpenApiDocument is called", async () => { + await usingTemporaryFiles(async ($) => { + await $.add( + "_.context.js", + "export class Context { constructor({ openApiDocument }) { this.openApiDocument = openApiDocument; } }", + ); + await $.add("package.json", '{ "type": "module" }'); + + const registry: Registry = new Registry(); + const contextRegistry: ContextRegistry = new ContextRegistry(); + const openApiDocument = { paths: { "/hello": {} } }; + + const loader: ModuleLoader = new ModuleLoader( + $.path("."), + registry, + contextRegistry, + openApiDocument, + ); + + await loader.load(); + + const rootContext = contextRegistry.find("/") as any; + + // Capture the proxy reference — it should remain stable + const capturedReference = rootContext?.openApiDocument; + + const updatedDocument = { paths: { "/goodbye": {} } }; + loader.setOpenApiDocument(updatedDocument); + + // The proxy reference is stable + expect(rootContext?.openApiDocument).toBe(capturedReference); + // But the data it reads reflects the new document + expect(rootContext?.openApiDocument.paths).toEqual({ "/goodbye": {} }); + expect(rootContext?.openApiDocument.paths["/hello"]).toBeUndefined(); + }); + }); + // can't test because I can't get Jest to refresh modules it.skip("updates the registry when a dependency is updated", async () => { await usingTemporaryFiles(async ($) => {