Skip to content

Commit 1aa5379

Browse files
feat(rust): Add RequestExecutor trait for CLI execution-sharing (#16282)
* feat(rust): Add RequestExecutor trait for CLI execution-sharing Add RequestExecutor trait and HttpClient::with_executor() constructor to enable CLI execution-sharing. When an external executor is injected, the SDK delegates HTTP execution entirely — auth, headers, and retries are handled by the caller's transport stack. Also adds cliEmbedded config flag that skips model generation when the SDK is embedded in a CLI binary. * fix(rust): Update http_client snapshot for RequestExecutor changes * fix(rust): Address review findings - restore Clone, fix base64 executor, fix cliEmbedded types - Restore #[derive(Clone)] on HttpClient (needed for pagination code gen) - Update BASE64_METHOD template to use send_request() instead of direct apply_auth_headers/execute_with_retries (bypassed injected executor) - Check cliEmbedded flag in generateApiModFile and generateLibFile to avoid declaring mod types when type generation is skipped * fix(rust): Preserve SSE header precedence over custom headers Move SSE-specific headers (Accept: text/event-stream, Cache-Control: no-store) after apply_custom_headers in the default path so they always take precedence, matching the original ordering. In the executor path, SSE headers are applied before delegation. --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent f23645b commit 1aa5379

411 files changed

Lines changed: 10429 additions & 5461 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

generators/rust/base/src/asIs/http_client.rs

Lines changed: 82 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{{BASE64_IMPORT}}use crate::{join_url, ApiError, ClientConfig, OAuthTokenProvider, RequestOptions};
2-
use futures::{Stream, StreamExt};
2+
use futures::{future::BoxFuture, Stream, StreamExt};
33
use reqwest::{
44
header::{HeaderMap, HeaderName, HeaderValue},
55
Client, Method, Request, Response,
@@ -100,6 +100,34 @@ impl Stream for ByteStream {
100100
}
101101
}
102102

103+
/// Trait for executing HTTP requests, enabling injection of custom
104+
/// transport implementations (e.g., for CLI execution-sharing).
105+
///
106+
/// When an external executor is provided, the SDK delegates raw HTTP
107+
/// execution to it, allowing the caller's transport stack to handle
108+
/// auth, retries, and TLS configuration.
109+
#[doc(hidden)]
110+
pub trait RequestExecutor: Send + Sync {
111+
fn execute(
112+
&self,
113+
request: Request,
114+
) -> BoxFuture<'_, Result<Response, reqwest::Error>>;
115+
}
116+
117+
/// Default executor that delegates to a `reqwest::Client`.
118+
struct ReqwestExecutor {
119+
client: Client,
120+
}
121+
122+
impl RequestExecutor for ReqwestExecutor {
123+
fn execute(
124+
&self,
125+
request: Request,
126+
) -> BoxFuture<'_, Result<Response, reqwest::Error>> {
127+
Box::pin(self.client.execute(request))
128+
}
129+
}
130+
103131
/// Configuration for OAuth token fetching.
104132
///
105133
/// This struct contains all the information needed to automatically fetch
@@ -124,6 +152,7 @@ struct OAuthTokenResponse {
124152
#[derive(Clone)]
125153
pub struct HttpClient {
126154
client: Client,
155+
executor: Option<Arc<dyn RequestExecutor>>,
127156
config: ClientConfig,
128157
/// Optional OAuth configuration for automatic token management
129158
oauth_config: Option<OAuthConfig>,
@@ -151,11 +180,33 @@ impl HttpClient {
151180

152181
Ok(Self {
153182
client,
183+
executor: None,
154184
config,
155185
oauth_config,
156186
})
157187
}
158188

189+
/// Creates an HttpClient with an injected request executor.
190+
///
191+
/// When using an injected executor, the client delegates HTTP execution
192+
/// entirely to the executor. Auth headers, custom headers, and retry
193+
/// logic are NOT applied by this client — the executor's transport
194+
/// stack is expected to handle them. This prevents double-retry and
195+
/// double-auth when the SDK is embedded inside a CLI.
196+
#[doc(hidden)]
197+
pub fn with_executor(
198+
executor: Arc<dyn RequestExecutor>,
199+
config: ClientConfig,
200+
) -> Self {
201+
let client = Client::new();
202+
Self {
203+
client,
204+
executor: Some(executor),
205+
config,
206+
oauth_config: None,
207+
}
208+
}
209+
159210
/// Returns the configured base URL.
160211
pub fn base_url(&self) -> &str {
161212
&self.config.base_url
@@ -199,12 +250,9 @@ impl HttpClient {
199250
request = request.json(&body);
200251
}
201252

202-
let mut req = request.build().map_err(|e| ApiError::Network(e))?;
203-
204-
self.apply_auth_headers(&mut req, &options).await?;
205-
self.apply_custom_headers(&mut req, &options)?;
253+
let req = request.build().map_err(|e| ApiError::Network(e))?;
206254

207-
let response = self.execute_with_retries(req, &options).await?;
255+
let response = self.send_request(req, &options).await?;
208256
self.parse_response_raw(response).await
209257
}
210258

@@ -218,37 +266,28 @@ impl HttpClient {
218266
options: Option<RequestOptions>,
219267
) -> Result<T, ApiError>
220268
where
221-
T: DeserializeOwned, // Generic T: DeserializeOwned means the response will be automatically deserialized into whatever type you specify:
269+
T: DeserializeOwned,
222270
{
223271
let url = join_url(&self.config.base_url, path);
224272
let mut request = self.client.request(method, &url);
225273

226-
// Apply query parameters if provided
227274
if let Some(params) = query_params {
228275
request = request.query(&params);
229276
}
230277

231-
// Apply additional query parameters from options
232278
if let Some(opts) = &options {
233279
if !opts.additional_query_params.is_empty() {
234280
request = request.query(&opts.additional_query_params);
235281
}
236282
}
237283

238-
// Apply body if provided
239284
if let Some(body) = body {
240285
request = request.json(&body);
241286
}
242287

243-
// Build the request
244-
let mut req = request.build().map_err(|e| ApiError::Network(e))?;
288+
let req = request.build().map_err(|e| ApiError::Network(e))?;
245289

246-
// Apply authentication and headers
247-
self.apply_auth_headers(&mut req, &options).await?;
248-
self.apply_custom_headers(&mut req, &options)?;
249-
250-
// Execute with retries
251-
let response = self.execute_with_retries(req, &options).await?;
290+
let response = self.send_request(req, &options).await?;
252291
self.parse_response(response).await
253292
}
254293

@@ -285,16 +324,31 @@ impl HttpClient {
285324
request = request.json(&body);
286325
}
287326

288-
let mut req = request.build().map_err(|e| ApiError::Network(e))?;
289-
290-
self.apply_auth_headers(&mut req, &options).await?;
291-
self.apply_custom_headers(&mut req, &options)?;
327+
let req = request.build().map_err(|e| ApiError::Network(e))?;
292328

293-
let response = self.execute_with_retries(req, &options).await?;
329+
let response = self.send_request(req, &options).await?;
294330
self.parse_response(response).await
295331
}
296332

297-
{{MULTIPART_METHOD}}{{BYTES_METHOD}} async fn apply_auth_headers(
333+
{{MULTIPART_METHOD}}{{BYTES_METHOD}} /// Applies auth/headers and executes the request, choosing between
334+
/// the injected executor path (no SDK-level auth/headers/retries)
335+
/// and the default path (full SDK behavior).
336+
async fn send_request(
337+
&self,
338+
req: Request,
339+
options: &Option<RequestOptions>,
340+
) -> Result<Response, ApiError> {
341+
if let Some(executor) = &self.executor {
342+
executor.execute(req).await.map_err(ApiError::Network)
343+
} else {
344+
let mut req = req;
345+
self.apply_auth_headers(&mut req, options).await?;
346+
self.apply_custom_headers(&mut req, options)?;
347+
self.execute_with_retries(req, options).await
348+
}
349+
}
350+
351+
async fn apply_auth_headers(
298352
&self,
299353
request: &mut Request,
300354
options: &Option<RequestOptions>,
@@ -603,14 +657,9 @@ impl HttpClient {
603657
}
604658

605659
// Build the request
606-
let mut req = request.build().map_err(|e| ApiError::Network(e))?;
660+
let req = request.build().map_err(|e| ApiError::Network(e))?;
607661

608-
// Apply authentication and headers
609-
self.apply_auth_headers(&mut req, &options).await?;
610-
self.apply_custom_headers(&mut req, &options)?;
611-
612-
// Execute with retries
613-
let response = self.execute_with_retries(req, &options).await?;
662+
let response = self.send_request(req, &options).await?;
614663

615664
// Return streaming response
616665
Ok(ByteStream::new(response))
@@ -643,12 +692,9 @@ impl HttpClient {
643692
request = request.json(&body);
644693
}
645694

646-
let mut req = request.build().map_err(|e| ApiError::Network(e))?;
647-
648-
self.apply_auth_headers(&mut req, &options).await?;
649-
self.apply_custom_headers(&mut req, &options)?;
695+
let req = request.build().map_err(|e| ApiError::Network(e))?;
650696

651-
let response = self.execute_with_retries(req, &options).await?;
697+
let response = self.send_request(req, &options).await?;
652698

653699
Ok(ByteStream::new(response))
654700
}

generators/rust/base/src/project/RustProject.ts

Lines changed: 58 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -210,21 +210,25 @@ export class RustProject extends AbstractProject<AbstractRustGeneratorContext<Ba
210210
request = request.multipart(form);
211211
212212
// Build the request
213-
let mut req = request.build().map_err(|e| ApiError::Network(e))?;
214-
215-
// Apply authentication and headers
216-
self.apply_auth_headers(&mut req, &options).await?;
217-
self.apply_custom_headers(&mut req, &options)?;
218-
219-
// Execute directly without retries (multipart requests cannot be cloned)
220-
let response = self.client.execute(req).await.map_err(ApiError::Network)?;
213+
let req = request.build().map_err(|e| ApiError::Network(e))?;
221214
222-
// Check response status
223-
if !response.status().is_success() {
224-
let status_code = response.status().as_u16();
225-
let body = response.text().await.ok();
226-
return Err(ApiError::from_response(status_code, body.as_deref()));
227-
}
215+
// Multipart requests cannot be cloned, so they skip retries
216+
// even in the default path. With an injected executor, delegate
217+
// entirely to the executor.
218+
let response = if let Some(executor) = &self.executor {
219+
executor.execute(req).await.map_err(ApiError::Network)?
220+
} else {
221+
let mut req = req;
222+
self.apply_auth_headers(&mut req, &options).await?;
223+
self.apply_custom_headers(&mut req, &options)?;
224+
let response = self.client.execute(req).await.map_err(ApiError::Network)?;
225+
if !response.status().is_success() {
226+
let status_code = response.status().as_u16();
227+
let body = response.text().await.ok();
228+
return Err(ApiError::from_response(status_code, body.as_deref()));
229+
}
230+
response
231+
};
228232
229233
self.parse_response(response).await
230234
}
@@ -270,12 +274,9 @@ export class RustProject extends AbstractProject<AbstractRustGeneratorContext<Ba
270274
.body(body);
271275
}
272276
273-
let mut req = request.build().map_err(|e| ApiError::Network(e))?;
274-
275-
self.apply_auth_headers(&mut req, &options).await?;
276-
self.apply_custom_headers(&mut req, &options)?;
277+
let req = request.build().map_err(|e| ApiError::Network(e))?;
277278
278-
let response = self.execute_with_retries(req, &options).await?;
279+
let response = self.send_request(req, &options).await?;
279280
self.parse_response(response).await
280281
}
281282
@@ -296,8 +297,8 @@ export class RustProject extends AbstractProject<AbstractRustGeneratorContext<Ba
296297
///
297298
/// # SSE-Specific Headers
298299
///
299-
/// This method automatically sets the following headers **after** applying custom headers,
300-
/// which means these headers will override any user-supplied values:
300+
/// In the default path, these headers are applied **after** custom headers,
301+
/// which means they will override any user-supplied values:
301302
/// - \`Accept: text/event-stream\` - Required for SSE protocol
302303
/// - \`Cache-Control: no-store\` - Prevents caching of streaming responses
303304
///
@@ -358,33 +359,47 @@ export class RustProject extends AbstractProject<AbstractRustGeneratorContext<Ba
358359
// Build the request
359360
let mut req = request.build().map_err(|e| ApiError::Network(e))?;
360361
361-
// Apply authentication and headers
362-
self.apply_auth_headers(&mut req, &options).await?;
363-
self.apply_custom_headers(&mut req, &options)?;
364-
365-
// SSE-specific headers
366-
req.headers_mut().insert(
367-
"Accept",
368-
"text/event-stream"
369-
.parse()
370-
.map_err(|_| ApiError::InvalidHeader)?,
371-
);
372-
req.headers_mut().insert(
373-
"Cache-Control",
374-
"no-store"
375-
.parse()
376-
.map_err(|_| ApiError::InvalidHeader)?,
377-
);
378-
379362
// Determine per-event timeout: request-level overrides client-level
380363
let timeout = options
381364
.as_ref()
382365
.and_then(|opts| opts.timeout_seconds)
383366
.map(std::time::Duration::from_secs)
384367
.unwrap_or(self.config.timeout);
385368
386-
// Execute with retries
387-
let response = self.execute_with_retries(req, &options).await?;
369+
let response = if let Some(executor) = &self.executor {
370+
// SSE-specific headers for the executor path
371+
req.headers_mut().insert(
372+
"Accept",
373+
"text/event-stream"
374+
.parse()
375+
.map_err(|_| ApiError::InvalidHeader)?,
376+
);
377+
req.headers_mut().insert(
378+
"Cache-Control",
379+
"no-store"
380+
.parse()
381+
.map_err(|_| ApiError::InvalidHeader)?,
382+
);
383+
executor.execute(req).await.map_err(ApiError::Network)?
384+
} else {
385+
self.apply_auth_headers(&mut req, &options).await?;
386+
self.apply_custom_headers(&mut req, &options)?;
387+
// SSE-specific headers applied after custom headers to ensure
388+
// proper SSE behavior even if custom headers are provided
389+
req.headers_mut().insert(
390+
"Accept",
391+
"text/event-stream"
392+
.parse()
393+
.map_err(|_| ApiError::InvalidHeader)?,
394+
);
395+
req.headers_mut().insert(
396+
"Cache-Control",
397+
"no-store"
398+
.parse()
399+
.map_err(|_| ApiError::InvalidHeader)?,
400+
);
401+
self.execute_with_retries(req, &options).await?
402+
};
388403
389404
// Return SSE stream with per-event timeout
390405
crate::SseStream::new(response, terminator, timeout).await
@@ -434,14 +449,9 @@ export class RustProject extends AbstractProject<AbstractRustGeneratorContext<Ba
434449
}
435450
436451
// Build the request
437-
let mut req = request.build().map_err(|e| ApiError::Network(e))?;
438-
439-
// Apply authentication and headers
440-
self.apply_auth_headers(&mut req, &options).await?;
441-
self.apply_custom_headers(&mut req, &options)?;
452+
let req = request.build().map_err(|e| ApiError::Network(e))?;
442453
443-
// Execute with retries
444-
let response = self.execute_with_retries(req, &options).await?;
454+
let response = self.send_request(req, &options).await?;
445455
446456
// Parse response as JSON string and decode base64
447457
let text = response.text().await.map_err(ApiError::Network)?;
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
- summary: |
2+
Add `RequestExecutor` trait and `HttpClient::with_executor()` constructor
3+
to enable CLI execution-sharing. When an external executor is injected,
4+
the SDK delegates HTTP execution entirely — auth, headers, and retries
5+
are handled by the caller's transport stack.
6+
type: feat

generators/rust/sdk/src/SdkCustomConfig.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@ import { z } from "zod";
33

44
export const SdkCustomConfigSchema = BaseRustCustomConfigSchema.extend({
55
clientName: z.string().optional(),
6-
generateExamples: z.boolean().optional().default(true)
6+
generateExamples: z.boolean().optional().default(true),
7+
/**
8+
* When true, the SDK is being generated in CLI-embedded mode:
9+
* - Model/type generation is skipped (types come from a co-generated types crate)
10+
* - The `RequestExecutor` trait and `HttpClient::with_executor()` are the primary API surface
11+
*/
12+
cliEmbedded: z.boolean().optional().default(false)
713
});
814

915
export type SdkCustomConfigSchema = z.infer<typeof SdkCustomConfigSchema>;

0 commit comments

Comments
 (0)