feat(cli): pre-process and mount bundled API specs for CLI generator#15929
Conversation
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
There was a problem hiding this comment.
Claude Code Review
This repository is configured for manual code reviews. Comment @claude review to trigger a review and subscribe this PR to future pushes, or @claude review once for a one-time review.
Tip: disable this comment in your organization's Code Review settings.
|
Devin is archived and cannot be woken up. Please unarchive Devin if you want to continue using it. |
4 similar comments
|
Devin is archived and cannot be woken up. Please unarchive Devin if you want to continue using it. |
|
Devin is archived and cannot be woken up. Please unarchive Devin if you want to continue using it. |
|
Devin is archived and cannot be woken up. Please unarchive Devin if you want to continue using it. |
|
Devin is archived and cannot be woken up. Please unarchive Devin if you want to continue using it. |
| const rawSpecsDir = join(workspaceTempDir.path, RAW_SPECS_DIRECTORY_NAME); | ||
| await mkdir(rawSpecsDir, { recursive: true }); | ||
|
|
||
| const containerSpecsDir = environment.usesContainerPaths ? CONTAINER_RAW_SPECS_DIRECTORY : rawSpecsDir; |
There was a problem hiding this comment.
Critical bug: containerSpecsDir should always be CONTAINER_RAW_SPECS_DIRECTORY regardless of execution environment. When environment.usesContainerPaths is false (local execution), this sets containerSpecsDir to the host path, which causes the manifest to contain host paths instead of container paths. Since the manifest is mounted into the container and read by the generator, it must contain container paths (e.g., /fern/raw-specs/...), not host paths.
// Bug - uses host path when usesContainerPaths is false:
const containerSpecsDir = environment.usesContainerPaths ? CONTAINER_RAW_SPECS_DIRECTORY : rawSpecsDir;
// Fix - always use container path for manifest entries:
const containerSpecsDir = CONTAINER_RAW_SPECS_DIRECTORY;The rawSpecsDir (host path) is correctly used for file I/O operations, but containerSpecsDir should only be used for generating the manifest paths that the container will read.
| const containerSpecsDir = environment.usesContainerPaths ? CONTAINER_RAW_SPECS_DIRECTORY : rawSpecsDir; | |
| const containerSpecsDir = CONTAINER_RAW_SPECS_DIRECTORY; |
Spotted by Graphite
Is this helpful? React 👍 or 👎 to let us know.
There was a problem hiding this comment.
Good catch — fixed in the latest commit. containerSpecsDir now always uses CONTAINER_RAW_SPECS_DIRECTORY since the manifest is read inside the container.
Docs Generation Benchmark ResultsComparing PR branch against median of 5 nightly run(s) on
Docs generation runs |
SDK Generation Benchmark ResultsComparing PR branch against median of 5 nightly run(s) on Full benchmark table (click to expand)
main (generator): generator-only time via --skip-scripts (includes Docker image build, container startup, IR parsing, and code generation — this is the same Docker-based flow customers use via |
| import path from "path"; | ||
|
|
||
| interface RawSpecsManifestEntry { | ||
| type: "openapi" | "protobuf" | "openrpc" | "graphql"; |
There was a problem hiding this comment.
Good catch. AsyncAPI specs currently flow through the OpenAPISpec type (the OpenAPILoader detects them by checking for "asyncapi" in the file contents), so they show up as type: "openapi" in the Spec union. But the manifest should support an explicit asyncapi type for when the generator needs to distinguish them. Adding it now.
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…t copySpecs module Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…ef paths Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Only copy files explicitly declared in generators.yml (spec, overrides, overlays) plus any external $ref targets discovered by parsing the specs. Protobuf roots are still copied as full directories. - Add discoverExternalRefs() that recursively scans YAML/JSON for $ref values - Add collectExternalRefPaths() to walk parsed documents for external $refs - Replace cp(commonRoot) tree copy with per-file copyPathPreservingStructure() - Handle transitive $ref chains, circular refs, missing files gracefully - Add js-yaml dependency for YAML parsing during $ref discovery - Update tests: 38 tests covering targeted copy, $ref discovery, transitive chains, circular refs, URL filtering, missing file handling Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…utting compact JSON Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
8f3ec68 to
cf24aca
Compare
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
|
Devin is archived and cannot be woken up. Please unarchive Devin if you want to continue using it. |
…mkdir Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
| } catch (err: unknown) { | ||
| if (err != null && typeof err === "object" && "code" in err && err.code === "ENOENT") { | ||
| throw new Error(`Spec file not found at mount path: ${containerPath}`); | ||
| } | ||
| } |
There was a problem hiding this comment.
Non-ENOENT errors are silently swallowed instead of being re-thrown. If lstat() fails with a permission error (EACCES) or any other error, the catch block will not throw, leaving isDir = false. The subsequent cp() call on line 80 will then fail with a confusing error message.
Fix: Re-throw non-ENOENT errors:
} catch (err: unknown) {
if (err != null && typeof err === "object" && "code" in err && err.code === "ENOENT") {
throw new Error(`Spec file not found at mount path: ${containerPath}`);
}
throw err;
}| } catch (err: unknown) { | |
| if (err != null && typeof err === "object" && "code" in err && err.code === "ENOENT") { | |
| throw new Error(`Spec file not found at mount path: ${containerPath}`); | |
| } | |
| } | |
| } catch (err: unknown) { | |
| if (err != null && typeof err === "object" && "code" in err && err.code === "ENOENT") { | |
| throw new Error(`Spec file not found at mount path: ${containerPath}`); | |
| } | |
| throw err; | |
| } | |
Spotted by Graphite
Is this helpful? React 👍 or 👎 to let us know.
There was a problem hiding this comment.
Fixed — non-ENOENT errors are now re-thrown instead of being silently swallowed. See ec98fdf.
| disableTelemetry, | ||
| rawApiSpecs: workspace instanceof OSSWorkspace ? workspace.allSpecs : undefined | ||
| rawApiSpecs: | ||
| workspace instanceof OSSWorkspace && generatorInvocation.name === "fernapi/fern-cli" |
There was a problem hiding this comment.
we should make this a helper function with an accept list of generators that want raw API specs
There was a problem hiding this comment.
Done — extracted a generatorWantsRawSpecs() helper backed by a GENERATORS_WANTING_RAW_SPECS accept list (ReadonlySet<string>). New generators can opt in by adding their name to the set. See ec98fdf.
Parses mounted pre-processed specs and prints summary info: - Title, version, endpoint count, schema count per spec - Supports OpenAPI, AsyncAPI (channels), OpenRPC (methods), protobuf, GraphQL - Gracefully handles malformed or missing spec files - 11 unit tests for analyzeSpecs, 2 for formatSpecAnalysis Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
|
Devin is archived and cannot be woken up. Please unarchive Devin if you want to continue using it. |
…ssing Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
| audiences?: Audiences; | ||
| }): Promise<RawSpecsManifestEntry> { | ||
| switch (spec.type) { | ||
| case "openapi": |
There was a problem hiding this comment.
The Spec union type is OpenAPISpec | ProtobufSpec | OpenRPCSpec | GraphQLSpec — there is no separate asyncapi variant. AsyncAPI specs flow through OpenAPISpec (the OpenAPILoader detects them by checking for "asyncapi" in the file content). The resolveOpenAPIOrAsyncAPI handler already detects and handles both via isAsyncAPISpec(), and writes type: "asyncapi" in the manifest when detected (line 144).
The assertNever added in the same commit (ec98fdf) enforces exhaustiveness at compile time — if a new spec variant were added to the Spec union, this switch would fail to compile.
| case "protobuf": | ||
| return copyProtobuf({ spec, hostOutputDir, containerBaseDir, index }); | ||
| case "graphql": | ||
| return copyGraphQL({ spec, hostOutputDir, containerBaseDir, index }); |
There was a problem hiding this comment.
you should always assertNever to enforce exhaustiveness.
There was a problem hiding this comment.
Added assertNever(spec) as the default case. This enforces exhaustiveness at compile time — if a new variant is added to the Spec union, this switch will fail to compile. See ec98fdf.
| disableTelemetry, | ||
| rawApiSpecs: workspace instanceof OSSWorkspace ? workspace.allSpecs : undefined | ||
| rawApiSpecs: | ||
| workspace instanceof OSSWorkspace && generatorInvocation.name === "fernapi/fern-cli" |
…cept list helper, add assertNever Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…unt specs for generators Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Description
Refs FER-9852
Adds plumbing to pre-process API specs (OpenAPI, AsyncAPI, protobuf, OpenRPC, GraphQL) during
fern generateand mount them into generator Docker containers as compact, self-contained JSON files. This enables thefernapi/fern-cligenerator to embed resolved specs and construct the CLI dynamically at runtime.Instead of shipping raw files and requiring generators to resolve
$refs, the CLI pre-processes each spec before mounting: bundles external$refs (via Redocly), merges overrides, applies overlays, filters outx-fern-ignoreoperations and non-matchingx-fern-audiences, and outputs a single compact JSON file per spec. Schemas referenced from multiple external files are deduplicated into#/components/schemas/entries by Redocly's bundler. Protobuf and GraphQL specs are copied as-is since they cannot be meaningfully bundled.Follows the existing protobuf source mounting precedent (
sourceMountsinrunGenerator.ts). NoGeneratorConfigschema changes required.Also adds a hidden
resolve-specsCLI command (fern resolve-specs <path-to-output>) and spec analysis output in the CLI generator that prints basic info (title, version, endpoint count, schema count) about mounted specs.Updates since last revision
raw-specs→specsthroughout: container mount path is now/fern/specs/(was/fern/raw-specs/), constants renamed (SPECS_DIRECTORY_NAME,SPECS_MANIFEST_FILENAME,CONTAINER_SPECS_DIRECTORY,GENERATORS_WANTING_SPECS), helper renamed togeneratorWantsSpecs(), generator-side function renamed tocopySpecs(), all test assertions updated. Internal variable/type names (rawApiSpecs,collectRawSpecs,RawSpecsManifest) are unchanged.Changes Made
rawSpecs.tsmodule —collectRawSpecs()dispatches each spec to a type-specific handler:loadOpenAPI()/loadAsyncAPI()(bundles$refs, merges overrides, applies overlays), then filters viafilterSpec(), then writes compact JSONcoreMergeWithOverrides, writes compact JSON.protoroot directory + any override files as-is.graphqlschema file + any override files as-isassertNeverdefault case for exhaustive type checking onSpecvariantsspecs-manifest.jsonwith container paths for each specfilterSpec()function inrawSpecs.ts— filters resolved OpenAPI/AsyncAPI specs:x-fern-ignore: trueaudiencesisSelectAudiences, removes operations whosex-fern-audiencesdon't overlap with configured audiencesx-fern-audiencesare kept (they are not restricted to any audience)parameters)constants.ts— addedSPECS_DIRECTORY_NAME,SPECS_MANIFEST_FILENAME,CONTAINER_SPECS_DIRECTORY, andgeneratorWantsSpecs()helper (backed byGENERATORS_WANTING_SPECSaccept list). The helper is shared between local generation and seed test paths.runGenerator.ts— accepts optionalrawApiSpecs: Spec[], invokescollectRawSpecswithaudiences, writes manifest, and pushes a Docker source mount for/fern/specs/runLocalGenerationForWorkspace.ts— usesgeneratorWantsSpecs()to gate spec mounting to opted-in generators only (currentlyfernapi/fern-cli)rawApiSpecsthroughGenerationRunner.RunArgs→executeGenerator()→writeFilesToDiskAndRunGenerator(), and extractsallSpecsfromOSSWorkspaceinTestRunner.run()(both cached and uncached workspace paths) so seed tests also receive mounted specsTestRunner.run()usesgeneratorWantsSpecs()to check once, then extracts specs in both paths:workspaceCache.getOrLoadApiWorkspace()to get theAbstractAPIWorkspaceand checksinstanceof OSSWorkspaceinstanceof OSSWorkspaceon the freshly-loadedapiWorkspacebefore conversion toFernWorkspaceContainerTestRunnerandLocalTestRunnerto destructure and passrawApiSpecsthrough to their respective generation functionsgenerators/cli/src/copySpecs.ts— generator-side module that readsspecs-manifest.jsonfrom the mounted/fern/specs/directory, copies spec files, and writes a new manifest with output-relative paths. Throws clear errors onENOENTand re-throws all otherlstaterrors.generators/cli/src/analyzeSpecs.ts— parses mounted specs and extracts metadata (title, version, endpoint count, schema count). Called fromcli.tsto print spec info during generation.generators/cli/src/cli.ts— callsanalyzeSpecsto print spec info, thencopySpecsduring generationresolve-specshidden CLI command —resolveSpecsForWorkspaces.ts+ registration incli.tscollectRawSpecs,filterSpec,generatorWantsSpecs,RawSpecsManifest,RawSpecsManifestEntry,SPECS_MANIFEST_FILENAMEfromlocal-workspace-runner/src/index.tsjs-yamldependency tolocal-workspace-runnerfor YAML parsing in OpenRPC resolutionHuman Review Checklist
All generators get spec mounts— Fixed: gated viageneratorWantsSpecs()accept list helper— Fixed: throws onlstatfailure silently falls backENOENT, re-throws all other errorsNo exhaustive check in spec type switch— Fixed:assertNever(spec)default case addedSeed tests don't receive spec files— Fixed:rawApiSpecsthreaded throughTestRunner→ContainerTestRunner/LocalTestRunner→GenerationRunnerrawprefix —rawApiSpecsparam,collectRawSpecs()function,RawSpecsManifesttype,rawSpecs.tsfilename all still haverawin the name. User-facing paths and constants are consistentlyspecs. Confirm this naming split is acceptable.WorkspaceCacheis used, raw specs are extracted viagetOrLoadApiWorkspace()(returns theAbstractAPIWorkspacebefore conversion). Theinstanceof OSSWorkspaceruntime check determines whetherallSpecsis available. Verify this works correctly for all fixture types (e.g. Fern Definition fixtures that aren'tOSSWorkspace).getParsedDockerImageName().nameused for generator gating in seed tests — The seedTestRunnerusesthis.getParsedDockerImageName().name(parsed fromseed.yml'stest.docker.image) to checkgeneratorWantsSpecs(). Verify this resolves tofernapi/fern-clicorrectly.components/schemas—filterSpeconly removes operations frompaths, not their referenced schemas. Unreferenced schemas are harmless but add size.x-fern-audiencesare kept when audience filtering is active — matches IR generation behavior (untagged operations are "public").RawSpecsManifestEntry/RawSpecsManifestare defined independently in bothrawSpecs.tsandcopySpecs.ts. If these drift, the contract breaks silently.loadAsyncAPIdoes not accept an overlays parameter (pre-existing limitation).$refs won't be resolved. No existing bundler available.Testing
rawSpecs.ts— 14 original + 3 integration (ignore, audiences, all-audiences) + 8filterSpecunit testscopySpecs.tsanalyzeSpecs.tspnpm run check— 0 errors)resolve-specscommand not unit-tested (thin orchestration layer)Link to Devin session: https://app.devin.ai/sessions/a0c0b7f6b17d4c849623df9f75344477
Requested by: @Swimburger