Skip to content

Commit d7c1e09

Browse files
authored
feat(cli): generate sdk_glue.rs with sdk_client() + block_on() for custom commands (#16311)
1 parent c08369c commit d7c1e09

39 files changed

Lines changed: 6167 additions & 222 deletions
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
- summary: |
2+
Generate `sdk_glue.rs` module providing `sdk_client()` and `block_on()`
3+
helpers that bridge the CLI's AppContext to the co-generated SDK client.
4+
Custom command handlers can now use typed SDK calls without manual
5+
Cargo.toml edits — the SDK crate is the sole direct dependency and
6+
re-exports all types.
7+
type: feat

generators/cli/src/copySpecs.ts

Lines changed: 73 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,10 @@ export async function copySpecs(args: {
6666
specsDir?: string;
6767
/** When true, emit `mod custom;` + `custom::register(app)` in main.rs. */
6868
embedTypes?: boolean;
69+
/** When true, emit `mod sdk_glue;` in main.rs for the SDK bridge. */
70+
embedSdk?: boolean;
6971
}): Promise<void> {
70-
const { outputDir, binaryName, authBindings, specsDir, embedTypes } = args;
72+
const { outputDir, binaryName, authBindings, specsDir, embedTypes, embedSdk } = args;
7173
const manifest = await readSpecsManifest(specsDir);
7274
if (manifest == null) {
7375
return;
@@ -90,12 +92,18 @@ export async function copySpecs(args: {
9092

9193
await writeFile(
9294
path.join(binDir, "main.rs"),
93-
renderMainRs({ binaryName, entries, authBindings, embedTypes: embedTypes ?? false })
95+
renderMainRs({
96+
binaryName,
97+
entries,
98+
authBindings,
99+
embedTypes: embedTypes ?? false,
100+
embedSdk: embedSdk ?? false
101+
})
94102
);
95103

96104
// Scaffold custom.rs for user-authored command handlers.
97105
if (embedTypes === true) {
98-
await scaffoldCustomRs(binDir, binaryName);
106+
await scaffoldCustomRs(binDir, binaryName, embedSdk ?? false);
99107
}
100108
}
101109

@@ -109,7 +117,7 @@ interface SpecEntry {
109117
* own async command handlers. Listed in `.fernignore` so `fern generate`
110118
* never overwrites user changes.
111119
*/
112-
async function scaffoldCustomRs(binDir: string, binaryName: string): Promise<void> {
120+
async function scaffoldCustomRs(binDir: string, binaryName: string, embedSdk: boolean): Promise<void> {
113121
const customRsPath = path.join(binDir, "custom.rs");
114122
// Only create if it doesn't already exist (respects .fernignore).
115123
try {
@@ -118,27 +126,75 @@ async function scaffoldCustomRs(binDir: string, binaryName: string): Promise<voi
118126
} catch (_e: unknown) {
119127
// does not exist — scaffold it below
120128
}
121-
const typesCrate = `${binaryName.replace(/-/g, "_")}_types`;
122129
const sdkCrate = `${binaryName.replace(/-/g, "_")}_sdk`;
123-
const content = [
130+
const content = embedSdk ? renderCustomRsWithSdk(sdkCrate) : renderCustomRsTypesOnly(binaryName);
131+
await writeFile(customRsPath, content);
132+
}
133+
134+
/** Scaffold when the SDK crate is available (default). */
135+
function renderCustomRsWithSdk(sdkCrate: string): string {
136+
return [
137+
"//! Custom command handlers.",
138+
"//!",
139+
"//! This file is yours to edit — add it to `.fernignore` so",
140+
"//! `fern generate` will never overwrite your changes.",
141+
"//!",
142+
"//! The generated `main.rs` calls `custom::register(app)` at",
143+
"//! startup, composing your commands into the CLI at compile time.",
144+
"//!",
145+
"//! Each handler receives an `AppContext`. Use `sdk_glue::sdk_client(ctx)`",
146+
"//! to get a fully-wired SDK client that inherits the CLI's auth,",
147+
"//! retries, TLS, and global headers. Use `sdk_glue::block_on(future)`",
148+
"//! to run async SDK calls from synchronous handler context.",
149+
`//! Types are available via \`${sdkCrate}::api::*\`.`,
150+
"",
151+
"use fern_cli_sdk::app::CliApp;",
152+
"",
153+
"/// Register custom commands on the CLI app builder.",
154+
"///",
155+
"/// Called from `main.rs` during startup. Uncomment the example",
156+
"/// below and adapt it to your API to get started.",
157+
"pub fn register(app: CliApp) -> CliApp {",
158+
" // Example: typed SDK client usage with the co-generated SDK.",
159+
" //",
160+
` // use ${sdkCrate}::api::*;`,
161+
" //",
162+
" // let app = app.command(",
163+
' // clap::Command::new("get-plant")',
164+
' // .about("Fetch a plant by its ID")',
165+
' // .arg(clap::Arg::new("plant-id").required(true)),',
166+
" // |matches, ctx| {",
167+
' // let plant_id = matches.get_one::<String>("plant-id").unwrap();',
168+
" // let client = super::sdk_glue::sdk_client(ctx);",
169+
" // let plant = super::sdk_glue::block_on(",
170+
" // client.plants.get_plant(plant_id, None),",
171+
" // )?;",
172+
' // println!("{}", serde_json::to_string_pretty(&plant).unwrap());',
173+
" // Ok(())",
174+
" // },",
175+
" // );",
176+
" app",
177+
"}",
178+
""
179+
].join("\n");
180+
}
181+
182+
/** Scaffold when only the types crate is available (embedSdk: false). */
183+
function renderCustomRsTypesOnly(binaryName: string): string {
184+
const typesCrate = `${binaryName.replace(/-/g, "_")}_types`;
185+
return [
124186
"//! Custom command handlers.",
125187
"//!",
126188
"//! This file is yours to edit — add it to `.fernignore` so",
127189
"//! `fern generate` will never overwrite your changes.",
128190
"//!",
129191
"//! The generated `main.rs` calls `custom::register(app)` at",
130192
"//! startup, composing your commands into the CLI at compile time.",
131-
"//! This is the same pattern used by other Fern generators (e.g.",
132-
"//! Ruby's `requirePaths`) — the generated entrypoint references",
133-
"//! this user-owned file, and `.fernignore` keeps it safe across",
134-
"//! regenerations.",
135193
"//!",
136194
"//! Each handler receives an `AppContext` whose `invoke()` and",
137195
"//! `execute()` methods use the CLI's native HTTP executor.",
138196
`//! Combine these with the typed structs from \`${typesCrate}\``,
139197
"//! for strongly-typed request/response serialization.",
140-
`//! The SDK crate (\`${sdkCrate}\`) provides typed client helpers`,
141-
"//! via `ctx.sdk_client()` (available once the runtime handle lands).",
142198
"",
143199
"use fern_cli_sdk::app::CliApp;",
144200
"",
@@ -172,24 +228,20 @@ async function scaffoldCustomRs(binDir: string, binaryName: string): Promise<voi
172228
" // Ok(())",
173229
" // },",
174230
" // );",
175-
" //",
176-
" // SDK client usage (available after the runtime handle lands):",
177-
` // use ${sdkCrate}::prelude::*;`,
178-
" // let client = ctx.sdk_client();",
179231
" app",
180232
"}",
181233
""
182234
].join("\n");
183-
await writeFile(customRsPath, content);
184235
}
185236

186237
function renderMainRs(args: {
187238
binaryName: string;
188239
entries: SpecEntry[];
189240
authBindings: DetectedAuthBinding[];
190241
embedTypes: boolean;
242+
embedSdk: boolean;
191243
}): string {
192-
const { binaryName, entries, authBindings, embedTypes } = args;
244+
const { binaryName, entries, authBindings, embedTypes, embedSdk } = args;
193245

194246
// Separate root-level auth (typed builders) from binding-level auth
195247
const rootAuthBindings = authBindings.filter((b) => b.placement === "root");
@@ -217,6 +269,9 @@ function renderMainRs(args: {
217269

218270
if (embedTypes) {
219271
lines.push("mod custom;");
272+
if (embedSdk) {
273+
lines.push("mod sdk_glue;");
274+
}
220275
lines.push("");
221276
}
222277

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
/**
2+
* Generate `cli/<binaryName>/sdk_glue.rs` — the adapter that bridges
3+
* the CLI's runtime (`AppContext`, `CliExecutor`) to the co-generated
4+
* SDK crate's typed client.
5+
*
6+
* Provides two public helpers for custom command handlers:
7+
*
8+
* - `sdk_client(ctx)` — construct a fully-wired SDK root client that
9+
* routes through the CLI's auth/retry/TLS stack.
10+
* - `block_on(future)` — run an async SDK call from synchronous
11+
* handler context (bridges `ApiError` → `CliError`).
12+
*
13+
* The generated code reads the SDK crate's `src/api/resources/mod.rs`
14+
* to discover the root client struct and its sub-client fields, so the
15+
* construction code is always in sync with the generated SDK.
16+
*/
17+
18+
import { readFile, writeFile } from "fs/promises";
19+
import path from "path";
20+
21+
/** A sub-client field parsed from the root client struct. */
22+
interface SubClientField {
23+
fieldName: string;
24+
typeName: string;
25+
}
26+
27+
/** Root client info parsed from the generated SDK. */
28+
interface RootClientInfo {
29+
name: string;
30+
subClients: SubClientField[];
31+
}
32+
33+
/**
34+
* Parse `src/api/resources/mod.rs` to extract the root client struct
35+
* definition and its sub-client fields.
36+
*
37+
* Expected pattern:
38+
* ```rust
39+
* pub struct ApiClient {
40+
* pub config: ClientConfig,
41+
* pub pets: PetsClient,
42+
* pub auth: AuthClient,
43+
* }
44+
* ```
45+
*/
46+
function parseRootClient(modRsContent: string): RootClientInfo {
47+
const structMatch = modRsContent.match(/pub struct (\w+Client)\s*\{([^}]+)\}/);
48+
if (structMatch == null) {
49+
throw new Error("Could not find root client struct in SDK's api/resources/mod.rs");
50+
}
51+
52+
const name = structMatch[1] ?? "";
53+
const body = structMatch[2] ?? "";
54+
const subClients: SubClientField[] = [];
55+
56+
// Match each `pub <field>: <Type>,` line, skipping `config: ClientConfig`
57+
const fieldRegex = /pub\s+(\w+)\s*:\s*(\w+)\s*,?/g;
58+
let match;
59+
while ((match = fieldRegex.exec(body)) !== null) {
60+
const fieldName = match[1] ?? "";
61+
const typeName = match[2] ?? "";
62+
if (typeName === "ClientConfig") {
63+
continue; // skip the config field
64+
}
65+
subClients.push({ fieldName, typeName });
66+
}
67+
68+
return { name, subClients };
69+
}
70+
71+
/**
72+
* Generate the `sdk_glue.rs` module content.
73+
*/
74+
function renderSdkGlue(sdkCrateSnake: string, rootClient: RootClientInfo): string {
75+
const subClientInits = rootClient.subClients
76+
.map(
77+
(sc) =>
78+
` ${sc.fieldName}: ${sdkCrateSnake}::api::${sc.typeName} { http_client: http_client.clone() },`
79+
)
80+
.join("\n");
81+
82+
return `\
83+
//! Generated SDK client glue — bridges AppContext to the co-generated SDK.
84+
//!
85+
//! Auto-generated by @fern-api/cli-generator. Do not edit by hand.
86+
87+
use std::future::Future;
88+
use std::pin::Pin;
89+
use std::sync::Arc;
90+
91+
use fern_cli_sdk::error::CliError;
92+
use fern_cli_sdk::openapi::AppContext;
93+
use fern_cli_sdk::sdk_executor::{CliExecutor, SdkRequestExecutor};
94+
95+
// ---------------------------------------------------------------------------
96+
// Executor adapter: CliExecutor → SDK RequestExecutor
97+
// ---------------------------------------------------------------------------
98+
99+
struct CliExecutorAdapter(Arc<CliExecutor>);
100+
101+
impl ${sdkCrateSnake}::RequestExecutor for CliExecutorAdapter {
102+
fn execute(
103+
&self,
104+
request: reqwest::Request,
105+
) -> Pin<Box<dyn Future<Output = Result<reqwest::Response, reqwest::Error>> + Send + '_>> {
106+
SdkRequestExecutor::execute(&*self.0, request)
107+
}
108+
}
109+
110+
// ---------------------------------------------------------------------------
111+
// sdk_client — construct a fully-wired SDK root client
112+
// ---------------------------------------------------------------------------
113+
114+
/// Build the SDK root client from the CLI's runtime context.
115+
///
116+
/// The returned client routes all HTTP through the CLI's executor, so
117+
/// it inherits auth, retries, TLS, and global headers automatically.
118+
pub fn sdk_client(ctx: &AppContext) -> ${sdkCrateSnake}::api::${rootClient.name} {
119+
let executor = ctx.build_sdk_executor();
120+
let adapter = Arc::new(CliExecutorAdapter(executor));
121+
let config = ${sdkCrateSnake}::ClientConfig::default();
122+
let http_client = ${sdkCrateSnake}::HttpClient::with_executor(
123+
adapter as Arc<dyn ${sdkCrateSnake}::RequestExecutor>,
124+
config.clone(),
125+
);
126+
${sdkCrateSnake}::api::${rootClient.name} {
127+
config,
128+
${subClientInits}
129+
}
130+
}
131+
132+
// ---------------------------------------------------------------------------
133+
// block_on — async SDK call → sync handler result
134+
// ---------------------------------------------------------------------------
135+
136+
/// Execute an async SDK operation from a synchronous custom-command handler.
137+
///
138+
/// Bridges the SDK's \`ApiError\` into the CLI's \`CliError\` so \`?\` works
139+
/// naturally in handler bodies.
140+
pub fn block_on<F, T>(future: F) -> Result<T, CliError>
141+
where
142+
F: Future<Output = Result<T, ${sdkCrateSnake}::ApiError>>,
143+
{
144+
tokio::task::block_in_place(|| {
145+
let handle = tokio::runtime::Handle::current();
146+
handle.block_on(future).map_err(convert_api_error)
147+
})
148+
}
149+
150+
fn convert_api_error(e: ${sdkCrateSnake}::ApiError) -> CliError {
151+
match e {
152+
${sdkCrateSnake}::ApiError::Http { status, message } => CliError::Api {
153+
code: status,
154+
message,
155+
reason: http_status_reason(status).to_string(),
156+
},
157+
${sdkCrateSnake}::ApiError::Network(err) => {
158+
CliError::Other(anyhow::anyhow!("SDK network error: {err}"))
159+
}
160+
other => CliError::Other(anyhow::anyhow!("SDK error: {other}")),
161+
}
162+
}
163+
164+
fn http_status_reason(status: u16) -> &'static str {
165+
match status {
166+
400 => "badRequest",
167+
401 => "unauthorized",
168+
403 => "forbidden",
169+
404 => "notFound",
170+
408 => "requestTimeout",
171+
409 => "conflict",
172+
429 => "tooManyRequests",
173+
500 => "internalServerError",
174+
502 => "badGateway",
175+
503 => "serviceUnavailable",
176+
504 => "gatewayTimeout",
177+
_ => "httpError",
178+
}
179+
}
180+
`;
181+
}
182+
183+
/**
184+
* Generate `cli/<binaryName>/sdk_glue.rs`.
185+
*
186+
* Must be called AFTER `generateEmbeddedSdk` so the SDK crate's source
187+
* files are on disk.
188+
*/
189+
export async function generateSdkGlue(args: {
190+
outputDir: string;
191+
binaryName: string;
192+
sdkCrateName: string;
193+
}): Promise<void> {
194+
const { outputDir, binaryName, sdkCrateName } = args;
195+
const sdkCrateSnake = sdkCrateName.replace(/-/g, "_");
196+
197+
// Read the SDK's root client definition.
198+
const modRsPath = path.join(outputDir, sdkCrateName, "src", "api", "resources", "mod.rs");
199+
const modRsContent = await readFile(modRsPath, "utf-8");
200+
const rootClient = parseRootClient(modRsContent);
201+
202+
// Write the glue module.
203+
const binDir = path.join(outputDir, "cli", binaryName);
204+
const content = renderSdkGlue(sdkCrateSnake, rootClient);
205+
await writeFile(path.join(binDir, "sdk_glue.rs"), content);
206+
}

0 commit comments

Comments
 (0)