From 04f77753f936994781bbb49229ed85dc78b412d2 Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Thu, 6 Aug 2026 19:58:38 +0200 Subject: [PATCH 1/2] feat(agent-bff): emit the OpenAPI document from a forest-bff openapi subcommand --- .gitignore | 3 + packages/agent-bff/README.md | 19 + packages/agent-bff/src/cli-dispatch.ts | 136 +++++ packages/agent-bff/src/cli.ts | 9 +- .../test/openapi/openapi-cli.test.ts | 508 ++++++++++++++++++ .../openapi/openapi-spec-validity.test.ts | 22 +- 6 files changed, 688 insertions(+), 9 deletions(-) create mode 100644 packages/agent-bff/src/cli-dispatch.ts create mode 100644 packages/agent-bff/test/openapi/openapi-cli.test.ts diff --git a/.gitignore b/.gitignore index 2246c5657a..02554be26a 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,9 @@ lerna-debug.log .env.executors .forestadmin-schema.json +# forest-bff openapi --output default destination +openapi.json + # yarn yarn-error.log .vscode/settings.json diff --git a/packages/agent-bff/README.md b/packages/agent-bff/README.md index 75efb8788a..a9d435eb7b 100644 --- a/packages/agent-bff/README.md +++ b/packages/agent-bff/README.md @@ -16,6 +16,25 @@ Packaged / production — run the bin: forest-bff ``` +Export the OpenAPI document without booting the server. Needs no configuration, so +it works in CI to commit the document, diff it, or generate a client: + +```bash +forest-bff openapi > openapi.json # stdout, redirected +forest-bff openapi --output # writes ./openapi.json +forest-bff openapi --output docs/api.json # writes that path +``` + +`--output` takes the next argument as the destination unless it is empty or starts with +`-`; the default `openapi.json` is written only when `--output` is the last argument, and +a leftover token is rejected like any other extra. A missing parent directory is created, +an existing file is overwritten, and a destination that cannot be written exits 1 with the +path on stderr. The path is confirmed on stderr, so stdout stays empty and pipeable. + +`forest-bff --help` and `forest-bff --version` print to stdout and exit 0, ignoring +anything that follows. An unknown command, or an argument other than `--output` after +`openapi`, exits 1 with the reason on stderr. + Local development — copy the env template, fill it in, and start with it loaded: ```bash diff --git a/packages/agent-bff/src/cli-dispatch.ts b/packages/agent-bff/src/cli-dispatch.ts new file mode 100644 index 0000000000..1e3bfe5f05 --- /dev/null +++ b/packages/agent-bff/src/cli-dispatch.ts @@ -0,0 +1,136 @@ +import type BFFHttpServer from './http/bff-http-server'; +import type { Logger } from './ports/logger-port'; + +import { mkdirSync, writeFileSync } from 'fs'; +import path from 'path'; + +import runCli from './cli-core'; +import { extractErrorMessage } from './errors'; +import { generateOpenApiDocument, serializeOpenApi } from './openapi/openapi-document'; +import version from './version'; + +export const DEFAULT_OUTPUT_FILE = 'openapi.json'; + +const OUTPUT_FLAG = '--output'; + +export const USAGE = `Usage: forest-bff [command] + +Commands: + (none) Start the BFF server, configured from the environment. + openapi Write the OpenAPI document to stdout. Needs no configuration. + --output [file] Write to a file instead of stdout, defaulting + to ${DEFAULT_OUTPUT_FILE} in the current directory. + +Options: + -h, --help Show this help and exit. + -v, --version Show the package version and exit.`; + +export const HINT = "Run 'forest-bff --help' for usage."; + +const HELP_FLAGS = new Set(['-h', '--help']); +const VERSION_FLAGS = new Set(['-v', '--version']); + +export interface DispatchOutcome { + exitCode: number; + server?: BFFHttpServer; +} + +export function printOpenApi( + write: (chunk: string) => void = chunk => process.stdout.write(chunk), +) { + write(`${serializeOpenApi(generateOpenApiDocument(version))}\n`); +} + +function rejectCli(reason: string): DispatchOutcome { + process.stderr.write(`${reason}\n${HINT}\n`); + + return { exitCode: 1 }; +} + +interface OutputOption { + file?: string; + extras: string[]; +} + +function parseOutputOption(rest: string[]): OutputOption { + const index = rest.indexOf(OUTPUT_FLAG); + + if (index === -1) return { extras: rest }; + + const candidate = rest[index + 1]; + const takesValue = candidate !== undefined && candidate !== '' && !candidate.startsWith('-'); + + return { + file: takesValue ? candidate : DEFAULT_OUTPUT_FILE, + extras: [...rest.slice(0, index), ...rest.slice(index + (takesValue ? 2 : 1))], + }; +} + +function writeOpenApiFile(file: string): DispatchOutcome { + const asDirectory = file.endsWith('/') || file.endsWith(path.sep); + const destination = path.resolve( + process.cwd(), + asDirectory ? path.join(file, DEFAULT_OUTPUT_FILE) : file, + ); + + try { + mkdirSync(path.dirname(destination), { recursive: true }); + printOpenApi(chunk => writeFileSync(destination, chunk)); + } catch (error) { + process.stderr.write(`Cannot write ${destination}: ${extractErrorMessage(error)}\n`); + + return { exitCode: 1 }; + } + + process.stderr.write(`Wrote the OpenAPI document to ${destination}\n`); + + return { exitCode: 0 }; +} + +export default async function dispatchCli( + argv: string[], + env: NodeJS.ProcessEnv, + logger?: Logger, +): Promise { + const [subcommand, ...rest] = argv; + + if (subcommand === undefined) { + return { exitCode: 0, server: await runCli(env, logger) }; + } + + if (HELP_FLAGS.has(subcommand)) { + process.stdout.write(`${USAGE}\n`); + + return { exitCode: 0 }; + } + + if (VERSION_FLAGS.has(subcommand)) { + process.stdout.write(`${version}\n`); + + return { exitCode: 0 }; + } + + if (subcommand !== 'openapi') { + return rejectCli( + subcommand === OUTPUT_FLAG + ? `${OUTPUT_FLAG} only applies to the openapi command` + : `Unknown command: ${subcommand}`, + ); + } + + const { file, extras } = parseOutputOption(rest); + + if (extras.length > 0) { + return rejectCli( + `openapi accepts only --output, got: ${extras.map(extra => JSON.stringify(extra)).join(' ')}`, + ); + } + + if (file !== undefined) { + return writeOpenApiFile(file); + } + + printOpenApi(); + + return { exitCode: 0 }; +} diff --git a/packages/agent-bff/src/cli.ts b/packages/agent-bff/src/cli.ts index c3c5209846..5ba6f0d4dd 100644 --- a/packages/agent-bff/src/cli.ts +++ b/packages/agent-bff/src/cli.ts @@ -1,7 +1,12 @@ #!/usr/bin/env node /* istanbul ignore file */ -import runCli, { reportFatalError } from './cli-core'; +import { reportFatalError } from './cli-core'; +import dispatchCli from './cli-dispatch'; if (require.main === module) { - runCli(process.env).catch(reportFatalError); + dispatchCli(process.argv.slice(2), process.env) + .then(({ exitCode }) => { + if (exitCode !== 0) process.exitCode = exitCode; + }) + .catch(reportFatalError); } diff --git a/packages/agent-bff/test/openapi/openapi-cli.test.ts b/packages/agent-bff/test/openapi/openapi-cli.test.ts new file mode 100644 index 0000000000..3b9e919adb --- /dev/null +++ b/packages/agent-bff/test/openapi/openapi-cli.test.ts @@ -0,0 +1,508 @@ +import type BFFHttpServer from '../../src/http/bff-http-server'; +import type { Logger } from '../../src/ports/logger-port'; + +import { mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'fs'; +import http from 'http'; +import { tmpdir } from 'os'; +import path from 'path'; +import request from 'supertest'; + +import dispatchCli, { + DEFAULT_OUTPUT_FILE, + HINT, + USAGE, + printOpenApi, +} from '../../src/cli-dispatch'; +import { issueBffAccessToken } from '../../src/oauth/bff-token'; +import { OPENAPI_PATH } from '../../src/openapi/openapi-routes'; +import version from '../../src/version'; + +const VALID_ENV = { + FOREST_AUTH_SECRET: 'auth-secret', + FOREST_ENV_SECRET: 'env-secret', + FOREST_SERVER_URL: 'https://api.forestadmin.com', + FOREST_APP_URL: 'https://app.forestadmin.com', + AGENT_URL: 'https://agent.example.com', + HTTP_PORT: '0', +} satisfies NodeJS.ProcessEnv; + +const noopLogger: Logger = () => undefined; + +function capture(): { write: (chunk: string) => void; text: () => string } { + const chunks: string[] = []; + + return { write: chunk => chunks.push(chunk), text: () => chunks.join('') }; +} + +describe('printOpenApi', () => { + it('should write a parseable OpenAPI 3.1 document', () => { + const output = capture(); + printOpenApi(output.write); + + expect(JSON.parse(output.text()).openapi).toBe('3.1.0'); + }); + + it('should end with a newline, so the output pipes cleanly', () => { + const output = capture(); + printOpenApi(output.write); + + expect(output.text().endsWith('\n')).toBe(true); + }); +}); + +describe('dispatchCli', () => { + describe('when the openapi subcommand is given', () => { + it('should emit the document, exit 0, and never bind a socket', async () => { + const stdout = jest.spyOn(process.stdout, 'write').mockImplementation(() => true); + const listen = jest.spyOn(http.Server.prototype, 'listen'); + + try { + const outcome = await dispatchCli(['openapi'], {}, noopLogger); + + expect(outcome).toEqual({ exitCode: 0 }); + expect(JSON.parse(stdout.mock.calls[0][0] as string).openapi).toBe('3.1.0'); + expect(listen).not.toHaveBeenCalled(); + } finally { + listen.mockRestore(); + stdout.mockRestore(); + } + }); + + it('should emit the document with no configuration at all, since export needs none', async () => { + const stdout = jest.spyOn(process.stdout, 'write').mockImplementation(() => true); + + try { + await dispatchCli(['openapi'], {}, noopLogger); + + const document = JSON.parse(stdout.mock.calls[0][0] as string); + + expect(Object.keys(document.paths)).toHaveLength(6); + } finally { + stdout.mockRestore(); + } + }); + }); + + describe('when the export is piped to a file or another tool', () => { + it('should write nothing to stdout but the document, since console.info also targets stdout', async () => { + const stdout = jest.spyOn(process.stdout, 'write').mockImplementation(() => true); + const info = jest.spyOn(console, 'info').mockImplementation(() => undefined); + + try { + await dispatchCli(['openapi'], {}, noopLogger); + + expect(info).not.toHaveBeenCalled(); + expect(stdout).toHaveBeenCalledTimes(1); + } finally { + stdout.mockRestore(); + info.mockRestore(); + } + }); + }); + + describe('when no subcommand is given', () => { + it('should boot the server, preserving the packaged bin behavior', async () => { + const outcome = await dispatchCli([], VALID_ENV, noopLogger); + + try { + expect(outcome.exitCode).toBe(0); + expect(outcome.server).toBeDefined(); + } finally { + await outcome.server?.stop(); + } + }); + }); + + describe.each([ + ['--help', '--help'], + ['-h', '-h'], + ])('when %s is given', (_, flag) => { + it('should print usage on stdout and exit 0, as POSIX expects for a help request', async () => { + const stdout = jest.spyOn(process.stdout, 'write').mockImplementation(() => true); + const stderr = jest.spyOn(process.stderr, 'write').mockImplementation(() => true); + + try { + const outcome = await dispatchCli([flag], {}, noopLogger); + + expect(outcome).toEqual({ exitCode: 0 }); + expect(stdout.mock.calls.map(call => call[0]).join('')).toBe(`${USAGE}\n`); + expect(stderr).not.toHaveBeenCalled(); + } finally { + stdout.mockRestore(); + stderr.mockRestore(); + } + }); + }); + + describe.each([ + ['--version', '--version'], + ['-v', '-v'], + ])('when %s is given', (_, flag) => { + it('should print the bare version on stdout and exit 0', async () => { + const stdout = jest.spyOn(process.stdout, 'write').mockImplementation(() => true); + + try { + const outcome = await dispatchCli([flag], {}, noopLogger); + const printed = stdout.mock.calls.map(call => call[0]).join(''); + + expect(outcome).toEqual({ exitCode: 0 }); + expect(printed).toBe(`${version}\n`); + } finally { + stdout.mockRestore(); + } + }); + }); + + describe('when a command fails', () => { + it('should point at --help rather than dump the whole usage on stderr', async () => { + const stderr = jest.spyOn(process.stderr, 'write').mockImplementation(() => true); + + try { + await dispatchCli(['bogus'], {}, noopLogger); + const written = stderr.mock.calls.map(call => call[0]).join(''); + + expect(written).toBe(`Unknown command: bogus\n${HINT}\n`); + expect(written.split('\n')).toHaveLength(3); + } finally { + stderr.mockRestore(); + } + }); + }); + + describe('when openapi is given extra arguments', () => { + it('should name the extra arguments rather than echo the whole command line', async () => { + const stderr = jest.spyOn(process.stderr, 'write').mockImplementation(() => true); + const stdout = jest.spyOn(process.stdout, 'write').mockImplementation(() => true); + + try { + const outcome = await dispatchCli(['openapi', 'extra', 'more'], {}, noopLogger); + + expect(outcome).toEqual({ exitCode: 1 }); + expect(stderr.mock.calls.map(call => call[0]).join('')).toBe( + `openapi accepts only --output, got: "extra" "more"\n${HINT}\n`, + ); + expect(stdout).not.toHaveBeenCalled(); + } finally { + stderr.mockRestore(); + stdout.mockRestore(); + } + }); + }); + + describe.each([ + ['--help', '--help'], + ['--version', '--version'], + ])('when %s is given an extra argument', (_, flag) => { + it('should still answer and exit 0, since GNU tools ignore what follows a help request', async () => { + const stdout = jest.spyOn(process.stdout, 'write').mockImplementation(() => true); + const stderr = jest.spyOn(process.stderr, 'write').mockImplementation(() => true); + + try { + const outcome = await dispatchCli([flag, 'extra'], {}, noopLogger); + + expect(outcome).toEqual({ exitCode: 0 }); + expect(stdout).toHaveBeenCalled(); + expect(stderr).not.toHaveBeenCalled(); + } finally { + stdout.mockRestore(); + stderr.mockRestore(); + } + }); + }); + + describe('when an unknown subcommand is given', () => { + it('should exit non-zero with the usage line on stderr', async () => { + const stderr = jest.spyOn(process.stderr, 'write').mockImplementation(() => true); + + try { + const outcome = await dispatchCli(['bogus'], {}, noopLogger); + + expect(outcome).toEqual({ exitCode: 1 }); + expect(stderr.mock.calls.map(call => call[0]).join('')).toBe( + `Unknown command: bogus\n${HINT}\n`, + ); + } finally { + stderr.mockRestore(); + } + }); + + it('should report it as unknown even with extra arguments, not as taking none', async () => { + const stderr = jest.spyOn(process.stderr, 'write').mockImplementation(() => true); + + try { + const outcome = await dispatchCli(['bogus', 'extra'], {}, noopLogger); + + expect(outcome).toEqual({ exitCode: 1 }); + expect(stderr.mock.calls.map(call => call[0]).join('')).toBe( + `Unknown command: bogus\n${HINT}\n`, + ); + } finally { + stderr.mockRestore(); + } + }); + + it('should not emit a document, so a typo never looks like a successful export', async () => { + const stdout = jest.spyOn(process.stdout, 'write').mockImplementation(() => true); + const stderr = jest.spyOn(process.stderr, 'write').mockImplementation(() => true); + + try { + await dispatchCli(['bogus'], {}, noopLogger); + + expect(stdout).not.toHaveBeenCalled(); + } finally { + stdout.mockRestore(); + stderr.mockRestore(); + } + }); + }); +}); + +describe('dispatchCli --output', () => { + let directory: string; + let originalCwd: string; + + beforeEach(() => { + originalCwd = process.cwd(); + directory = realpathSync(mkdtempSync(path.join(tmpdir(), 'bff-output-'))); + process.chdir(directory); + }); + + afterEach(() => { + process.chdir(originalCwd); + rmSync(directory, { recursive: true, force: true }); + }); + + function expectedDocument(): string { + const output = capture(); + printOpenApi(output.write); + + return output.text(); + } + + async function exportQuietly(argv: string[]) { + const stderr = jest.spyOn(process.stderr, 'write').mockImplementation(() => true); + + try { + return await dispatchCli(argv, {}, noopLogger); + } finally { + stderr.mockRestore(); + } + } + + describe('when --output is given with no value', () => { + it(`should write ${DEFAULT_OUTPUT_FILE} in the current directory and keep stdout empty`, async () => { + const stdout = jest.spyOn(process.stdout, 'write').mockImplementation(() => true); + const stderr = jest.spyOn(process.stderr, 'write').mockImplementation(() => true); + + try { + const outcome = await dispatchCli(['openapi', '--output'], {}, noopLogger); + + expect(outcome).toEqual({ exitCode: 0 }); + expect(readFileSync(path.join(directory, DEFAULT_OUTPUT_FILE), 'utf8')).toBe( + expectedDocument(), + ); + expect(stdout).not.toHaveBeenCalled(); + } finally { + stdout.mockRestore(); + stderr.mockRestore(); + } + }); + + it('should confirm on stderr where the document landed, keeping stdout pipeable', async () => { + const stdout = jest.spyOn(process.stdout, 'write').mockImplementation(() => true); + const stderr = jest.spyOn(process.stderr, 'write').mockImplementation(() => true); + + try { + await dispatchCli(['openapi', '--output'], {}, noopLogger); + + expect(stderr.mock.calls.map(call => call[0]).join('')).toBe( + `Wrote the OpenAPI document to ${path.join(directory, DEFAULT_OUTPUT_FILE)}\n`, + ); + expect(stdout).not.toHaveBeenCalled(); + } finally { + stdout.mockRestore(); + stderr.mockRestore(); + } + }); + }); + + describe('when --output is given a path', () => { + it('should write the document there rather than to the default name', async () => { + const target = path.join(directory, 'nested-name.json'); + + const outcome = await dispatchCli(['openapi', '--output', target], {}, noopLogger); + + expect(outcome).toEqual({ exitCode: 0 }); + expect(readFileSync(target, 'utf8')).toBe(expectedDocument()); + }); + + it(`should treat a trailing slash as a directory and write ${DEFAULT_OUTPUT_FILE} inside it`, async () => { + const outcome = await exportQuietly(['openapi', '--output', 'build/']); + + expect(outcome).toEqual({ exitCode: 0 }); + expect(readFileSync(path.join(directory, 'build', DEFAULT_OUTPUT_FILE), 'utf8')).toBe( + expectedDocument(), + ); + }); + + it('should create a missing parent directory, so a CI path needs no mkdir first', async () => { + const target = path.join(directory, 'docs', 'nested', 'api.json'); + + const outcome = await exportQuietly(['openapi', '--output', target]); + + expect(outcome).toEqual({ exitCode: 0 }); + expect(readFileSync(target, 'utf8')).toBe(expectedDocument()); + }); + + it('should resolve a relative path against the current directory', async () => { + const outcome = await exportQuietly(['openapi', '--output', 'relative.json']); + + expect(outcome).toEqual({ exitCode: 0 }); + expect(readFileSync(path.join(directory, 'relative.json'), 'utf8')).toBe(expectedDocument()); + }); + }); + + describe('when the destination already holds a file', () => { + it('should overwrite it, since an export is expected to refresh the document', async () => { + const target = path.join(directory, 'stale.json'); + writeFileSync(target, '{"openapi":"stale"}'); + + const outcome = await exportQuietly(['openapi', '--output', target]); + + expect(outcome).toEqual({ exitCode: 0 }); + expect(readFileSync(target, 'utf8')).toBe(expectedDocument()); + }); + }); + + describe('when the token after --output starts with a dash', () => { + it('should reject it as an extra and write nothing, never a file named after a flag', async () => { + const stderr = jest.spyOn(process.stderr, 'write').mockImplementation(() => true); + + try { + const outcome = await dispatchCli(['openapi', '--output', '--help'], {}, noopLogger); + + expect(outcome).toEqual({ exitCode: 1 }); + expect(stderr.mock.calls.map(call => call[0]).join('')).toBe( + `openapi accepts only --output, got: "--help"\n${HINT}\n`, + ); + expect(() => readFileSync(path.join(directory, '--help'))).toThrow(); + expect(() => readFileSync(path.join(directory, DEFAULT_OUTPUT_FILE))).toThrow(); + } finally { + stderr.mockRestore(); + } + }); + }); + + describe.each([ + ['an equals form', ['openapi', '--output=out.json'], '--output=out.json'], + ['a doubled flag', ['openapi', '--output', 'a.json', '--output'], '--output'], + ['an empty destination', ['openapi', '--output', ''], ''], + ])('when %s is used', (_, argv, reported) => { + it('should reject it, since the parser accepts one --output with a plain value', async () => { + const stderr = jest.spyOn(process.stderr, 'write').mockImplementation(() => true); + + try { + const outcome = await dispatchCli(argv, {}, noopLogger); + + expect(outcome).toEqual({ exitCode: 1 }); + expect(stderr.mock.calls.map(call => call[0]).join('')).toBe( + `openapi accepts only --output, got: ${JSON.stringify(reported)}\n${HINT}\n`, + ); + } finally { + stderr.mockRestore(); + } + }); + }); + + describe('when the destination cannot be written', () => { + it('should name the path and exit 1 without leaking a stack trace', async () => { + const stderr = jest.spyOn(process.stderr, 'write').mockImplementation(() => true); + + try { + const outcome = await dispatchCli(['openapi', '--output', directory], {}, noopLogger); + const written = stderr.mock.calls.map(call => call[0]).join(''); + + expect(outcome).toEqual({ exitCode: 1 }); + expect(written).toContain(`Cannot write ${directory}: `); + expect(written).not.toContain('at '); + expect(written).not.toContain(HINT); + } finally { + stderr.mockRestore(); + } + }); + }); + + describe('when --output is used as the command itself', () => { + it('should say it belongs to openapi rather than report an unknown command', async () => { + const stderr = jest.spyOn(process.stderr, 'write').mockImplementation(() => true); + + try { + const outcome = await dispatchCli(['--output'], {}, noopLogger); + + expect(outcome).toEqual({ exitCode: 1 }); + expect(stderr.mock.calls.map(call => call[0]).join('')).toBe( + `--output only applies to the openapi command\n${HINT}\n`, + ); + expect(() => readFileSync(path.join(directory, DEFAULT_OUTPUT_FILE))).toThrow(); + } finally { + stderr.mockRestore(); + } + }); + }); + + describe('when no --output is given', () => { + it('should keep writing to stdout and create no file', async () => { + const stdout = jest.spyOn(process.stdout, 'write').mockImplementation(() => true); + + try { + const outcome = await dispatchCli(['openapi'], {}, noopLogger); + + expect(outcome).toEqual({ exitCode: 0 }); + expect(stdout).toHaveBeenCalledTimes(1); + expect(() => readFileSync(path.join(directory, DEFAULT_OUTPUT_FILE))).toThrow(); + } finally { + stdout.mockRestore(); + } + }); + }); +}); + +describe('the CLI export and the HTTP route', () => { + it('should produce the same document, since both use one generator', async () => { + const output = capture(); + printOpenApi(output.write); + + const outcome = await dispatchCli([], VALID_ENV, noopLogger); + + expect(outcome.server).toBeDefined(); + + try { + const response = await request((outcome.server as BFFHttpServer).callback) + .get(OPENAPI_PATH) + .set( + 'Authorization', + `Bearer ${issueBffAccessToken({ + sid: 'session-1', + user: { + id: 1, + email: 'user@example.com', + firstName: 'Ada', + lastName: 'Lovelace', + team: 'Operations', + permissionLevel: 'admin', + role: 'admin', + tags: {}, + renderingId: 1, + }, + renderingId: 1, + authSecret: VALID_ENV.FOREST_AUTH_SECRET, + expiresInSeconds: 900, + })}`, + ); + + expect(output.text()).toBe(`${response.text}\n`); + } finally { + await outcome.server?.stop(); + } + }); +}); diff --git a/packages/agent-bff/test/openapi/openapi-spec-validity.test.ts b/packages/agent-bff/test/openapi/openapi-spec-validity.test.ts index 2f9bff04b3..a823e41679 100644 --- a/packages/agent-bff/test/openapi/openapi-spec-validity.test.ts +++ b/packages/agent-bff/test/openapi/openapi-spec-validity.test.ts @@ -12,9 +12,17 @@ const REDOCLY_BIN = path.join( describe('the generated OpenAPI document', () => { let directory: string; + let status: number | null; + let output: string; beforeAll(() => { directory = mkdtempSync(path.join(tmpdir(), 'bff-openapi-')); + const file = path.join(directory, 'openapi.json'); + writeFileSync(file, serializeOpenApi(generateOpenApiDocument('1.0.0'))); + + const result = spawnSync(process.execPath, [REDOCLY_BIN, 'lint', file], { encoding: 'utf8' }); + status = result.status; + output = `${result.stdout ?? ''}${result.stderr ?? ''}`; }); afterAll(() => { @@ -22,15 +30,15 @@ describe('the generated OpenAPI document', () => { }); it('should pass redocly lint, which is what a consumer runs before generating a client', () => { - const file = path.join(directory, 'openapi.json'); - writeFileSync(file, serializeOpenApi(generateOpenApiDocument('1.0.0'))); - - const result = spawnSync(process.execPath, [REDOCLY_BIN, 'lint', file], { encoding: 'utf8' }); - const output = `${result.stdout ?? ''}${result.stderr ?? ''}`; - - expect({ status: result.status, output }).toEqual({ + expect({ status, output }).toEqual({ status: 0, output: expect.stringContaining('is valid'), }); }); + + it('should warn only that the session scheme is unused, which is deliberate', () => { + expect(output).toContain('You have 1 warning'); + expect(output).toContain('bffSession" is never used'); + expect(output).not.toMatch(/You have \d+ error/); + }); }); From e1eabdbd3b79002535f062cdbffa0bfa5c41aca1 Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Mon, 10 Aug 2026 15:56:08 +0200 Subject: [PATCH 2/2] fix(agent-bff): return the OpenAPI document instead of streaming it --- packages/agent-bff/README.md | 4 +-- packages/agent-bff/src/cli-dispatch.ts | 10 +++--- .../test/openapi/openapi-cli.test.ts | 32 +++++-------------- 3 files changed, 14 insertions(+), 32 deletions(-) diff --git a/packages/agent-bff/README.md b/packages/agent-bff/README.md index a9d435eb7b..4fb6bcbf5e 100644 --- a/packages/agent-bff/README.md +++ b/packages/agent-bff/README.md @@ -5,8 +5,8 @@ agent from a browser without learning MCP or JSON:API. It is a bootable Koa 3 server with a `/health` endpoint, a version header, env-driven config validation, OAuth (Mode 1) + API-key (Mode 2) auth, and a hardened request edge (timezone, CORS, -auth-mode precedence, structured error contract). The data-endpoint proxy and OpenAPI generation -land in later slices. +auth-mode precedence, structured error contract), and it serves and exports its own OpenAPI +document. The data-endpoint proxy lands in a later slice. ## Usage diff --git a/packages/agent-bff/src/cli-dispatch.ts b/packages/agent-bff/src/cli-dispatch.ts index 1e3bfe5f05..061b49b497 100644 --- a/packages/agent-bff/src/cli-dispatch.ts +++ b/packages/agent-bff/src/cli-dispatch.ts @@ -35,10 +35,8 @@ export interface DispatchOutcome { server?: BFFHttpServer; } -export function printOpenApi( - write: (chunk: string) => void = chunk => process.stdout.write(chunk), -) { - write(`${serializeOpenApi(generateOpenApiDocument(version))}\n`); +export function renderOpenApi(): string { + return `${serializeOpenApi(generateOpenApiDocument(version))}\n`; } function rejectCli(reason: string): DispatchOutcome { @@ -75,7 +73,7 @@ function writeOpenApiFile(file: string): DispatchOutcome { try { mkdirSync(path.dirname(destination), { recursive: true }); - printOpenApi(chunk => writeFileSync(destination, chunk)); + writeFileSync(destination, renderOpenApi()); } catch (error) { process.stderr.write(`Cannot write ${destination}: ${extractErrorMessage(error)}\n`); @@ -130,7 +128,7 @@ export default async function dispatchCli( return writeOpenApiFile(file); } - printOpenApi(); + process.stdout.write(renderOpenApi()); return { exitCode: 0 }; } diff --git a/packages/agent-bff/test/openapi/openapi-cli.test.ts b/packages/agent-bff/test/openapi/openapi-cli.test.ts index 3b9e919adb..f87fe9691a 100644 --- a/packages/agent-bff/test/openapi/openapi-cli.test.ts +++ b/packages/agent-bff/test/openapi/openapi-cli.test.ts @@ -11,7 +11,7 @@ import dispatchCli, { DEFAULT_OUTPUT_FILE, HINT, USAGE, - printOpenApi, + renderOpenApi, } from '../../src/cli-dispatch'; import { issueBffAccessToken } from '../../src/oauth/bff-token'; import { OPENAPI_PATH } from '../../src/openapi/openapi-routes'; @@ -28,25 +28,13 @@ const VALID_ENV = { const noopLogger: Logger = () => undefined; -function capture(): { write: (chunk: string) => void; text: () => string } { - const chunks: string[] = []; - - return { write: chunk => chunks.push(chunk), text: () => chunks.join('') }; -} - -describe('printOpenApi', () => { - it('should write a parseable OpenAPI 3.1 document', () => { - const output = capture(); - printOpenApi(output.write); - - expect(JSON.parse(output.text()).openapi).toBe('3.1.0'); +describe('renderOpenApi', () => { + it('should return a parseable OpenAPI 3.1 document', () => { + expect(JSON.parse(renderOpenApi()).openapi).toBe('3.1.0'); }); it('should end with a newline, so the output pipes cleanly', () => { - const output = capture(); - printOpenApi(output.write); - - expect(output.text().endsWith('\n')).toBe(true); + expect(renderOpenApi().endsWith('\n')).toBe(true); }); }); @@ -273,10 +261,7 @@ describe('dispatchCli --output', () => { }); function expectedDocument(): string { - const output = capture(); - printOpenApi(output.write); - - return output.text(); + return renderOpenApi(); } async function exportQuietly(argv: string[]) { @@ -469,8 +454,7 @@ describe('dispatchCli --output', () => { describe('the CLI export and the HTTP route', () => { it('should produce the same document, since both use one generator', async () => { - const output = capture(); - printOpenApi(output.write); + const exported = renderOpenApi(); const outcome = await dispatchCli([], VALID_ENV, noopLogger); @@ -500,7 +484,7 @@ describe('the CLI export and the HTTP route', () => { })}`, ); - expect(output.text()).toBe(`${response.text}\n`); + expect(exported).toBe(`${response.text}\n`); } finally { await outcome.server?.stop(); }